RxJS Marble Testing 怎么写?弹珠测试核心用法与面试要点
什么是 Marble Testing
RxJS 的异步数据流测试一直是前端开发中的难点——回调嵌套、定时器模拟、异步断言让测试代码既冗长又脆弱。Marble Testing 是 RxJS 官方提供的一种解决方案:用简短的字符串(称为 marble 弹珠字符串)可视化地描述 Observable 的时间线和事件,再由 TestScheduler 在虚拟时间中同步执行,把原本需要等待真实异步的测试变成瞬时可验证的同步断言。
一句话概括:Marble Testing = 弹珠字符串 + TestScheduler = 用可视化语法写同步的异步测试。
Marble 语法速查
核心符号
| 符号 | 含义 | 示例 |
|---|---|---|
- | 时间流逝(1 帧,约 10ms) | --- 表示 30ms |
a-z | 发出的值 | -a-b- 发出 a 和 b |
| | 完成 | -a-b-| 发出后完成 |
# | 错误 | -a-# 发出 a 后抛错 |
() | 同步分组 | (abc|) 同步发出 a、b、c 后完成 |
^ | 订阅点(hot Observable) | ^-a-b- 从订阅点开始接收 |
! | 取消订阅 | ^-a-! 订阅后收到 a 就取消 |
常见 marble 字符串解读
typescript// 冷 Observable:从订阅时开始 cold('-a-b-c-|') // → 10ms 发出 a,20ms 发出 b,30ms 发出 c,40ms 完成 cold('-a-b-#') // → 10ms 发出 a,20ms 发出 b,30ms 报错 cold('(abc|)') // → 同步发出 a、b、c,然后立即完成 // 热 Observable:从 ^ 标记处开始接收 hot('--a--b--c--|', { a: 1, b: 2, c: 3 }) // → ^ 之前的历史值对新订阅者不可见
TestScheduler 基本用法
初始化 TestScheduler
typescriptimport { TestScheduler } from 'rxjs/testing'; let testScheduler: TestScheduler; beforeEach(() => { testScheduler = new TestScheduler((actual, expected) => { // 深比较实际输出与期望输出 expect(actual).toEqual(expected); }); });
关键点:
run()回调内提供的cold、hot、expectObservable、expectSubscriptions是测试的四大工具,不要在run()外部使用它们。
测试 map 操作符
typescriptit('应将每个值转为大写', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|', { a: 'hello', b: 'world', c: 'rxjs' }); const expected = '-A-B-C-|'; const result$ = source$.pipe(map(x => x.toUpperCase())); expectObservable(result$).toBe(expected, { A: 'HELLO', B: 'WORLD', C: 'RXJS' }); }); });
为什么要传值映射?当 marble 字符串中的字母与实际值不同时,必须通过第二个参数映射,否则默认值就是字母本身。
面试高频:时间类操作符测试
时间相关操作符是 Marble Testing 最核心的应用场景,因为传统方式很难精确控制时间。
delay
typescriptit('应延迟 30ms 发出值', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-|'); const expected = '---a-b-|'; const result$ = source$.pipe(delay(30, testScheduler)); expectObservable(result$).toBe(expected); }); });
debounceTime
typescriptit('应在 20ms 无新值后才发出', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a--b--c---|'); const expected = '-----b--c---|'; const result$ = source$.pipe(debounceTime(20, testScheduler)); expectObservable(result$).toBe(expected); }); });
throttleTime
typescriptit('应每 30ms 最多发出一个值', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-ab-cde-f-|'); const expected = '-a---d--f-|'; const result$ = source$.pipe(throttleTime(30, testScheduler)); expectObservable(result$).toBe(expected); }); });
面试追问:debounceTime 和 throttleTime 的区别?前者等"安静期"再发,后者等"冷却期"再放行——两者在 marble 图上表现为截然不同的输出模式。
组合操作符的测试
merge:交错合并
typescriptit('应交错合并两个流', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a---b-|'); const source2$ = cold('--c-d---|'); const expected = '-a-c-b-d-|'; const result$ = merge(source1$, source2$); expectObservable(result$).toBe(expected); }); });
concat:顺序拼接
typescriptit('应顺序拼接两个流', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a-b-|'); const source2$ = cold('--c-d-|'); const expected = '-a-b--c-d-|'; const result$ = concat(source1$, source2$); expectObservable(result$).toBe(expected); }); });
combineLatest:取最新组合
typescriptit('应在任一流发出时组合最新值', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a---b-|', { a: 1, b: 2 }); const source2$ = cold('--c-d---|', { c: 10, d: 20 }); const expected = '----xy-z|'; const result$ = combineLatest([source1$, source2$]); expectObservable(result$).toBe(expected, { x: [1, 20], y: [2, 20], z: [2, 20] }); }); });
面试追问:combineLatest 为什么第一个输出是
[1, 20]而不是[1, 10]?因为 combineLatest 要求每个源至少发出一次后才开始组合——source1$ 发出 a=1 时 source2$ 还没发出过值,直到 source2$ 发出 d=20 时两个流才都有值,此时组合的是 source1$ 的最新值 1 和 source2$ 的最新值 20。
错误处理测试
catchError
typescriptit('应捕获错误并返回替代值', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-#'); const expected = '-a-b-(d|)'; const result$ = source$.pipe( catchError(() => of('d')) ); expectObservable(result$).toBe(expected); }); });
retry
typescriptit('应在出错时重试一次', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-#'); const expected = '-a-a-#'; const result$ = source$.pipe(retry(1)); expectObservable(result$).toBe(expected); }); });
注意
(d|)的括号:catchError 返回的 of('d') 是同步发出再完成的,在 marble 中必须用括号分组。
订阅与取消订阅验证
expectSubscriptions 专门验证 Observable 何时被订阅、何时被取消订阅,这是面试中区分"会用"和"理解原理"的分水岭。
typescriptit('应在 take(2) 后自动取消订阅', () => { testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { const source$ = cold('-a-b-c-d-|'); const sub = '^---!'; const result$ = source$.pipe(take(2)); expectObservable(result$).toBe('-a-b-|'); expectSubscriptions(source$.subscriptions).toBe(sub); }); });
^---!表示:订阅开始(^),经历 3 帧(---),取消订阅(!)。这验证了 take(2) 在收到第二个值后确实取消了上游订阅。
实战场景
搜索防抖
typescriptit('应对输入做防抖后发起搜索', () => { testScheduler.run(({ cold, expectObservable }) => { const input$ = cold('-a--b---c-|'); const expected = '-----b---c-|'; const result$ = input$.pipe( debounceTime(20, testScheduler), distinctUntilChanged(), switchMap(q => search(q)) ); expectObservable(result$).toBe(expected); }); });
轮询与停止
typescriptit('应按间隔轮询并在获取足够数据后停止', () => { testScheduler.run(({ expectObservable }) => { const expected = '-a-b-c-d-e-|'; const result$ = interval(10, testScheduler).pipe( take(5), map(i => String.fromCharCode(97 + i)) ); expectObservable(result$).toBe(expected); }); });
Hot Observable 的测试
Hot Observable 在订阅前就已经开始发出值,测试时用 ^ 标记订阅起点,之后才能收到值。
typescriptit('应只接收订阅后的值', () => { testScheduler.run(({ hot, expectObservable }) => { const source$ = hot('--a--b--c--|'); const sub = '---^-------!'; const expected = '--b--c--|'; const result$ = source$.pipe(take(2)); expectObservable(result$, sub).toBe(expected); }); });
面试追问:cold 和 hot 的本质区别?cold 每次订阅都重新开始,数据对每个订阅者独立;hot 共享同一个数据源,新订阅者只能收到订阅后的值。
常见陷阱
1. marble 字符串长度必须对齐
typescript// 错误:长度不一致 const source$ = cold('-a-b-|'); const expected = '-A-B-C-|'; // 多了 C,长度不匹配 // 正确:每个位置一一对应 const source$ = cold('-a-b-|'); const expected = '-A-B-|';
2. 不要忘记传 TestScheduler
typescript// 错误:使用了真实的 setTimeout source$.pipe(debounceTime(100)); // 正确:传入 testScheduler 使用虚拟时间 source$.pipe(debounceTime(100, testScheduler));
3. run() 内部不要使用真实异步
typescript// 错误:run() 内用了 setInterval testScheduler.run(() => { setInterval(() => {}, 100); // 会干扰虚拟时间 });
4. 值映射与默认值
当 marble 字母就是你想表达的值时,可以省略映射对象;但当字母与实际值不同(如 a 代表 1),必须显式传入。