skip to content

Why can an RxJS TestScheduler.run marble test hang forever or see no values at all, and how do you fix each case?

level: seniorimportance: nice to knowfreq 20%

answer

  1. no frame ceiling in run mode
  2. endless sources never drain
  3. Promises live outside virtual time
  4. side effects need flush()

basics

~20 s

RxJS TestScheduler.run removes the frame limit, so an endless source such as interval keeps virtual time flushing forever; add an unsubscription marble. Promises and direct timers are not virtualised, so their values never arrive; test those parts asynchronously or stub them.

solid answer

~50 s

Inside `run()`, `maxFrames` is set to `Infinity`, and `flush()` keeps executing while tasks remain. An `interval`, a `timer` with a period or a `repeat` always schedules another task, so the test **hangs**. The fix is to end the test's subscription with a second argument such as `expectObservable(ticks$, '35ms !')`, or to bound the stream with `take(n)`. The opposite failure, **no values**, comes from work outside RxJS's schedulers: `from(promise)`, `async` functions or a direct `setTimeout` call resolve on the real event loop after `run()` has already flushed and compared, so the expectation sees nothing. Test such code with the runner's async support, or keep the Promise at a boundary you can replace with a `cold()` stub. A third trap is checking a side effect with a plain `expect` inside the callback: expectations and virtual time run at the end, so call `flush()` first.

code

ts · 21 lines
ts
import { interval, map, tap } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';

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

it('stops an endless source with an unsubscription marble', () => {
  testScheduler.run(({ expectObservable }) => {
    const ticks$ = interval(10).pipe(map(() => 'a'));
    expectObservable(ticks$, '35ms !').toBe('10ms a 9ms a 9ms a');
  });
});

it('flushes before asserting a side effect', () => {
  testScheduler.run(({ cold, expectObservable, flush }) => {
    let seen = 0;
    const source$ = cold('--a--b|').pipe(tap(() => seen++));
    expectObservable(source$).toBe('--a--b|');
    flush();
    expect(seen).toBe(2);
  });
});

go deeper

for a junior

Recall that endless sources need an unsubscription marble in run() tests and that Promises are not controlled by virtual time.

for a middle

Explain the run() lifecycle, scheduling during the callback and flushing at the end, and why that makes flush() necessary before side-effect checks.

for a senior

Diagnose hanging or empty marble tests quickly, and restructure streams so Promises and raw timers sit at injectable boundaries.

for a principal

Set conventions that keep time-based logic on RxJS schedulers and push Promises to edges, so the codebase stays testable with virtual time.

## How run() executes a test `testScheduler.run(callback)` does three things: 1. Switches to **run mode**: one frame is one virtual millisecond, the frame limit (`maxFrames`) is lifted to `Infinity`, and the providers behind RxJS's schedulers (intervals, timeouts, microtasks, timestamps, animation frames) are pointed at the `TestScheduler`. 2. Calls your callback, in which `cold`, `hot`, `expectObservable` and `expectSubscriptions` only **schedule** work and assertions. 3. **Flushes** virtual time, executing every scheduled task in time order, then compares actual and expected notifications. Each failure below comes from one of those steps. ## Failure 1: the test never finishes Because run mode has no frame limit, flushing continues while the scheduler still has tasks. Some sources never stop scheduling: - `interval(n)` and `timer(delay, period)`; - anything under `repeat()` or a polling loop; - a retry loop against a fixture that always errors. ```ts // hangs: interval keeps scheduling, nothing ever unsubscribes expectObservable(interval(10)).toBe('-'); // finishes: the test's subscription ends on frame 35 expectObservable(interval(10), '35ms !').toBe('10ms a 9ms b 9ms c', { a: 0, b: 1, c: 2 }); ``` Fixes: - pass an **unsubscription marble** as the second argument of `expectObservable`; - or bound the stream under test with `take(n)` or a notifier fixture; - never rely on the expected marble: `toBe('-')` describes output, it does not stop the source. Outside run mode the opposite happens: `maxFrames` is 750, and later frames are **silently ignored**, which can make a test pass while hiding events. ## Failure 2: the stream emits nothing The RxJS docs are explicit: `TestScheduler` can only virtualise code that schedules through RxJS schedulers. Anything else runs on the real event loop: | Code in the chain | Virtualised? | |---|---| | `delay`, `debounceTime`, `timer`, `interval` on default schedulers | yes | | `asapScheduler`; `animationFrameScheduler` once `animate()` defines the paints | yes, in run mode | | `from(promise)`, `async`/`await`, `firstValueFrom` inside the chain | no | | a direct `setTimeout` or `requestAnimationFrame` in your own code | no | For the unvirtualised cases, `run()` flushes and compares **before** the real callback fires, so `expectObservable(from(Promise.resolve('p')))` records no notifications at all and the test fails with an empty actual array. Fixes: 1. **Test that piece traditionally**, with the runner's async support (returning a Promise, `done`, or its own fake timers), as the RxJS guide recommends. 2. **Move the Promise to a boundary**: have the stream depend on a function returning an Observable, so a test can inject a `cold()` stub while production wraps the Promise. 3. Replace direct `setTimeout` calls with RxJS time operators, which also makes the code composable. ## Failure 3: a side effect looks unchanged A `tap()` that increments a counter has not run yet when a plain `expect(counter)` executes inside the callback, because virtual time only starts at the end. Call the `flush()` helper first, then assert synchronously. ## Failure 4: zero-length delays The guide also notes that a delay of zero cannot currently be asserted: `delay(0)` schedules a new task without any passage of virtual time, so it cannot be told apart from synchronous emission in a marble. ## Why run mode lifts the frame limit The legacy default of 750 frames was designed for tests of RxJS itself, where frames were 10 virtual milliseconds. With 1 ms frames and time progression syntax, a realistic test easily covers seconds or minutes of virtual time, so a fixed ceiling would silently truncate application tests. Run mode therefore removes the ceiling and makes the test responsible for ending every endless source. The trade is deliberate: a hang is loud and gets fixed, whereas silently dropped frames can hide a real regression. ## A checklist before blaming the operator - Is every endless source bounded or unsubscribed by the test? - Does any Promise, `async` function or raw timer sit inside the chain? - Are side-effect assertions preceded by `flush()`? - Is the test inside `run()`, so defaults are virtualised and frames are 1 ms?

  • How would you restructure a stream that calls a Promise-returning API so its timing logic can still be marble-tested?
    Make the stream depend on a function that returns an Observable, and convert the Promise at that boundary in production code. In the test, inject a `cold()` stub with the latency you want, so debouncing, switching and retry timing run on virtual time, and test the thin Promise wrapper separately with the runner's async support.
  • What changes if the same marble test is written without run()?
    Operators are no longer virtualised automatically, so the `TestScheduler` must be passed to each one; a frame is 10 virtual milliseconds; spaces count as frames; time progression syntax is unsupported; frames beyond 750 are silently dropped; and you must call `flush()` yourself. The RxJS guide discourages that form.

saying these in an interview costs you the question

  • An expected marble of '-' makes TestScheduler stop an endless source.
  • TestScheduler virtualises Promises the same way it virtualises timers.
  • A plain expect inside run() sees tap side effects immediately.
  • run() caps a test at 750 frames, so endless sources are cut off safely.
  • Code that calls setTimeout directly is controlled by virtual time.