Skip to main content
We can test asynchronous RxJS code synchronously and deterministically by virtualizing time using the TestScheduler. Marble diagrams provide a visual way to represent Observable behavior and create comprehensive tests.
The TestScheduler can only test code that uses RxJS schedulers (asyncScheduler, animationFrameScheduler, etc.). Code that uses Promises or native timers cannot be tested with TestScheduler and should use traditional async testing.

Getting Started with TestScheduler

The TestScheduler is exported from 'rxjs/testing' and requires an assertion function from your test framework:
import { TestScheduler } from 'rxjs/testing';
import { throttleTime } from 'rxjs';

const testScheduler = new TestScheduler((actual, expected) => {
  expect(actual).toEqual(expected);
});

// Tests run synchronously!
it('throttles values correctly', () => {
  testScheduler.run((helpers) => {
    const { cold, expectObservable, time } = helpers;
    const source = cold(' -a--b--c---|');
    const t = time('      ---|       '); // t = 3
    const expected = '    -a-----c---|';

    expectObservable(
      source.pipe(throttleTime(t))
    ).toBe(expected);
  });
});

The run() Helper API

The testScheduler.run(callback) method provides helper functions for writing tests:
1

cold() - Create Cold Observables

Creates an Observable that starts emitting when subscribed.
testScheduler.run(({ cold }) => {
  const source = cold('--a--b--|', { a: 1, b: 2 });
  // Subscription starts at frame 0
});
2

hot() - Create Hot Observables

Creates an Observable that behaves as if already running.
testScheduler.run(({ hot }) => {
  const source = hot('^-a--b--|', { a: 1, b: 2 });
  // ^ marks the subscription point (zero frame)
});
3

expectObservable() - Assert Observable Behavior

Schedules an assertion for the Observable’s emissions.
testScheduler.run(({ cold, expectObservable }) => {
  const source = cold('--a--b--|');
  const expected = '   --a--b--|';
  
  expectObservable(source).toBe(expected);
});
4

expectSubscriptions() - Assert Subscription Timing

Verifies when subscriptions and unsubscriptions occur.
testScheduler.run(({ cold, expectSubscriptions }) => {
  const source = cold('--a--b--|');
  const subs = '       ^-------!';
  
  expectSubscriptions(source.subscriptions).toBe(subs);
});
5

flush() - Execute Virtual Time

Manually triggers all scheduled assertions (usually automatic).
testScheduler.run(({ cold, flush }) => {
  let count = 0;
  cold('--a--b--|').subscribe(() => count++);
  
  flush();
  expect(count).toBe(2);
});
6

time() - Convert Marbles to Numbers

Returns the frame count for a marble diagram (measured to |).
testScheduler.run(({ time }) => {
  const t = time('---|'); // Returns 3
  const t2 = time('-------|'); // Returns 7
});

Marble Syntax Guide

Inside testScheduler.run(), one frame equals one virtual millisecond.
Marble diagrams use special characters to represent Observable events over time:
CharacterMeaningExample
-One frame of time---a-- = emit a at frame 3
a-z0-9Emitted value--a--b-- = emit a, then b
|Completion--a--| = emit a, then complete
#Error--a--# = emit a, then error
^Subscription point (hot only)^--a-- = subscribe here
!Unsubscription point^--! = subscribe then unsubscribe
()Synchronous grouping--(abc|) = emit a, b, c, complete (same frame)
Whitespace (ignored)Used for alignment

Time Progression Syntax

You can use CSS-style duration syntax: 100ms, 1.5s, or 2m for milliseconds, seconds, or minutes.
testScheduler.run(({ cold, expectObservable }) => {
  const source = cold('a 100ms b 1s c|');
  const expected = '   a 100ms b 1s c|';
  
  expectObservable(source).toBe(expected);
});
You may need to subtract 1ms because value emissions advance time by 1 frame.
// Each letter advances time by 1 frame
const input = '    -a-b-c|';
const expected = ' -- 9ms a 9ms b 9ms (c|)';
// Not '10ms' because 'a' itself takes 1 frame

Testing Common Scenarios

Testing Operators with Timing

import { TestScheduler } from 'rxjs/testing';
import { debounceTime } from 'rxjs';

it('debounces values', () => {
  testScheduler.run(({ cold, expectObservable, time }) => {
    const source = cold('  -a--b-c---d----|');
    const t = time('        --|          ');
    const expected = '     -----c------d--|';

    expectObservable(
      source.pipe(debounceTime(t))
    ).toBe(expected);
  });
});

Testing with Values

import { map } from 'rxjs';

it('maps values using provided object', () => {
  testScheduler.run(({ cold, expectObservable }) => {
    const source = cold('  --a--b--|', {
      a: { id: 1, name: 'Alice' },
      b: { id: 2, name: 'Bob' }
    });
    
    const expected = '     --a--b--|';
    const expectedValues = {
      a: 'Alice',
      b: 'Bob'
    };

    expectObservable(
      source.pipe(map(user => user.name))
    ).toBe(expected, expectedValues);
  });
});

