skip to content

In RxJS, how do you test a debounceTime-based search stream with TestScheduler.run so the test needs no real waiting?

level: middleimportance: must knowfreq 45%

answer

  1. virtual time, synchronous test
  2. run() swaps the timers
  3. keystrokes hot, requests cold
  4. one less than the delay
  5. assertions run on flush

basics

~10 s

Wrap the test in RxJS TestScheduler.run: timer-based operators such as debounceTime then use virtual time automatically. Feed keystrokes from hot(), stub the search with cold(), and assert with expectObservable(...).toBe(marbles, values), all synchronously.

solid answer

~40 s

Create a `TestScheduler` with a callback that performs your test runner's deep-equality assertion, then put the test inside `testScheduler.run(helpers => ...)`. In run mode, every operator that schedules through `asyncScheduler`, including `debounceTime`, `delay` and `timer`, is redirected to virtual time, so you do not pass the scheduler in and nothing really waits. Model the keystrokes with `hot()` because user input exists independently of subscribers, stub the search call with a `cold()` observable that answers after, say, `50ms`, and write the expected output with `expectObservable(results$).toBe('452ms x 400ms y 150ms |', values)`. The stream under test should take its source and its search function as parameters so the test can inject both. `run()` flushes virtual time when the callback returns and then evaluates the scheduled assertions, so a debounce of 300 ms is checked in microseconds.

code

ts · 23 lines
ts
import { Observable, debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';

function searchResults(terms$: Observable<string>, search: (q: string) => Observable<string[]>) {
  return terms$.pipe(debounceTime(300), distinctUntilChanged(), switchMap(search));
}

it('searches 300 ms after typing pauses', () => {
  const testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));

  testScheduler.run(({ hot, cold, expectObservable }) => {
    // keystrokes: r@1, rx@102, rxj@503, complete@1004
    const terms = hot('-a 100ms b 400ms c 499ms -|', { a: 'r', b: 'rx', c: 'rxj' });
    // each search answers 50 ms after it starts
    const search = (q: string) => cold('50ms r|', { r: [`${q} results`] });

    // rx released @402 -> x@452; rxj released @803 -> y@853; done @1004
    expectObservable(searchResults(terms, search)).toBe('452ms x 400ms y 150ms |', {
      x: ['rx results'],
      y: ['rxj results'],
    });
  });
});

go deeper

for a junior

Recall that TestScheduler.run lets time-based RxJS code be tested synchronously with virtual time and marble strings.

for a middle

Explain run mode's automatic virtualisation, why keystrokes are hot and stubs cold, and compute expected frames including the one-frame value quirk.

for a senior

Design streams that accept their sources and dependencies so they are testable, and keep Promises out of chains that need virtual-time tests.

for a principal

Set a team standard for testing time-based streams, balancing marble tests' precision against their learning curve for engineers new to RxJS.

