乐闻世界logo
搜索文章和话题

What is Marble Testing in RxJS and how to use it?

2月21日 16:58

Concept of Marble Testing

Marble Testing is a string-based visual testing method in RxJS that uses special syntax to represent the time flow and events of Observables. This approach makes asynchronous testing intuitive and easy to understand.

Marble Syntax

Basic Symbols

SymbolMeaning
-Time passage (1 frame, about 10ms)
a, b, cEmitted values
``
#Error
()Synchronous emission
^Subscription point (hot Observable)
!Unsubscription

Examples

javascript
// Basic example const source$ = cold('-a-b-c-|'); // Meaning: emit a after 10ms, b after 20ms, c after 30ms, complete after 40ms // Error example const error$ = cold('-a-b-#'); // Meaning: emit a after 10ms, b after 20ms, error after 30ms // Synchronous example const sync$ = cold('(abc|)'); // Meaning: synchronously emit a, b, c, then complete // Hot Observable const hot$ = hot('^-a-b-c-|'); // Meaning: start from subscription point, emit a after 10ms, b after 20ms, c after 30ms, complete after 40ms

Using TestScheduler

Basic Setup

javascript
import { TestScheduler } from 'rxjs/testing'; describe('My Observable Tests', () => { let testScheduler: TestScheduler; beforeEach(() => { testScheduler = new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); }); });

Testing Basic Operators

javascript
import { of } from 'rxjs'; import { map, filter } from 'rxjs/operators'; it('should map values', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|'); const expected = '-A-B-C-|'; const result$ = source$.pipe( map(x => x.toUpperCase()) ); expectObservable(result$).toBe(expected, { a: 'a', b: 'b', c: 'c' }); }); }); it('should filter values', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-d-|'); const expected = '-a-c---|'; const result$ = source$.pipe( filter(x => ['a', 'c'].includes(x)) ); expectObservable(result$).toBe(expected); }); });
javascript
import { of } from 'rxjs'; import { delay, debounceTime, throttleTime } from 'rxjs/operators'; it('should delay emissions', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|'); const expected = '---a-b-c-|'; // Delayed by 30ms const result$ = source$.pipe( delay(30, testScheduler) ); expectObservable(result$).toBe(expected); }); }); it('should debounce', () => { 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); }); }); it('should throttle', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-d-|'); const expected = '-a---c---|'; const result$ = source$.pipe( throttleTime(30, testScheduler) ); expectObservable(result$).toBe(expected); }); });

Testing Combination Operators

javascript
import { of, merge, concat, combineLatest } from 'rxjs'; it('should merge observables', () => { 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); }); }); it('should concatenate observables', () => { 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); }); }); it('should combine latest', () => { testScheduler.run(({ cold, expectObservable }) => { const source1$ = cold('-a---b-|'); const source2$ = cold('--c-d---|'); const expected = '----ab-bd-|'; const result$ = combineLatest([source1$, source2$]); expectObservable(result$).toBe(expected); }); });

Testing Error Handling

javascript
import { of, throwError } from 'rxjs'; import { catchError, retry } from 'rxjs/operators'; it('should catch errors', () => { 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); }); }); it('should retry on error', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-#'); const expected = '-a-a-#'; const result$ = source$.pipe( retry(1) ); expectObservable(result$).toBe(expected); }); });

Testing Subscription and Unsubscription

javascript
import { interval } from 'rxjs'; import { take } from 'rxjs/operators'; it('should handle subscription', () => { testScheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => { const source$ = cold('-a-b-c-|'); const subs = '^------!'; const result$ = source$.pipe(take(2)); expectObservable(result$).toBe('-a-b-|'); expectSubscriptions(source$.subscriptions).toBe(subs); }); }); it('should handle unsubscription', () => { testScheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => { const source$ = cold('-a-b-c-d-|'); const subs = '^---!'; const result$ = source$.pipe(take(2)); expectObservable(result$).toBe('-a-b-|'); expectSubscriptions(source$.subscriptions).toBe(subs); }); });

Practical Application Examples

javascript
import { of } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; function search(query: string) { return of(`Results for ${query}`); } it('should search with debounce', () => { testScheduler.run(({ cold, expectObservable }) => { const input$ = cold('-a--b---c-|'); const expected = '-----b---c-|'; const result$ = input$.pipe( debounceTime(20, testScheduler), distinctUntilChanged(), switchMap(query => search(query)) ); expectObservable(result$).toBe(expected); }); });

