可测试的响应式程序编写指南
在软件开发中,如果所有代码都能轻松进行单元测试,那无疑会让开发过程更加顺利。然而,异步函数给单元测试带来了挑战,尤其是在 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 的测试,以及对响应式流进行有效的测试和重构,能够提高代码的可测试性和可维护性,确保程序的稳定性和正确性。在实际开发中,可以根据具体需求灵活运用这些方法,不断优化测试流程和代码结构。
超级会员免费看

被折叠的 条评论
为什么被折叠?



