27、可测试的响应式程序编写指南

可测试的响应式程序编写指南

在软件开发中,如果所有代码都能轻松进行单元测试,那无疑会让开发过程更加顺利。然而,异步函数给单元测试带来了挑战,尤其是在 JavaScript 应用中,异步行为极为常见。本文将介绍如何使用 Mocha 来测试这类程序,包括异步代码、Promise 以及响应式流,并探讨如何让流更易于测试。

测试异步代码和 Promise

异步代码给编写单元测试带来了难题。虽然 Mocha 设计为按顺序运行各个测试用例,但如何让它等待长时间运行的计算完成,而不是同步地快速遍历整个测试套件呢?下面将介绍两种测试场景:直接调用 AJAX 请求和使用 Promise。

测试 AJAX 请求

以一个简单的 ajax 函数为例:

const ajax = (url, success, error) => {
  let req = new XMLHttpRequest();
  req.responseType = 'json';
  req.open('GET', url);
  req.onload = function() {
    if(req.status == 200) {
      let data = JSON.parse(req.responseText);
      success(data);
    }
    else {
      req.onerror();
    }
  }
  req.onerror = function () {
    if(error) {
      error(new Error('IO Error'));
    }
  };
  req.send();
};

使用 Mocha 为这个函数设置单元测试:

describe('Asynchronous Test', function () {
  it('Should fetch Wikipedia pages for search term + "reactive programming"', function() { 
    const searchTerm = 'reactive+programming';
    const url = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch=${searchTerm}`;
    let result = undefined;
    ajax(url, response => {
      result = response;
    });
    expect(result).to.not.be.undefined;
  });  
});

运行这个测试会发现,虽然看起来逻辑简单,但实际运行会报错 AssertionError: expected undefined not to be undefined 。原因是单元测试没有意识到这是一个异步操作,它会同步执行所有语句,而忽略了 HTTP 请求的延迟。

Mocha 为异步函数测试提供了很好的支持,只需在 it() 的回调函数中传入 done() 函数,Mocha 就会等待该函数被调用。以下是一个测试 ajax 函数成功和错误情况的测试套件:

const assert = chai.assert;
describe('Ajax test', function () {
  it('Should fetch Wikipedia pages for search term + "reactive programming"', 
    function (done) {
      const searchTerm = 'reactive+programming';
      const url = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch=${searchTerm}`;
      const success = results => {
        expect(results)
          .to.have.property('query')
          .with.property('search')
          .with.length(10);
        done();
      };
      const error = (err) => {
        done(err);
      };
      ajax(url, success, error);
    });  
  it('Should fail for invalid URL', function (done) {
    const url = 'invalid-url';  
    const success = data => {
      done(new Error('Should not have been successful!'));
    };
    const error = (err) => {
      expect(err).to.have.property('message').to.equal('IO Error');
      done();
    };
    ajax(url, success, error);
  }); 
});

这个测试套件包含两个测试用例,一个测试成功的 AJAX 查询返回的 Wikipedia 响应对象,另一个测试无效 URL 时的错误情况。

使用 Promise

使用 Promise 包装操作是更具函数式编程风格的做法,因为它提供了对时间因素的抽象,许多第三方库也使用 Promise 包装其 API。以下是将 ajax 函数重构为使用 Promise 的代码:

const ajax = url => new Promise((resolve, reject) => {  
  let req = new XMLHttpRequest();
  req.responseType = 'json';
  req.open('GET', url);
  req.onload = () => {
    if(req.status == 200) {
      let data = JSON.parse(req.responseText);
      resolve(data);  
    }
    else {
      reject(new Error(req.statusText)); 
    }
  };
  req.onerror = () => {
    reject(new Error('IO Error'));  
  };
  req.send();
});

然后,让 Chai 使用 Promise 扩展并加载 should.js API:

chai.use(chaiAsPromised);
const should = chai.should();