Testing Errors

import { throwError, catchError, of } from 'rxjs';

it('catches and recovers from errors', () => {
  testScheduler.run(({ cold, expectObservable }) => {
    const source = cold('  --a--#', 
      { a: 1 }, 
      new Error('Oops')
    );
    
    const expected = '     --a--(b|)';
    const expectedValues = { a: 1, b: 0 };

    expectObservable(
      source.pipe(
        catchError(() => of(0))
      )
    ).toBe(expected, expectedValues);
  });
});

Testing Hot vs Cold Observables

testScheduler.run(({ cold, expectObservable }) => {
  const source = cold('--a--b--c--|');
  
  // Each subscription starts from the beginning
  expectObservable(source).toBe('--a--b--c--|');
  expectObservable(source).toBe('--a--b--c--|');
});

Subscription Marble Diagrams

Test when subscriptions occur and when they’re torn down:
testScheduler.run(({ hot, expectObservable }) => {
  const source = hot('--a--a--a--a--a--a--');
  const sub1 = '      --^-----------!';
  const sub2 = '      ---------^--------!';
  const expect1 = '   --a--a--a--a--';
  const expect2 = '   -----------a--a--a-';

  expectObservable(source, sub1).toBe(expect1);
  expectObservable(source, sub2).toBe(expect2);
});
CharacterMeaning
-One frame of time
^Subscription point
!Unsubscription point
100msTime progression

Testing Side Effects

Test Observable behavior that updates external state:
import { tap } from 'rxjs';

it('counts emitted values', () => {
  testScheduler.run(({ cold, expectObservable, flush }) => {
    let count = 0;
    
    const source = cold('--a--b--|', { a: 1, b: 2 });
    const expected = '   --a--b--|';
    
    const result = source.pipe(
      tap(() => count++)
    );
    
    expectObservable(result).toBe(expected);
    
    // flush() completes all observables
    flush();
    
    expect(count).toBe(2);
  });
});

Advanced Testing Patterns

Testing Higher-Order Observables

import { mergeMap, of, delay } from 'rxjs';

it('flattens higher-order observables', () => {
  testScheduler.run(({ cold, expectObservable, time }) => {
    const source = cold('  --a----b--|');
    const t = time('       --|       ');
    const expected = '     ----a----b--|';

    expectObservable(
      source.pipe(
        mergeMap(x => of(x).pipe(delay(t)))
      )
    ).toBe(expected);
  });
});

Testing Animation Frames

import { animationFrames, takeWhile } from 'rxjs';

it('emits on animation frames', () => {
  testScheduler.run(({ animate, expectObservable }) => {
    animate('              ---x---x---x');
    const expected = '     ---a---b---c';
    
    expectObservable(
      animationFrames().pipe(
        takeWhile(({ elapsed }) => elapsed < 30)
      )
    ).toBe(expected);
  });
});

Testing with Multiple Sources

import { combineLatest } from 'rxjs';

it('combines latest values from multiple sources', () => {
  testScheduler.run(({ cold, expectObservable }) => {
    const a$ = cold('     -a--b--c--|');
    const b$ = cold('     --1--2--|  ');
    const expected = '    --A-BC-D-|';
    const values = {
      A: ['a', '1'],
      B: ['b', '1'],
      C: ['b', '2'],
      D: ['c', '2']
    };

    expectObservable(
      combineLatest([a$, b$])
    ).toBe(expected, values);
  });
});

Known Limitations

TestScheduler cannot virtualize code that uses Promises, setTimeout, setInterval, or other non-RxJS async operations.

Testing Promise-based Code

For code that uses Promises, use traditional async testing:
import { from } from 'rxjs';

// Cannot use TestScheduler for this
it('handles promises', async () => {
  const promise = Promise.resolve('data');
  const result$ = from(promise);
  
  const value = await result$.toPromise();
  expect(value).toBe('data');
});

Zero Delay Limitation

You cannot test delays of exactly zero:
// This will not work as expected
delay(0) // Schedules a macrotask, truly async

Best Practices

1

Always Use testScheduler.run()

The modern API provides better defaults and features:
// Good
testScheduler.run(({ cold, expectObservable }) => {
  // test code
});

// Avoid (legacy API)
const source = testScheduler.createColdObservable('--a--');
2

Use Whitespace for Alignment

Make your tests readable by aligning marble diagrams:
const source = cold('  --a--b--|');
const expected = '     --x--y--|';
const subs = '         ^-------!';
3

Test One Behavior Per Test

Keep tests focused and easy to understand:
it('filters even numbers', () => { /* ... */ });
it('maps values to strings', () => { /* ... */ });
4

Provide Meaningful Value Objects

Use descriptive value mappings:
const values = {
  a: { id: 1, status: 'active' },
  b: { id: 2, status: 'inactive' }
};