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
| Symbol | Meaning |
|---|---|
- | Time passage (1 frame, about 10ms) |
a, b, c | Emitted 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
javascriptimport { TestScheduler } from 'rxjs/testing'; describe('My Observable Tests', () => { let testScheduler: TestScheduler; beforeEach(() => { testScheduler = new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); }); });
Testing Basic Operators
javascriptimport { 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); }); });
Testing Time-Related Operators
javascriptimport { 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
javascriptimport { 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
javascriptimport { 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
javascriptimport { 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
1. Testing Search Functionality
javascriptimport { 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
javascriptimport { 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
javascriptimport { 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
javascriptimport { 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
javascriptit('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
javascriptimport { 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
javascriptimport { 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
javascriptit('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
javascriptit('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:
- Visual Testing: Use strings to represent time flows, intuitive and easy to understand
- Time Control: Precisely control the timing of asynchronous operations
- Easy Maintenance: Clear syntax and structure
- Comprehensive Coverage: Can test various operators and scenarios
Mastering Marble Testing can significantly improve the test quality and development efficiency of RxJS code.