以下是使用 Promise 进行异步测试的代码:

describe('Ajax with promises', function () {
  it('Should fetch Wikipedia pages for search term + "reactive programming"', function () { 
    const searchTerm = 'reactive+programming';
    const url = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch=${searchTerm}`;
    return ajax(url)
     .should.be.fulfilled
     .should.eventually.have.property('query')
       .with.property('search')
       .with.length(10); 
  });  
});

与之前的测试相比,这个测试可以直接处理 ajax() 返回的 Promise,使用 Mocha 编写的测试更加清晰和流畅。

以下是一个使用 Promise 的搜索流示例:

const search$ = Rx.Observable.fromEvent(inputText, 'keyup')  
  .debounceTime(500)
  .pluck('target','value')   
  .filter(notEmpty)
  .do(term => console.log(`Searching with term ${term}`))  
  .map(query => URL + query)
  .switchMap(query => 
    Rx.Observable.fromPromise(ajax(query))
    .pluck('query', 'search')
    .defaultIfEmpty([]))  
  .do(result => {
    count.innerHTML = `${result.length} results`;
  })
  .subscribe(arr => { 
    clearResults(results);  
    appendResults(results, arr); 
  });

在这个搜索流中, switchMap() 调用会使测试变得复杂,但由于大部分数据流逻辑由可观察对象本身处理,我们只需测试自己的函数是否按预期工作。

测试响应式流

响应式测试与测试普通函数式程序类似。由于可观察对象是纯函数式数据类型,如果一个可观察对象由纯函数组成,那么整个可观察序列也是纯的。

以下是一个同步添加数组中数字的冷可观察对象测试示例:

describe('Adding numbers', function () {
  it('Should add numbers together', function () {
    const adder = (total, delta) => total + delta;
    Rx.Observable.from([1, 2, 3, 4, 5, 6, 7, 8, 9])
     .reduce(adder)
     .subscribe(total => {
      expect(total).to.equal(45);
    });
  });
});

由于可观察对象的语义是基于生产者/消费者模型设计的,因此可以将所有断言放在下游观察者中,这在逻辑上是合理的,因为这是数据流的结果所在。

以下是使用生成器的类似测试:

it('Should add numbers from a generator', function () {
  const adder = (total, delta) => total + delta;
  function* numbers() {
    let start = 0;
    while(true) {
      yield start++;
    }
  }
  Rx.Observable.from(numbers)
   .take(10)
   .reduce(adder)
   .subscribe(total => {
    expect(total).to.equal(45);
  });
});

可以看到,测试同步可观察对象就像测试普通纯函数一样简单。

但如果在测试中引入时间延迟,情况就会变得复杂:

it('Should add numbers together with delay', function () { 
  Rx.Observable.from([1, 2, 3, 4, 5, 6, 7, 8, 9])
   .reduce((total, delta) => total + delta)
   .delay(1000)
   .subscribe(total => {    
    expect(total).to.equal(45);
  });
});  

运行这个测试会发现,虽然看起来测试通过了,但实际上 subscribe() 块并没有执行,因为它在一秒后才会执行,而测试在异步块完成之前就报告了完成,导致得到了一个假阳性结果。

为了解决这个问题,需要在 it() 回调中使用 done() 函数:

it('Should add numbers together with delay', function (done) {
  Rx.Observable.from([1, 2, 3, 4, 5, 6, 7, 8, 9])
   .reduce((total, delta) => total + delta)
   .delay(1000)
   .subscribe(total => {    
    expect(total).to.equal(45);
  }, null, done);
});  

运行这个测试会发现,Mocha 会等待测试完成并执行断言。

以下是测试搜索流中异步 Promise 可观察对象的示例:

it('Should fetch Wikipedia pages for search term "reactive programming" using an observable + promise', function (done) {
  const searchTerm = 'reactive+programming';
  const url = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&utf8=1&srsearch=${searchTerm}`;
  const testFn = query => Rx.Observable.fromPromise(ajax(query))
    .subscribe(data => {
      expect(data).to.have.property('query')
        .with.property('search')
        .with.length(10);
    }, null, done);
  testFn(url);
});  

测试流程总结

测试类型 操作步骤
测试 AJAX 请求 1. 定义 ajax 函数;2. 使用 Mocha 设置单元测试;3. 处理异步问题,传入 done() 函数;4. 编写成功和错误情况的测试用例
测试响应式流 1. 编写同步可观察对象测试;2. 处理时间延迟问题,使用 done() 函数;3. 测试异步 Promise 可观察对象

响应式流测试流程图

graph TD;
    A[开始] --> B[编写同步可观察对象测试];
    B --> C{是否有时间延迟};
    C -- 是 --> D[使用 done() 函数];
    C -- 否 --> E[正常测试];
    D --> F[测试异步 Promise 可观察对象];
    E --> F;
    F --> G[结束];
让流可测试

为了提高可测试性和可复用性,需要将观察者、管道和订阅分离。将这些主要部分解耦,可以根据被测试的流注入所需的断言,避免在应用程序和单元测试中修改或重建可观察序列,防止代码重复。

以一个简单的程序为例,该程序每秒生成 10 个连续数字,并对所有偶数进行平方求和:

Rx.Observable.interval(1000)
    .take(10)
    .filter(num => num % 2 === 0)
    .map(num => num * num)
    .reduce((total, delta) => total + delta)
    .subscribe(console.log);

为了使这个程序可测试,需要完成以下几个步骤:
1. 分离业务逻辑 :将业务逻辑从可观察管道中分离出来,使函数可以独立于流进行测试。
2. 解耦生产者和消费者 :将生产者、管道和消费者分离,允许注入断言代码。
3. 封装流函数 :将流封装到一个函数中,以便使用适当的观察者调用。

按照这些步骤重构后的代码如下:

const isEven = num => num % 2 === 0;
const square = num => num * num;
const add = (a, b) => a + b;
const runInterval = (source$) =>
  source$
    .take(10)
    .filter(isEven)
    .map(square)
    .reduce(add);

isEven() square() add() 函数很容易测试,这里重点关注可观察对象的测试。由于可观察对象是前馈、单向的流,依赖于无副作用的函数,可以将整个流视为纯函数。

在测试中调用重构后的流函数,并传入生产者,将断言放在 subscribe 块中:

it('Should square and add even numbers', function (done) { 
  this.timeout(20000);
  runInterval(Rx.Observable.interval(1000))
    .subscribe({
      next: total => expect(total).to.equal(120),
      err:  err   => assert.fail(err.message),
      complete: done 
    });
});  

生产者和订阅者是这个纯流的边界,通过确保函数正常工作并信任 RxJS 的处理,可以对测试结果有信心。同时,参数化观察者可以将流的输出导向不同的目标,如断言、控制台、文件系统、HTML 页面或数据库等。

运行这段代码会输出:

 Should square and add even numbers (10032ms)

可测试流重构步骤总结

步骤 操作
1 分离业务逻辑,将函数从可观察管道中独立出来
2 解耦生产者、消费者和管道,便于注入断言代码
3 封装流为函数,通过传入不同生产者进行测试

可测试流重构流程图

graph TD;
    A[原始可观察流] --> B[分离业务逻辑];
    B --> C[解耦生产者和消费者];
    C --> D[封装流为函数];
    D --> E[编写测试代码];
    E --> F[运行测试];
    F --> G{测试是否通过};
    G -- 是 --> H[完成];
    G -- 否 --> B;

综上所述,通过合理运用 Mocha 进行异步代码和 Promise 的测试,以及对响应式流进行有效的测试和重构,能够提高代码的可测试性和可维护性,确保程序的稳定性和正确性。在实际开发中,可以根据具体需求灵活运用这些方法,不断优化测试流程和代码结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值