## The problem A typeahead pipeline usually looks like this: ```ts import { Observable, debounceTime, distinctUntilChanged, switchMap } from 'rxjs'; export function searchResults( terms$: Observable<string>, search: (term: string) => Observable<string[]>, ): Observable<string[]> { return terms$.pipe(debounceTime(300), distinctUntilChanged(), switchMap(search)); } ``` Testing it with real timers means sleeping for hundreds of milliseconds per case and still getting flaky results. RxJS's **`TestScheduler`** runs the same code on **virtual time**: a clock that jumps straight to the next scheduled task, so a 300 ms debounce is checked synchronously. ## Step by step 1. **Create the scheduler** with an assertion callback from your test runner: `new TestScheduler((actual, expected) => expect(actual).toEqual(expected))`. `TestScheduler` compares arrays of frame-stamped notifications and delegates the actual equality check to you. 2. **Enter run mode** with `testScheduler.run((helpers) => { ... })`. For the duration of the callback, one frame is one virtual millisecond, and the timer providers behind `asyncScheduler` are pointed at the `TestScheduler`. That is why `debounceTime(300)` needs **no scheduler argument**. 3. **Model the inputs.** Use `hot()` for keystrokes: they happen on a fixed timeline whether or not anyone listens. Use `cold()` for the search stub: every call to `search()` returns a fresh timeline that starts when `switchMap` subscribes. 4. **Describe the expectation** with `expectObservable(actual$).toBe(marbles, values)`. 5. **Let `run()` flush.** When the callback returns, `run()` flushes virtual time and evaluates every scheduled expectation. ## The worked test ```ts import { TestScheduler } from 'rxjs/testing'; it('searches 300 ms after typing pauses', () => { const testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected)); testScheduler.run(({ hot, cold, expectObservable }) => { const terms = hot('-a 100ms b 400ms c 499ms -|', { a: 'r', b: 'rx', c: 'rxj' }); const search = (q: string) => cold('50ms r|', { r: [`${q} results`] }); expectObservable(searchResults(terms, search)).toBe('452ms x 400ms y 150ms |', { x: ['rx results'], y: ['rxj results'], }); }); }); ``` Reading the timelines: - Keystrokes arrive on frames 1 (`r`), 102 (`rx`) and 503 (`rxj`); the source completes on frame 1004. - `rx` arrives 101 ms after `r`, inside the 300 ms window, so `r` is never searched. `rx` is released on frame 402, and `rxj` on frame 803. - Each search answers 50 ms after it starts: `x` on frame 452, `y` on frame 853. The result completes when the source has completed and no search is active, on frame 1004. ## Frame arithmetic, the usual source of red tests - A value in a marble **advances time by one frame**, so `'a 299ms b'` puts `b` 300 frames after `a`. Expected strings often contain "one less" than the obvious number. - Time progression needs a space before it when it is not at the start: `'a 10ms b'`, not `'a10msb'`, which would be five separate values. - Keep diagrams aligned with leading spaces; run mode ignores them. ## What makes the stream testable | Design choice | Why it helps | |---|---| | source passed in as a parameter | the test substitutes a `hot()` timeline | | search function passed in | the test substitutes a `cold()` stub with known latency | | default schedulers, no hard-coded `asyncScheduler` argument | run mode virtualises them automatically | | no Promises inside the chain | virtual time cannot control Promise resolution | ## Why virtual time rather than real or runner-faked timers - **Real timers** make every case wait for the debounce, multiply suite time, and fail intermittently on a loaded CI machine. - **A test runner's fake timers** can advance time, but they know nothing about RxJS's frame-by-frame notifications; you end up asserting on values collected in arrays and advancing clocks by hand. - **`TestScheduler`** records every notification with its exact frame and compares the whole timeline in one assertion, including completion and errors. A regression that shifts a result by a single millisecond fails with a precise diff. The cost is the learning curve of the notation, which is why the frame arithmetic above deserves a comment in the test itself. ## Helpers you get from run() - `cold`, `hot`: create source observables from marbles. - `expectObservable`, `expectSubscriptions`: schedule assertions. - `time('---|')`: converts a marble into a number of frames, handy for passing durations. - `flush()`: run virtual time early, before checking a side effect. - `animate()`: controls when animation frames are painted.

  • Why does the stream under test not need asyncScheduler replaced by the TestScheduler explicitly?
    Inside `run()`, RxJS points the timer, timestamp and animation-frame providers that its built-in schedulers use at the `TestScheduler`. Operators such as `debounceTime` default to `asyncScheduler`, so they run on virtual time automatically. Outside `run()` you would have to pass the `TestScheduler` to each operator.
  • Why stub the search with cold() rather than hot()?
    Each call to `search()` should behave like a new request: its timeline starts when `switchMap` subscribes. `cold()` gives exactly that, a fresh run per subscription. A `hot()` stub would emit on fixed frames regardless of when the search started, so its latency would no longer be relative to the keystroke.
  • How would you assert that the stale search for 'r' was never started?
    Record the terms passed to the stub, or give each term its own `cold()` and assert with `expectSubscriptions(stub.subscriptions)` that the one for `r` has no subscription log, which shows the debounce dropped it before `switchMap` ever subscribed.

saying these in an interview costs you the question

  • Marble tests for debounceTime still need real timers or a sleep.
  • You must pass the TestScheduler into debounceTime inside run().
  • Keystrokes and search stubs should both be cold observables.
  • 'a 300ms b' puts b exactly 300 frames after a.
  • expectObservable checks the stream the moment toBe() is called.