In an RxJS marble test, how do you use expectSubscriptions and subscription marbles to prove that switchMap unsubscribes a stale inner stream?
answer
- fixtures log who subscribed
- caret in, bang out
- one marble per subscription
- an array for repeats
- second argument to expectObservable
basics
~20 sIn RxJS marble tests, hot() and cold() fixtures log every subscription. Pass fixture.subscriptions to expectSubscriptions(...).toBe(...) with subscription marbles, where ^ marks subscribe and ! unsubscribe; an array covers several subscriptions, proving the stale inner ended when the next began.
solid answer
~40 sEvery fixture created with `hot()` or `cold()` has a `subscriptions` array of `SubscriptionLog` entries, one per subscription, each with the frame it subscribed and unsubscribed. `expectSubscriptions(fixture.subscriptions).toBe(marbles)` compares that log with **subscription marbles**, a restricted syntax: `-` or a time progression for time, `^` for the subscribe frame, `!` for the unsubscribe frame, at most one of each per string. For several subscriptions you pass an array. To prove `switchMap` cancels a stale inner, feed an outer `hot('-a------b------|')` and a `cold('--x--y--z|')` inner, then assert `['-^------!', '--------^--------!']`: the first inner is unsubscribed on frame 8, exactly when `b` starts the second. The related `expectObservable(obs, '^---!')` second argument controls when the test's own subscription starts and ends, which is also how you stop an infinite source.
code
ts · 15 linesimport { switchMap } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
it('switchMap cancels the stale inner subscription', () => {
const testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
testScheduler.run(({ hot, cold, expectObservable, expectSubscriptions }) => {
const outer = hot('-a------b------|');
const inner = cold('--x--y--z|');
expectObservable(outer.pipe(switchMap(() => inner))).toBe('---x--y---x--y--z|');
expectSubscriptions(inner.subscriptions).toBe(['-^------!', '--------^--------!']);
expectSubscriptions(outer.subscriptions).toBe('^--------------!');
});
});go deeper
Recall that fixtures record their subscriptions, and that ^ means subscribed and ! means unsubscribed in subscription marbles.
Explain expectSubscriptions with single strings and arrays, and the second argument of expectObservable for late or early-leaving subscribers.
Use subscription logs to prove cancellation, leaks, retries and sharing, rather than inferring them from missing output values.
Decide which stream contracts deserve subscription-level tests, such as cancellation and shared connections, to keep suites precise without being brittle.
## Why assert subscriptions at all Output marbles show **what** a stream emitted. Many RxJS bugs are about **work that keeps running or never starts**: a stale request that is not cancelled, a source that is never unsubscribed, a retry that resubscribes too often, a shared source subscribed twice. Those are invisible in the output but visible in the **subscription log** that `TestScheduler` keeps for every fixture. ## The pieces - **`fixture.subscriptions`**: both `cold()` and `hot()` return an Observable with this property, an array of `SubscriptionLog` objects recording the subscribe and unsubscribe frames of each subscription. - **`expectSubscriptions(logs).toBe(marbles)`**: schedules an assertion, evaluated when virtual time is flushed, that the logs match the given subscription marbles. It accepts a single string or an **array of strings**, one per expected subscription. - **`expectObservable(actual$, subscriptionMarbles)`**: the optional second argument decides when the test's own subscription to `actual$` begins and ends. ## Subscription marble syntax | Character | Meaning | |---|---| | `-` | one frame passes | | `100ms`, `1s` | time progression, as in value marbles | | `^` | the subscription happens on this frame | | `!` | the unsubscription happens on this frame | Rules: **at most one `^` and at most one `!`** per string; any other character is an error. `'--^--!-'` means subscribed on frame 2, unsubscribed on frame 5. A string with no `!` means the subscription was never ended. ## Proving switchMap cancels the stale inner ```ts testScheduler.run(({ hot, cold, expectObservable, expectSubscriptions }) => { const outer = hot('-a------b------|'); const inner = cold('--x--y--z|'); expectObservable(outer.pipe(switchMap(() => inner))).toBe('---x--y---x--y--z|'); expectSubscriptions(inner.subscriptions).toBe([ '-^------!', // first inner: subscribed @1, cancelled @8 '--------^--------!', // second inner: subscribed @8, completed @17 ]); expectSubscriptions(outer.subscriptions).toBe('^--------------!'); }); ``` What the three assertions prove: 1. The output skips the first inner's `z`, which would have fallen on frame 9. 2. The **first inner was unsubscribed on frame 8**, the same frame the second one was subscribed. That is the cancellation, stated directly rather than inferred from missing values. 3. The outer source was released on frame 15 when it completed, so nothing is left subscribed. With `mergeMap` in place of `switchMap`, the first log entry would show the inner running to its own completion on frame 10 instead, `'-^--------!'`, and the output would contain both `z` values. ## Controlling the test's own subscription The second argument of `expectObservable` uses the same syntax: - `expectObservable(source$, '---^---!')` subscribes on frame 3 and unsubscribes on frame 7, which is how you test a late subscriber or a consumer that leaves early. - `expectObservable(interval(10), '35ms !')` unsubscribes on frame 35. For an endless source this is **required**: in run mode there is no frame limit, so without an unsubscription the flush never ends. ## Senior-level uses - **Leak checks**: assert that every fixture shows a `!`, meaning the operator chain released its sources. - **Teardown operators**: show that a notifier really unsubscribes the source on the expected frame. - **Retry and repeat**: count the entries in the log to prove how many times a source was resubscribed. - **Sharing**: show that two consumers of a shared stream produce **one** subscription to the underlying fixture, not two. ## Reading a failing subscription assertion When an `expectSubscriptions` check fails, the diff shows the raw log objects: each `SubscriptionLog` has a `subscribedFrame` and an `unsubscribedFrame`, and a subscription that was never ended shows `unsubscribedFrame: Infinity`. Two common readings: - `Infinity` where you expected a number means the chain **never released** the fixture, a leak. - An extra entry means the fixture was **subscribed more often** than intended, typically a missing share operator or an unexpected resubscription. Translating the frames back into a marble string, one character per frame, is usually the fastest way to see what happened. ## Pitfalls - Passing one string when the fixture was subscribed twice: the log has two entries, the expectation one, and the test fails. Use an array. - Putting two `^` in one string to express two subscriptions, which throws; again, use an array. - Reading `!` as an emission. It records the frame of unsubscription, which happens after completion, after an error, or on cancellation.
- How would the inner subscription log differ if the chain used mergeMap instead of switchMap?The first inner would not be cancelled when `b` arrives; it would run to its own completion on frame 10, so its entry becomes `'-^--------!'`, overlapping the second inner's `'--------^--------!'`. The output would then include the first inner's `z` on frame 9.
- Why is an unsubscription marble mandatory when testing an interval inside run()?Run mode removes the frame limit, so virtual time keeps flushing while tasks remain. An `interval` always schedules its next tick, so flushing never ends. Passing `'35ms !'` as the second argument of `expectObservable` unsubscribes on frame 35 and lets the flush finish.
saying these in an interview costs you the question
- Missing output values are enough to prove a request was cancelled.
- A subscription marble may contain two ^ markers for two subscriptions.
- The ! marker records a value emitted by the source.
- expectObservable always unsubscribes the tested stream at the last frame.
- Only cold() fixtures record subscriptions; hot() ones cannot be checked.