首页
/ RxJS 中的弹珠测试(Marble Testing)完全指南

RxJS 中的弹珠测试(Marble Testing)完全指南

2025-07-05 07:06:21作者:钟日瑜

什么是弹珠测试?

弹珠测试是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()回调中,我们可以使用以下工具函数:

  1. cold(marble, values?, error?)
    创建冷Observable,测试开始时才会激活

  2. hot(marble, values?, error?)
    创建热Observable(类似Subject),测试前就已经激活

  3. expectObservable(actual$, subscription?)
    断言Observable的行为

  4. expectSubscriptions(subscriptions)
    断言订阅/取消订阅的时间点

  5. time(marble)
    将弹珠图转换为时间数值

  6. 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);
  });
});

常见问题与限制

  1. Promise无法测试
    弹珠测试只能测试使用RxJS调度器的代码,无法测试原生Promise。

  2. 零延迟问题
    delay(0)实际上会创建新的macrotask,无法用弹珠测试准确模拟。

  3. 外部TestScheduler.run()
    run()回调外部使用时,行为会有差异:

    • 时间比例不同(1帧=10毫秒)
    • 不支持时间推进语法
    • 需要显式传递调度器

最佳实践

  1. 保持测试简洁,每个测试只验证一个行为
  2. 使用有意义的变量名表示弹珠图中的值
  3. 垂直对齐输入和预期的弹珠图以提高可读性
  4. 对复杂的时间操作添加注释说明
  5. 考虑将常用弹珠模式提取为常量

弹珠测试是RxJS中非常强大的测试工具,虽然初期学习曲线较陡,但一旦掌握,可以极大地提高测试的可靠性和开发效率。通过虚拟时间的概念,我们能够将异步测试转化为同步断言,使测试更加稳定和快速。