In RxJS, how do you test a debounceTime-based search stream with TestScheduler.run so the test needs no real waiting?
answer
- virtual time, synchronous test
- run() swaps the timers
- keystrokes hot, requests cold
- one less than the delay
- assertions run on flush
basics
~10 sWrap 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 sCreate 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 linesimport { 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
Recall that TestScheduler.run lets time-based RxJS code be tested synchronously with virtual time and marble strings.
Explain run mode's automatic virtualisation, why keystrokes are hot and stubs cold, and compute expected frames including the one-frame value quirk.
Design streams that accept their sources and dependencies so they are testable, and keep Promises out of chains that need virtual-time tests.
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.