RxJS 中的弹珠测试(Marble Testing)完全指南
什么是弹珠测试?
弹珠测试是RxJS提供的一种独特的测试方法,它通过虚拟时间的方式让我们能够同步测试异步的Observable行为。这种方法使用一种称为"弹珠图"(Marble Diagram)的字符串语法来可视化Observable的时间线和事件流。
为什么需要弹珠测试?
在传统的异步测试中,我们需要处理真实的时间流逝,这会导致:
- 测试运行缓慢(需要等待真实时间)
- 测试结果不稳定(时间相关的不确定性)
- 测试代码复杂(需要处理回调、Promise等)
弹珠测试通过虚拟化时间解决了这些问题,让我们能够:
- 同步执行测试(无需等待)
- 确定性测试结果(完全可控的时间线)
- 直观表达预期行为(通过弹珠图)
基本测试结构
import { TestScheduler } from 'rxjs/testing';
const testScheduler = new TestScheduler((actual, expected) => {
// 使用你喜欢的断言库
expect(actual).toEqual(expected);
});
it('测试节流操作符', () => {
testScheduler.run((helpers) => {
const { cold, time, expectObservable } = helpers;
// 创建输入流
const input$ = cold('a---b---c---|');
// 定义节流时间
const throttleDuration = time('---|');
// 预期输出
const expected = 'a-----c---|';
// 测试节流操作
const result$ = input$.pipe(throttleTime(throttleDuration));
// 断言
expectObservable(result$).toBe(expected);
});
});
弹珠图语法详解
基础符号
-
:虚拟时间帧(默认1帧=1毫秒)a-z0-9
:发出的值|
:完成信号#
:错误信号^
:订阅点(仅用于热Observable)!
:取消订阅点
时间推进语法
可以使用更直观的时间单位:
10ms
:10毫秒1s
:1秒0.5m
:30秒
示例:'a 10ms b 1s c|'
表示:
- 第0帧发出a
- 第10帧发出b
- 第1010帧发出c并完成
同步分组
使用()
将多个事件放在同一帧:
(abc)
:在同一帧同步发出a、b、c(a|)
:发出a并立即完成(a#)
:发出a并立即报错
测试工具函数
在testScheduler.run()
回调中,我们可以使用以下工具函数:
-
cold(marble, values?, error?)
创建冷Observable,测试开始时才会激活 -
hot(marble, values?, error?)
创建热Observable(类似Subject),测试前就已经激活 -
expectObservable(actual$, subscription?)
断言Observable的行为 -
expectSubscriptions(subscriptions)
断言订阅/取消订阅的时间点 -
time(marble)
将弹珠图转换为时间数值 -
animate(marble)
模拟requestAnimationFrame调用
实际测试示例
测试简单映射
it('测试map操作符', () => {
testScheduler.run(({ cold, expectObservable }) => {
const input$ = cold('a-b-c-|');
const expected = ' x-y-z-|';
const values = { a: 1, b: 2, c: 3, x: 10, y: 20, z: 30 };
const result$ = input$.pipe(map(n => n * 10));
expectObservable(result$).toBe(expected, values);
});
});
测试延迟
it('测试delay操作符', () => {
testScheduler.run(({ cold, time, expectObservable }) => {
const input$ = cold('a-b-|');
const delayTime = time('--|'); // 2帧
const expected = ' --a-b-|';
const result$ = input$.pipe(delay(delayTime));
expectObservable(result$).toBe(expected);
});
});
测试错误处理
it('测试catchError', () => {
testScheduler.run(({ cold, expectObservable }) => {
const input$ = cold('a-b-#', null, new Error('oops'));
const expected = ' a-b-c|';
const result$ = input$.pipe(
catchError(() => of('c'))
);
expectObservable(result$).toBe(expected);
});
});
高级技巧
测试订阅时间
it('测试晚订阅场景', () => {
testScheduler.run(({ hot, expectObservable }) => {
const source$ = hot('--a--b--c--d--|');
const sub = ' ------^-------!';
const expected = ' ------b--c--d|';
expectObservable(source$, sub).toBe(expected);
});
});
测试副作用
it('测试tap副作用', () => {
let sideEffect = 0;
testScheduler.run(({ cold, expectObservable, flush }) => {
const input$ = cold('a-b-c-|');
const expected = ' a-b-c-|';
const result$ = input$.pipe(
tap(() => sideEffect++)
);
expectObservable(result$).toBe(expected);
flush(); // 确保所有Observable完成
expect(sideEffect).toBe(3);
});
});
常见问题与限制
-
Promise无法测试
弹珠测试只能测试使用RxJS调度器的代码,无法测试原生Promise。 -
零延迟问题
delay(0)
实际上会创建新的macrotask,无法用弹珠测试准确模拟。 -
外部TestScheduler.run()
在run()
回调外部使用时,行为会有差异:- 时间比例不同(1帧=10毫秒)
- 不支持时间推进语法
- 需要显式传递调度器
最佳实践
- 保持测试简洁,每个测试只验证一个行为
- 使用有意义的变量名表示弹珠图中的值
- 垂直对齐输入和预期的弹珠图以提高可读性
- 对复杂的时间操作添加注释说明
- 考虑将常用弹珠模式提取为常量
弹珠测试是RxJS中非常强大的测试工具,虽然初期学习曲线较陡,但一旦掌握,可以极大地提高测试的可靠性和开发效率。通过虚拟时间的概念,我们能够将异步测试转化为同步断言,使测试更加稳定和快速。