cswork / libraryOpen practice workspace

Articles

RxJS Marble Testing Interview Exercises: Subscription Timing, Cancellation, and Error Recovery

Practice RxJS marble testing with TestScheduler, hot and cold streams, subscription windows, switchMap cancellation, and catchError recovery.

An RxJS marble test can pass while the app still displays a stale search result. The expected-output diagram may assert the right letters yet say nothing about whether the first HTTP-like stream was unsubscribed when a new query arrived. The official RxJS marble-testing guide treats expectObservable and expectSubscriptions as separate assertions. In a technical interview, the strongest answer writes both.

This guide uses an original search-stream fixture to practice timing, cancellation, and error recovery under TestScheduler.run(). It is not a report that a particular company uses these exact exercises. RxJS behavior and marble syntax are grounded in the official guide and TestScheduler API; the search contract and test cases are our practice design. For adjacent product behavior, Implement Resilient Autocomplete Data Fetching asks for latest-query precedence and bounded retries; here we make those requirements observable on virtual timelines.

The fixture: a search box with three outcomes

Assume a stream queries$ emits nonempty normalized search terms. search(term) returns a cold Observable that either emits one result list and completes, errors, or remains pending. The UI policy is “latest query wins”: a new term cancels interest in the prior request; a failure for one term produces a local error state but does not kill future searches; destroying the component ends the whole subscription. The real transport may or may not stop network work when its Observable is unsubscribed, so a marble test proves subscription lifetime in this RxJS composition, not universal server cancellation.

Clarify whether the UI should emit a loading event immediately, whether empty input clears results, whether successful lists are cached, and whether retries are desired. Those policies change the output timeline. To keep the core exercise precise, the examples below omit debounce and caching until the cancellation rule is correct. Add them afterward, rather than writing a long operator chain whose expected behavior nobody can state.

The urgent interview questions are: where is virtual time zero; what is hot versus cold; what do ^ and ! assert; how does switchMap end an old inner subscription; which catchError placement preserves the outer query stream; and what work cannot be made deterministic with TestScheduler alone? The article-specific thesis is that a correct test asserts emissions, subscription windows, and stream survival as separate properties.

Question 1: How do you read a marble diagram without guessing?

In run mode, a dash advances one virtual frame, letters emit mapped values, | completes, and # errors. Parentheses group events in the same frame. A hot Observable's producer is already running relative to subscription; a cold Observable starts its sequence when subscribed. The ^ in a subscription diagram marks subscription, and ! marks unsubscription. In a hot source diagram, ^ can also mark the zero frame. The same glyph has a different role in those two contexts; state which diagram you are reading.

Run mode sets one frame per virtual millisecond for its marble syntax. Do not reuse that assumption for a manually configured TestScheduler outside run(): the API documents a different legacy frame factor. Alignment spaces in example code are for human reading; a string's characters and time-progression tokens determine its timing. Count absolute frames before you write expected output.

Start with cold('--a|'). If subscribed at frame 0, it emits a at frame 2 and completes at frame 3. Its subscription diagram is ^--!. If another test subscribes at frame 5, the same cold emissions shift to frames 7 and 8. A hot source with a at frame 2 does not replay that event to a subscriber arriving at frame 5 unless it has an explicit replay behavior. This is why replacing a hot query source with a cold source can make an autocomplete test describe the wrong user interaction.

The official guide notes that run() schedules assertions for the flush after the callback. Do not place an ordinary immediate assertion in the callback and assume the scheduled emissions have happened. Use expectObservable and expectSubscriptions for the virtual events, or explicitly flush() when the test needs a synchronous observation at that point.

Exercise 1: Prove both a result and its subscription window

Create the smallest deterministic test before the multi-request case:

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

scheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
  const result$ = cold('--a|', { a: ['one'] });
  expectObservable(result$).toBe('--a|', { a: ['one'] });
  expectSubscriptions(result$.subscriptions).toBe('^--!');
});

The first assertion says what a subscriber sees. The second says when the source is active. Their agreement is trivial here, but it establishes a discipline that catches cancellation bugs later. If result$ never completes, its subscription would remain open unless the test passes an explicit unsubscribe diagram to expectObservable. The official guide warns that an unbounded stream without such a limit can make a test impractical.

The test also demonstrates a boundary: the arrays in the values map are objects compared by your assertion function. Choose an equality assertion that actually compares nested values, not reference identity alone. If the test framework uses a different matcher, adapt only that constructor callback, not the marble semantics.

Exercise 2: What does switchMap cancel?

For latest-query-wins behavior, map each query to an inner search Observable with switchMap. A later query unsubscribes from the previous inner Observable and subscribes to the new one. The RxJS operator API describes this switching behavior. It is an output policy as well as a resource-lifetime policy: an old inner emission after its unsubscribe point must not reach the final subscriber.

Use this synthetic test with a fresh scheduler for this case, so its absolute frames start at 0 rather than continuing from the previous exercise. Imports are omitted for space.

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

scheduler.run(({ hot, cold, expectObservable, expectSubscriptions }) => {
  const queries$ = hot('-a---b------|');
  const first$ = cold('--x-----|');
  const second$ = cold('--y|');
  const result$ = queries$.pipe(
    switchMap(term => term === 'a' ? first$ : second$)
  );

  expectObservable(result$).toBe('---x---y----|');
  expectSubscriptions(first$.subscriptions).toBe('-^---!');
  expectSubscriptions(second$.subscriptions).toBe('-----^--!');
});

