Why can an RxJS TestScheduler.run marble test hang forever or see no values at all, and how do you fix each case?
answer
- no frame ceiling in run mode
- endless sources never drain
- Promises live outside virtual time
- side effects need flush()
basics
~20 sRxJS 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 sInside `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 linesimport { 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
Recall that endless sources need an unsubscription marble in run() tests and that Promises are not controlled by virtual time.
Explain the run() lifecycle, scheduling during the callback and flushing at the end, and why that makes flush() necessary before side-effect checks.
Diagnose hanging or empty marble tests quickly, and restructure streams so Promises and raw timers sit at injectable boundaries.
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.