2. Testing Auto-save

javascript
import { of } from 'rxjs'; import { debounceTime, switchMap } from 'rxjs/operators'; function save(data: any) { return of('Saved'); } it('should auto-save with debounce', () => { testScheduler.run(({ cold, expectObservable }) => { const changes$ = cold('-a--b---c-|'); const expected = '-----b---c-|'; const result$ = changes$.pipe( debounceTime(20, testScheduler), switchMap(data => save(data)) ); expectObservable(result$).toBe(expected); }); });

3. Testing Polling

javascript
import { interval } from 'rxjs'; import { take, map } from 'rxjs/operators'; it('should poll at intervals', () => { testScheduler.run(({ cold, expectObservable }) => { const expected = '-a-b-c-d-e-|'; const result$ = interval(10, testScheduler).pipe( take(5), map(x => String.fromCharCode(97 + x)) ); expectObservable(result$).toBe(expected); }); });

4. Testing Caching

javascript
import { of } from 'rxjs'; import { shareReplay } from 'rxjs/operators'; it('should cache values', () => { testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { const source$ = cold('-a-b-c-|'); const expected = '-a-b-c-|'; const subs = ['^------!', ' ^-!']; const cached$ = source$.pipe(shareReplay(1)); expectObservable(cached$).toBe(expected); expectObservable(cached$).toBe('--c-|'); expectSubscriptions(source$.subscriptions).toBe(subs); }); });

Advanced Usage

1. Testing Hot Observable

javascript
it('should handle hot observable', () => { 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); }); });

2. Testing Multicast

javascript
import { of } from 'rxjs'; import { share, multicast } from 'rxjs/operators'; import { Subject } from 'rxjs'; it('should multicast correctly', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|'); const expected = '-a-b-c-|'; const shared$ = source$.pipe(share()); expectObservable(shared$).toBe(expected); expectObservable(shared$).toBe(expected); }); });

3. Testing Custom Operators

javascript
import { Observable } from 'rxjs'; import { OperatorFunction } from 'rxjs'; function customMap<T, R>(project: (value: T) => R): OperatorFunction<T, R> { return (source$) => new Observable(subscriber => { return source$.subscribe({ next: value => { try { subscriber.next(project(value)); } catch (error) { subscriber.error(error); } }, error: error => subscriber.error(error), complete: () => subscriber.complete() }); }); } it('should use custom operator', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|'); const expected = '-A-B-C-|'; const result$ = source$.pipe( customMap(x => x.toUpperCase()) ); expectObservable(result$).toBe(expected); }); });

Best Practices

1. Use Meaningful Values

javascript
// ✅ Good practice it('should map values', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|'); const expected = '-A-B-C-|'; const result$ = source$.pipe(map(x => x.toUpperCase())); expectObservable(result$).toBe(expected, { a: 'a', b: 'b', c: 'c', A: 'A', B: 'B', C: 'C' }); }); }); // ❌ Bad practice it('should map values', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-c-|'); const expected = '-d-e-f-|'; const result$ = source$.pipe(map(x => x.toUpperCase())); expectObservable(result$).toBe(expected); }); });

2. Test Edge Cases

javascript
it('should handle empty observable', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('|'); const expected = '|'; const result$ = source$.pipe(map(x => x.toUpperCase())); expectObservable(result$).toBe(expected); }); }); it('should handle error observable', () => { testScheduler.run(({ cold, expectObservable }) => { const source$ = cold('-#'); const expected = '-#'; const result$ = source$.pipe(map(x => x.toUpperCase())); expectObservable(result$).toBe(expected); }); });

3. Use expectSubscriptions

javascript
it('should subscribe and unsubscribe correctly', () => { testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { const source$ = cold('-a-b-c-|'); const subs = '^------!'; const result$ = source$.pipe(take(3)); expectObservable(result$).toBe('-a-b-c-|'); expectSubscriptions(source$.subscriptions).toBe(subs); }); });

Summary

Marble Testing is a powerful testing tool in RxJS that provides:

  1. Visual Testing: Use strings to represent time flows, intuitive and easy to understand
  2. Time Control: Precisely control the timing of asynchronous operations
  3. Easy Maintenance: Clear syntax and structure
  4. Comprehensive Coverage: Can test various operators and scenarios

Mastering Marble Testing can significantly improve the test quality and development efficiency of RxJS code.

标签:Rxjs