构建可测试的响应式程序
在开发响应式程序时,单元测试的效率是一个关键问题。有时候,一个简单的单元测试可能需要很长时间才能运行完成,这会严重影响持续集成(CI)管道的效率。例如,一个使用了
interval()
或
timer()
操作符的单元测试,可能会因为物理时间的存在而运行缓慢。
1. RxJS 中的调度器
在 RxJS 中,时间是通过调度器(scheduler)来管理的。调度器可以控制订阅的开始时间和通知的发布时间,它是 RxJS 抽象时间概念的核心。
调度器通常由以下三个主要部分组成:
-
数据结构
:用于存储所有排队等待执行的动作。
-
执行上下文
:确定动作将在哪里执行,例如定时器、间隔器、立即执行、回调函数或不同的线程(对于服务器端的 Rx 框架)。
-
虚拟时钟
:为自身提供时间概念,这对于测试非常重要。
RxJS 有不同类型的调度器,但它们都遵循相同的接口:
interface Scheduler {
now(): number;
schedule(work, delay?, state?): Subscription;
flush(): void;
active: boolean;
actions: Action[];
scheduledId: number;
}
下面是一个使用调度器同步执行一组动作并将其作为一系列通知刷新的示例:
it('Should schedule things in order', function () {
let stored = [];
let store = state => () => stored.push(state);
let scheduler = Rx.Scheduler.queue;
scheduler.schedule(store(1));
scheduler.schedule(store(2));
scheduler.schedule(store(3));
scheduler.schedule(store(4));
scheduler.schedule(store(5));
scheduler.flush();
expect(stored).to.deep.equal([1, 2, 3, 4, 5]);
});
许多 RxJS 工厂操作符(如
from()
、
generate()
、
range()
等)都有一个额外的参数,用于指定调度器。对于同步数据源,通常使用
null
值,以便立即传递通知。而
AsapScheduler
和
AsyncScheduler
则常用于延迟(异步)动作。
2. 调度器对数据流的影响
让我们看一个简单的
range
可观察对象的例子,它将发出的值推送到一个外部数组中:
it('Emits values synchronously on default scheduler', function () {
let temp = [];
Rx.Observable.range(1, 5)
.do([].push.bind(temp))
.subscribe(value => {
expect(temp).to.have.length(value);
expect(temp).to.contain(value);
});
});
这个流使用默认调度器,因此测试断言
range()
发出的每个值都被推送到
temp
数组中,并立即传播到订阅者。现在,我们将发布值的调度器更改为
AsyncScheduler
:
it('Emits values on an asynchronous scheduler', function (done) {
let temp = [];
Rx.Observable.range(1, 5, Rx.Scheduler.async)
.do([].push.bind(temp))
.subscribe(value => {
expect(temp).to.have.length(value);
expect(temp).to.contain(value);
}, done, done);
});
由于这是一个异步流,我们需要使用
done()
解析回调来告诉 Mocha 等待所有值发出。通过使用调度器,我们可以操纵时间在流中的流动,并控制事件的发布方式。
3. 虚拟时间与大理石图
为了解决长时间运行的单元测试问题,我们可以使用虚拟时间调度器,如
Rx.TestScheduler
。它可以创建时间,并与大理石图(marble diagram)密切相关。
大理石图是一种可视化工具,用于表示可观察对象在时间上的行为。每个事件都被包装在一个
Notification
对象中,该对象携带了事件的所有必要元数据。
下面是一个将大理石字符串解析为一系列通知的示例:
it('Should parse a marble string into a series of notifications',
function () {
let result = Rx.TestScheduler.parseMarbles(
'--a---b---|',
{ a: 'A', b: 'B' });
expect(result).deep.equal([
{ frame: 20, notification: Rx.Notification.createNext('A') },
{ frame: 60, notification: Rx.Notification.createNext('B') },
{ frame: 100, notification: Rx.Notification.createComplete() }
]);
});
我们可以使用
TestScheduler
来验证大理石图的正确性。例如,测试
map()
操作符:
function square(x) {
return x * x;
}
function assertDeepEqual(actual, expected) {
expect(actual).to.deep.equal(expected);
}
describe('Map operator', function () {
it('Should map multiple values', function () {
let scheduler = new Rx.TestScheduler(assertDeepEqual);
let source = scheduler.createColdObservable(
'--1--2--3--4--5--6--7--8--9--|');
let expected = '--a--b--c--d--e--f--g--h--i--|';
let r = source.map(square);
scheduler.expectObservable(r).toBe(expected,
{ 'a': 1, 'b': 4, 'c': 9, 'd': 16, 'e': 25,
'f': 36, 'g':49, 'h': 64, 'i': 81});
scheduler.flush();
});
});
通过使用大理石图,我们可以直观地测试流的行为。此外,我们还可以使用虚拟调度器来测试基于时间的操作,如
debounceTime()
:
describe('Marble test with debounceTime', function () {
it('Should delay all element by the specified time', function () {
let scheduler = new Rx.TestScheduler(assertDeepEqual);
let source = scheduler.createHotObservable(
'-a--------b------c----|');
let expected = '------a--------b------(s|)';
let r = source.debounceTime(50, scheduler);
scheduler.expectObservable(r).toBe(expected);
scheduler.flush();
});
});
4. 加速单元测试
我们可以利用虚拟时间调度器来加速基于
interval()
的长时间运行的单元测试。例如,模拟一个一秒的间隔,我们可以使用 10 毫秒的模拟间隔:
it('Should square and add even numbers', function () {
let scheduler = new Rx.TestScheduler(assertDeepEqual);
let source = scheduler.createColdObservable(
'-1-2-3-4-5-6-7-8-9-|');
let expected = '-------------------(s-|';
let r = runInterval(source);
scheduler.expectObservable(r).toBe(expected, {'s': 120});
scheduler.flush();
});
5. 重构搜索流以提高可测试性
对于使用
debounceTime()
操作的搜索组件,我们可以通过重构代码来提高其可测试性。首先,将流拆分为一个可传递虚拟可观察流的
source$
和一个负责从 Wikipedia 获取结果的搜索流
fetchResult$
:
const search$ = (source$, fetchResult$, url = '', scheduler = null) =>
source$
.debounceTime(500, scheduler)
.filter(notEmpty)
.do(term => console.log(`Searching with term ${term}`))
.map(query => url + query)
.switchMap(fetchResult$);
然后,我们可以使用虚拟调度器来测试搜索流的防抖效果:
function frames(n = 1, unit = '-') {
return (n === 1) ? unit :
unit + frames(n - 1, unit);
}
describe('Search component', function () {
const results_1 = [
'rxmarbles.com',
'https://www.manning.com/books/rxjs-in-action'
];
const results_2 =
['https://www.manning.com/books/rxjs-in-action'];
const searchFn = term => {
let r = [];
if(term.toLowerCase() === 'rx') {
r = results_1;
}
else if (term.toLowerCase() === 'rxjs') {
r = results_2;
}
return Rx.Observable.of(r);
};
it('Should test the search stream with debouncing', function () {
let searchTerms = {
a: 'r',
b: 'rx',
c: 'rxjs',
};
let scheduler = new Rx.TestScheduler(assertDeepEqual);
let source = scheduler.createHotObservable(
'-(ab)-' + frames(50) +'-c|', searchTerms);
let r = search$(source, searchFn, '', scheduler);
let expected = frames(50) + '-f------(s|)';
scheduler.expectObservable(r).toBe(expected,
{
// 结果集期望
});
scheduler.flush();
});
});
通过使用调度器和虚拟时间,我们可以大大提高单元测试的效率,并确保测试覆盖整个流的行为。这种方法不仅可以加速测试过程,还可以提高代码的可维护性和可测试性。
构建可测试的响应式程序
6. 调度器与异步编程的优势总结
在异步编程中,调度器发挥着至关重要的作用。下面通过表格形式总结调度器在不同场景下的优势:
| 场景 | 未使用调度器 | 使用调度器 |
| ---- | ---- | ---- |
| 单元测试效率 | 长时间运行的测试可能使 CI 管道失效,如使用
interval()
或
timer()
的测试需等待物理时间 | 可使用虚拟时间调度器(如
Rx.TestScheduler
)加速测试,将物理时间转换为虚拟时间,测试瞬间完成 |
| 事件发布控制 | 难以控制事件的发布时间和顺序,默认同步或异步机制固定 | 可通过传递不同调度器(如
AsyncScheduler
)控制事件同步或异步发布,还能使用
observeOn()
操作符在流中改变事件发布方式 |
| 复杂操作测试 | 基于时间的操作(如
debounceTime()
)测试复杂,需手动添加时间戳 | 利用大理石图和虚拟调度器可直观测试,模拟操作效果并验证结果 |
7. 实际应用中的操作步骤
在实际开发中,若要应用调度器和虚拟时间进行单元测试,可遵循以下步骤:
1.
引入调度器
:在项目中引入 RxJS 的调度器相关类和方法,如
Rx.Scheduler
和
Rx.TestScheduler
。
2.
创建调度器实例
:根据需求创建不同类型的调度器实例,如
let scheduler = new Rx.TestScheduler(assertDeepEqual);
。
3.
使用调度器控制流
:
- 在可观察对象的工厂操作中传递调度器参数,如
Rx.Observable.range(1, 5, Rx.Scheduler.async)
。
- 使用
observeOn()
操作符在流中改变事件发布方式,如
Rx.Observable.range(1, 5).do([].push.bind(temp)).observeOn(Rx.Scheduler.async).subscribe(...)
。
4.
使用大理石图创建期望
:
- 使用
scheduler.createColdObservable()
或
scheduler.createHotObservable()
创建可观察对象,如
let source = scheduler.createColdObservable('--1--2--3--4--5--6--7--8--9--|');
。
- 定义期望的大理石图,如
let expected = '--a--b--c--d--e--f--g--h--i--|';
。
- 通过
scheduler.expectObservable(r).toBe(expected, {...})
设置期望并验证结果。
5.
执行测试
:调用
scheduler.flush()
触发流的执行并完成测试。
8. 流程图展示测试流程
graph TD;
A[引入调度器] --> B[创建调度器实例];
B --> C[使用调度器控制流];
C --> D[使用大理石图创建期望];
D --> E[执行测试];
9. 调度器在不同平台的应用差异
在不同平台上,调度器的应用有所不同。在 JavaScript 的单线程环境中,通常使用默认调度器,较少选择其他调度器。但在服务器端实现的 Rx 家族(如 Rx.Net 或 RxJava)中,调度器可将繁重的处理任务卸载到不同线程,同时保持活动的 UI 线程空闲以响应用户操作。
10. 代码可维护性与可测试性提升
通过使用调度器和虚拟时间,代码的可维护性和可测试性得到显著提升。例如,在搜索组件的重构中,将流拆分为
source$
和
fetchResult$
,使得测试可以独立于 DOM 事件和第三方 API 进行,只关注业务逻辑。这种模块化的设计使得代码更易于理解和修改,同时测试用例也更加稳定和可靠。
总的来说,调度器和虚拟时间为响应式程序的单元测试提供了强大的工具。它们使得我们能够在不依赖物理时间的情况下,高效地测试异步流的行为,确保代码的质量和稳定性。无论是简单的操作符测试,还是复杂的搜索流测试,都可以通过合理运用这些技术得到有效的解决。在未来的开发中,我们应该充分利用调度器和虚拟时间的优势,构建更加健壮和可维护的响应式程序。
超级会员免费看

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