At frame 1, query a subscribes to first$; it emits x at frame 3. At frame 5, query b arrives: first$ is unsubscribed before its planned completion at frame 9, and second$ starts. That stream emits y at frame 7 and completes at frame 8. The outer query stream completes at frame 12; by then no inner work remains, so the result completes then. The assertion on first$.subscriptions proves the cancellation that the output letters alone would not reveal.

Now change first$ to emit only after frame 5 and predict the result before running the test. x disappears because the first inner subscription ends before that emission. If the implementation used mergeMap, both requests could remain active and an old result might appear after y; if it used concatMap, the new request would wait behind the slow old one. Those are different product policies, not merely different spellings of a flattening operator.

Exercise 3: Does cancellation actually stop an HTTP request?

The marble test proves teardown of the cold mock's subscription. A production HTTP adapter may abort a browser request on unsubscribe, or it may simply stop delivering its result to this observer. A Promise adapted with from(promise) cannot generally undo the work merely because the RxJS subscriber leaves. Describe the adapter's cancellation contract and verify it with an integration test if avoiding network work matters. This distinction is an inference from the Observable boundary, not a guarantee made by the marble DSL.

For the interview, add a teardown spy to a custom Observable or inspect the cold mock's subscriptions log. Ask whether the service is shared with another subscriber. If a shared request remains subscribed elsewhere, canceling one consumer need not end the producer. In that case the test should model sharing explicitly; a standalone cold() request per query cannot prove global transport lifetime.

Also test component teardown. Pass an unsubscribe marble to expectObservable(result$, '--------!') and assert that the active inner search is released at that frame. The provided diagram becomes the subscriber lifetime, not an expected output string. If your test uses takeUntil(destroy$), assert the destroy signal's time and the inner subscription's end. Never infer cleanup from “no late DOM update” alone; an abandoned subscription can continue doing work silently.

Question 4: Why does catchError placement change survival?

The RxJS catchError API replaces an errored Observable with another Observable. If the handler is inside switchMap, it replaces only that query's failed inner search. The outer queries$ can still accept later terms. If the handler is outside switchMap and returns a finite fallback such as of({kind:'error'}), the whole switched chain is replaced and then completes; later query emissions do not restart it.

For this fixture, write the recovery at the inner boundary:

const results$ = queries$.pipe(
  switchMap(term => search(term).pipe(
    map(items => ({ kind: 'results', term, items } as const)),
    catchError(() => of({ kind: 'error', term } as const))
  ))
);

Imagine query a succeeds, b errors, then c arrives. The expected stream has a success for a, a local error state tied to b, and a later result for c. The critical assertion is that the subscription to the outer query source remains open after b's #. If you move catchError outside and return a finite fallback, you should expect completion after the fallback, not a c result. A test that only asserts the error message at b misses this difference.

Recovery policy is not always “swallow everything.” If an authentication error should terminate the feature, the inner handler may rethrow or convert it to a distinct state that disables future searches. If transient failures should retry, bound attempts and delays; then use virtual time to test retry timing and cancellation of retries when a newer query arrives. State the policy before selecting catchError or retry.

A three-layer test matrix

Build a matrix around the contract instead of adding random operator cases. These are original practice expectations.

ScenarioOutput assertionSubscription assertion
One cold resultResult at its relative frame, then completionSource opens at subscribe and closes on completion
New query before old resultOnly current result reaches outputOld inner ends at new query frame
Old result before new queryEarlier result may appear, then current resultOld inner still ends when new query arrives
Inner search errorsOne error state; later query still worksFailed inner closes, outer query remains subscribed
Outer source errorsResult stream follows defined outer-error policyActive inner tears down with the chain
Component destroys mid-requestNo later UI emissionsActive inner ends at destroy frame
Shared search has two consumersEach gets allowed outputOne consumer leaving may not stop shared producer

To keep the matrix honest, record the absolute frame of each outer event, inner subscription, emission, error, and unsubscribe. Distinguish completion (|) from explicit unsubscription (!); they can produce the same absence of later output but imply different resource behavior. Where a source never completes, always bound the observed window.

What TestScheduler cannot prove by itself

The official marble-testing guide's known-issues section warns that TestScheduler virtualizes RxJS scheduler-based time, not arbitrary Promise microtasks. A fetch mock returning a native Promise may not become deterministic just because run() wraps the test. Replace the dependency with a cold Observable for the composition test, then separately test the adapter with fake network responses or the test framework's async tools.

Likewise, marble tests do not prove browser keyboard accessibility, actual AbortController behavior, cache eviction, or a server's cancellation semantics. They are exceptionally good at operator composition and subscription windows. Choose an integration or component test for the missing boundary rather than stretching the DSL into a claim it cannot support.

When a candidate finishes a marble test, ask them to explain one failing variant. If expectObservable passes but expectSubscriptions fails, what leaked? If both pass but the browser still makes two requests, what adapter or sharing behavior differs? If the error state appears but future input is ignored, where did catchError sit? Those follow-ups turn diagrams into debugging evidence.

Sources and further reading