Testing RxJS: Marble Testing with TestScheduler, Fake Timers and Angular Services
Time-based RxJS code is tested inside TestScheduler.run() with virtual time and marble diagrams, without waiting on the real clock and checking the frame of every value. We test Bookstore's search flow, its retry with backoff and cancelled requests with marbles, the same tests with Vitest fake timers, and Angular services with HttpTestingController, and show the Promise and delay-outside-run() pitfalls with real output.
Version note: The examples in this post were run with RxJS 7.8.2, TypeScript 6.0, Node.js 24, Vitest 5.1 and Angular 22.2 (the Angular project uses TypeScript 6.0, the range Angular 22.2 supports); the Angular tests ran with ng test on Vitest and jsdom.
Predict First
The search box of Bookstore, the online bookstore used as the shared example across this site's series, uses the settledTerm(300) operator we wrote in the previous post: 300 ms after the last key it trims the term and passes it on unless it is a repeat. Someone on the team wrote their first test:
import { of } from "rxjs";
import { expect, it } from "vitest";
import { settledTerm } from "../11/operators.ts";
it("searches 300 ms after the last key", () => {
const terms: string[] = [];
of("d", "du", "dun", "dune").pipe(settledTerm(300)).subscribe((term) => terms.push(term));
expect(terms).toEqual(["dune"]);
});
Does the test pass? If it does, how long does it take, and has it really tested the 300 ms wait?
In this post we look at two ways to test RxJS code: marble testing with RxJS's own TestScheduler, and Vitest's fake timers. With both we test the search flow from earlier posts (debounceTime + switchMap) and the retry with backoff, then move on to Bookstore's Angular services with HttpTestingController. The test vocabulary (fake, stub, spy) is in the Unit Test series' "Test Double'lar: Dummy, Stub, Spy, Mock ve Fake Arasındaki Fark" post, and the general Angular test setup in the Frontend Testleri series; here we focus on the part that is specific to RxJS.
Virtual Time and TestScheduler.run()
The test in the prediction passes, in 1 ms:
✓ src/12/01-predict.test.ts > searches 300 ms after the last key 1ms
Tests 1 passed (1)
But it does not test the 300 ms wait. of emits the four values synchronously and completes at once; as we saw in the time-based operators post, debounceTime emits its pending last value without waiting when the source completes. The test tested something a text box never does, an input that completes. It would also pass with settledTerm(0) or settledTerm(5000).
To test time you have to control time. RxJS provides TestScheduler in rxjs/testing for that. While the run() callback executes, time-based operators such as debounceTime, delay and timer use a virtual clock instead of the real one; when the callback returns, the virtual clock is run to the end synchronously. We already ran the timed examples in this series this way; this time we compare the results with an expectation instead of printing them. We added Vitest 5.1 to the examples project; the tests run with npm test, and vitest.config.ts collects only the *.test.ts files under src, not the tests of bookstore-web:
import { TestScheduler } from "rxjs/testing";
import { beforeEach, expect, it } from "vitest";
import { settledTerm } from "../11/operators.ts";
let testScheduler: TestScheduler;
beforeEach(() => {
// the scheduler compares the recorded frames with deep equality; Vitest reports the difference
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
it("emits a term 300 ms after the last key", () => {
testScheduler.run(({ hot, expectObservable }) => {
// "d", "du", "dun", "dune" 100 ms apart: a value takes one frame, so "a 99ms b" is 100 ms
const input = hot("a 99ms b 99ms c 99ms d", { a: "d", b: "du", c: "dun", d: "dune" });
expectObservable(input.pipe(settledTerm(300))).toBe("600ms d", { d: "dune" });
});
});
it("trims the term and skips a repeat", () => {
testScheduler.run(({ hot, expectObservable }) => {
const input = hot("a 399ms b 399ms c", { a: "dune", b: "dune ", c: " emma" });
expectObservable(input.pipe(settledTerm(300))).toBe("300ms a 799ms c", { a: "dune", c: "emma" });
});
});
The TestScheduler constructor takes a function that compares two values; RxJS does not know which test framework you use, so we leave the comparison to Vitest's expect(…).toEqual(…). We saw marble syntax in the marble diagram post; inside run() it has a few extra rules. A frame is 1 virtual millisecond. Time progression expressions such as 100ms or 1.5s advance time by that amount and, unless they come first, need a space on each side. Spaces do not advance time inside run(); they are only for alignment. hot() creates a source that is already running, cold() one that starts over on every subscription. The expected output uses the same syntax: "600ms d" means the value "dune" at millisecond 600 and no completion.
There is a trap: a value character also takes a frame. If you write the input as "a 100ms b 100ms c 100ms d" because the user types 100 ms apart, the test fails like this:
AssertionError: expected [ { frame: 603, …(1) } ] to deeply equal [ { frame: 600, …(1) } ]
- Expected
+ Received
@@ -1,8 +1,8 @@
[
{
- "frame": 600,
+ "frame": 603,
"notification": {
"error": undefined,
"kind": "N",
"value": "dune",
},
a is emitted at frame 0 and advances one frame, then 100 ms pass; b lands at millisecond 101. The RxJS docs say so explicitly and suggest subtracting 1 from the time you want; that is why the test says "a 99ms b". When you run a new marble test for the first time, look at the frame numbers: the failure message tells you exactly which frame was expected and which one arrived.
hot, cold and expectSubscriptions: The Search Flow
What matters in the search flow from the higher-order mapping post is not so much the output as the cancellation: when the user moves to a new term, the old request must be dropped. That is hard to see in the output, but every Observable created with cold() records who subscribed and unsubscribed when, and expectSubscriptions can test that:
import { switchMap, tap } from "rxjs";
import { TestScheduler } from "rxjs/testing";
import { beforeEach, expect, it } from "vitest";
import { settledTerm } from "../11/operators.ts";
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
it("cancels the request for a term the user has moved on from", () => {
testScheduler.run(({ hot, cold, expectObservable, expectSubscriptions, flush }) => {
const input = hot("a 399ms b", { a: "em", b: "emma" });
// fake API answers: each request takes 500 ms, then completes
const responses = {
em: cold("500ms r|", { r: ["Emma"] }),
emma: cold("500ms r|", { r: ["Emma"] }),
};
const requests: string[] = [];
const results = input.pipe(
settledTerm(300),
tap((term) => requests.push(term)),
switchMap((term) => responses[term as keyof typeof responses]),
);
expectObservable(results).toBe("1200ms r", { r: ["Emma"] });
// "em" starts at 300 ms and is dropped at 700 ms, when "emma" settles
expectSubscriptions(responses.em.subscriptions).toBe("300ms ^ 399ms !");
expectSubscriptions(responses.emma.subscriptions).toBe("700ms ^ 500ms !");
// flush runs the virtual clock now, so side effects can be checked inside run()
flush();
expect(requests).toEqual(["em", "emma"]);
});
});
In subscription marbles ^ is the moment of subscribing and ! the moment of unsubscribing. The "em" request started at 300 ms and was dropped when "emma" settled at 700 ms; the "emma" request started at 700 ms and ended when it completed 500 ms later. We tried turning switchMap into mergeMap: since the "em" request is never dropped, an extra result arrives at 800 ms and the test fails with expected [ { frame: 800, …(1) }, …(1) ] to deeply equal [ { frame: 1200, …(1) } ].
The test ends with flush(). expectObservable and expectSubscriptions do not compare right away; they schedule the comparison for when the virtual clock is done. If you want to check a side effect such as the requests collected with tap using a normal expect inside run(), you first have to advance the virtual clock with flush(); otherwise the array is still empty.
Testing the Retry with Backoff
The most important behaviours of the retry from the error handling post were the growing waits, never retrying a 4xx, and stopping after a successful attempt. All three turn into a marble test:
import { defer } from "rxjs";
import { TestScheduler } from "rxjs/testing";
import { beforeEach, expect, it } from "vitest";
import { retryWithBackoff } from "../11/operators.ts";
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
const httpError = (status: number) => ({ status });
const isRetryable = (err: unknown) => (err as { status: number }).status >= 500;
it("waits 500, 1000 and 2000 ms, then gives up", () => {
testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
const request = cold("-#", undefined, httpError(503));
// random: () => 1 removes the jitter, so the delays are the ceilings
const result = request.pipe(retryWithBackoff({ count: 3, isRetryable, random: () => 1 }));
expectObservable(result).toBe("3504ms #", undefined, httpError(503));
expectSubscriptions(request.subscriptions).toBe([
"^!",
"501ms ^!",
"1502ms ^!",
"3503ms ^!",
]);
});
});
it("does not retry a 404", () => {
testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
const request = cold("-#", undefined, httpError(404));
const result = request.pipe(retryWithBackoff({ count: 3, isRetryable, random: () => 1 }));
expectObservable(result).toBe("-#", undefined, httpError(404));
expectSubscriptions(request.subscriptions).toBe("^!");
});
});
it("succeeds on the third attempt", () => {
testScheduler.run(({ cold, expectObservable }) => {
let attempt = 0;
// every subscription is a new attempt: two 503s, then the answer
const request = defer(() =>
++attempt < 3 ? cold("-#", undefined, httpError(503)) : cold("-(s|)", { s: 12 }),
);
const result = request.pipe(retryWithBackoff({ count: 3, isRetryable, random: () => 1 }));
expectObservable(result).toBe("1503ms (s|)", { s: 12 });
});
});
In the previous post we gave retryWithBackoff a random option; here we pass () => 1 to remove the jitter, and the waits are exactly 500, 1000 and 2000 ms. Everything in this test can be read frame by frame. cold("-#") fails 1 ms after it is subscribed. The first attempt starts at 0 and ends at 1, 500 ms of waiting and the second attempt starts at 501; error at 502, 1000 ms of waiting and 1502; then 2000 ms of waiting and 3503. The fourth error is passed down at 3504. Making the randomness injectable pays off here: you could not test an operator that calls Math.random directly this precisely.
The third test uses defer for a source that behaves differently on each subscription: 503 on the first two attempts, the answer on the third. A real HTTP request is exactly like that: every retry is a new subscription and a new request.
The Same Tests with Vitest Fake Timers
RxJS's time-based operators end up calling setTimeout and setInterval, and they measure waits with Date.now(). Vitest's vi.useFakeTimers() replaces all of these with a fake clock; vi.advanceTimersByTime(ms) moves it forward. The same three scenarios:
import { defer, map, Subject, switchMap, throwError, timer } from "rxjs";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { retryWithBackoff, settledTerm } from "../11/operators.ts";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("emits a term 300 ms after the last key", () => {
const input = new Subject<string>();
const terms: string[] = [];
input.pipe(settledTerm(300)).subscribe((term) => terms.push(term));
for (const term of ["d", "du", "dun", "dune"]) {
input.next(term);
vi.advanceTimersByTime(100);
}
// 100 ms after "dune": nothing yet
expect(terms).toEqual([]);
vi.advanceTimersByTime(200);
expect(terms).toEqual(["dune"]);
});
it("cancels the request for a term the user has moved on from", () => {
const input = new Subject<string>();
const requests: string[] = [];
const results: string[][] = [];
input
.pipe(
settledTerm(300),
switchMap((term) => {
requests.push(term);
return timer(500).pipe(map(() => [term]));
}),
)
.subscribe((titles) => results.push(titles));
input.next("em");
vi.advanceTimersByTime(400);
input.next("emma");
vi.advanceTimersByTime(1000);
expect(requests).toEqual(["em", "emma"]);
expect(results).toEqual([["emma"]]);
});
it("waits 500, 1000 and 2000 ms, then gives up", () => {
const attempts: number[] = [];
let failure: unknown;
defer(() => {
attempts.push(Date.now());
return throwError(() => ({ status: 503 }));
})
.pipe(retryWithBackoff({ count: 3, random: () => 1 }))
.subscribe({ error: (err: unknown) => (failure = err) });
vi.runAllTimers();
const start = attempts[0];
expect(attempts.map((t) => t - start)).toEqual([0, 500, 1500, 3500]);
expect(failure).toEqual({ status: 503 });
});
✓ src/12/01-predict.test.ts > searches 300 ms after the last key 1ms
✓ src/12/03-search.test.ts > cancels the request for a term the user has moved on from 4ms
✓ src/12/04-retry-with-backoff.test.ts > waits 500, 1000 and 2000 ms, then gives up 2ms
✓ src/12/04-retry-with-backoff.test.ts > does not retry a 404 0ms
✓ src/12/04-retry-with-backoff.test.ts > succeeds on the third attempt 0ms
✓ src/12/02-settled-term.test.ts > emits a term 300 ms after the last key 3ms
✓ src/12/02-settled-term.test.ts > trims the term and skips a repeat 0ms
✓ src/12/05-fake-timers.test.ts > emits a term 300 ms after the last key 3ms
✓ src/12/05-fake-timers.test.ts > cancels the request for a term the user has moved on from 1ms
✓ src/12/05-fake-timers.test.ts > waits 500, 1000 and 2000 ms, then gives up 1ms
✓ src/12/06-pitfalls.test.ts > a Promise does not run on virtual time 4ms
✓ src/12/06-pitfalls.test.ts > delay outside run() waits in real time 1002ms
✓ src/12/06-pitfalls.test.ts > delay inside run() uses virtual time 0ms
Test Files 6 passed (6)
Tests 12 passed | 1 expected fail (13)
Every test passes, and the only one that waits a real second is the delay trap we wrote on purpose below. The two approaches test the same thing but answer different questions well:
| Topic | TestScheduler (marble) | Vitest fake timers |
|---|---|---|
| What is checked | The exact frame of every value, error and subscription | The state at chosen moments (arrays, counters) |
| How sources are made | With hot and cold, in one line | Subject, defer, manual next |
| Testing cancellation | expectSubscriptions shows it directly | Indirectly, through results or counters |
Promises and fetch | Stay outside virtual time | Microtasks have to be awaited separately |
| Code outside RxJS | Controls only RxJS schedulers | The same clock covers any code using setTimeout |
| Reading it | Hard for someone who does not know marble syntax | Ordinary test code |
In practice, choose marble tests for your own custom operators: an operator's contract is defined on the time axis, and marbles state it most directly. For Angular services and places where RxJS mixes with other code, fake timers feel more natural; the tests in the next section are like that.
Testing Angular Services: HttpTestingController
If an Angular service returns an Observable from HttpClient, testing it needs no real server. provideHttpClientTesting() replaces the backend under HttpClient with a test backend: requests do not go to the network, they wait in HttpTestingController, and the test answers them with flush. The promise of CategoryService from the multicasting post was "one request for the whole app":
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { apiUrl } from '../api';
import { CategoryService } from './category-service';
describe('CategoryService', () => {
let service: CategoryService;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(CategoryService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// fails the test if a request was sent that the test did not expect
httpTesting.verify();
});
it('sends one request for every subscriber, early or late', () => {
const seen: string[][] = [];
service.categories$.subscribe((list) => seen.push(list));
service.categories$.subscribe((list) => seen.push(list));
// expectOne fails if there are zero or two matching requests
const request = httpTesting.expectOne(`${apiUrl}/categories`);
expect(request.request.method).toBe('GET');
request.flush(['science-fiction', 'classics']);
// a late subscriber gets the cached list without a new request
service.categories$.subscribe((list) => seen.push(list));
expect(seen).toEqual([
['science-fiction', 'classics'],
['science-fiction', 'classics'],
['science-fiction', 'classics'],
]);
});
});
expectOne expects exactly one matching request; with zero or two it fails the test. verify() in afterEach fails the test if an unexpected request is left over, which also checks that the late third subscriber sent no new request. The polling OrderService.status$ runs on timer(0, 5000); here fake timers and HttpTestingController work together:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { apiUrl } from '../api';
import { OrderService, type OrderStatus } from './order-service';
describe('OrderService.status$', () => {
let service: OrderService;
let httpTesting: HttpTestingController;
const statusUrl = `${apiUrl}/orders/1042/status`;
beforeEach(() => {
// timer(0, 5000) runs on setInterval, so fake timers move the polling forward
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(OrderService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTesting.verify();
vi.useRealTimers();
});
it('polls every 5 seconds and stops after Delivered', () => {
const statuses: string[] = [];
let completed = false;
service.status$(1042).subscribe({
next: (order) => statuses.push(order.status),
complete: () => (completed = true),
});
vi.advanceTimersByTime(0);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Paid' } satisfies OrderStatus);
vi.advanceTimersByTime(5000);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Shipped' } satisfies OrderStatus);
vi.advanceTimersByTime(5000);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Delivered' } satisfies OrderStatus);
// after Delivered: no more requests, however long we wait
vi.advanceTimersByTime(60_000);
httpTesting.expectNone(statusUrl);
expect(statuses).toEqual(['Paid', 'Shipped', 'Delivered']);
expect(completed).toBe(true);
});
it('never has two requests in flight when the API is slow', () => {
service.status$(1042).subscribe();
vi.advanceTimersByTime(0);
const first = httpTesting.expectOne(statusUrl);
// the answer takes 12 seconds: two ticks of the timer pass meanwhile
vi.advanceTimersByTime(12_000);
httpTesting.expectNone(statusUrl);
first.flush({ id: 1042, status: 'Paid' } satisfies OrderStatus);
// exhaustMap ignored the ticks at 5 s and 10 s; the next request comes at 15 s
vi.advanceTimersByTime(3000);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Delivered' } satisfies OrderStatus);
});
});
The second test checks the rule from the time-based operators post that a new request must not start before the previous one has finished: the API does not answer for 12 seconds, the timer fires twice meanwhile, and exhaustMap ignores both. The tests in the Angular project run with npx ng test --watch=false; Angular 22's default test builder runs Vitest on jsdom and opens no browser. describe, it and vi are not imported in the spec files because of the vitest/globals type in tsconfig.spec.json:
✓ |bookstore-web| src/app/catalog/category-service.spec.ts > CategoryService > sends one request for every subscriber, early or late 8ms
✓ |bookstore-web| src/app/orders/order-service.spec.ts > OrderService.status$ > polls every 5 seconds and stops after Delivered 9ms
✓ |bookstore-web| src/app/orders/order-service.spec.ts > OrderService.status$ > never has two requests in flight when the API is slow 1ms
Test Files 2 passed (2)
Tests 3 passed (3)
We measured each test's value by breaking the behaviour it is meant to protect and checking that it fails. With exhaustMap turned into mergeMap, and with shareReplay removed from CategoryService:
--- order-service.ts with mergeMap instead of exhaustMap
FAIL |bookstore-web| src/app/orders/order-service.spec.ts > OrderService.status$ > never has two requests in flight when the API is slow
Error: Expected zero matching requests for criteria "Match URL: http://localhost:5180/api/orders/1042/status", found 2.
--- category-service.ts without shareReplay
FAIL |bookstore-web| src/app/catalog/category-service.spec.ts > CategoryService > sends one request for every subscriber, early or late
Error: Expected one matching request for criteria "Match URL: http://localhost:5180/api/categories", found 2 requests.
Both tests failed with a message that states exactly the rule they protect. Finally, the no-ignored-subscription rule we added in the previous post flagged every subscribe call in the spec files. In a test, subscribing to see the values is the whole point; we turned the rule off for spec files only:
{
// in a test, subscribing to look at the values is the point
files: ['src/**/*.spec.ts'],
rules: { 'rxjs-x/no-ignored-subscription': 'off' },
},
The Limits of TestScheduler: Promises and Outside run()
TestScheduler puts only RxJS's schedulers on virtual time. Promises run on JavaScript's microtask queue, and RxJS has no way to control them. That is why two broken tests are common; we wrote both on purpose:
// it.fails: this test is expected to fail, see the post for the message
it.fails("a Promise does not run on virtual time", () => {
testScheduler.run(({ expectObservable }) => {
const price = defer(() => Promise.resolve(450));
expectObservable(price).toBe("(p|)", { p: 450 });
});
});
it("delay outside run() waits in real time", async () => {
const term = await firstValueFrom(of("dune").pipe(delay(1000)));
expect(term).toBe("dune");
});
it("delay inside run() uses virtual time", () => {
testScheduler.run(({ expectObservable }) => {
expectObservable(of("dune").pipe(delay(1000))).toBe("1000ms (a|)", { a: "dune" });
});
});
With the it.fails mark removed from the first test, Vitest says:
AssertionError: expected [] to deeply equal [ { frame: +0, …(1) }, …(1) ]
Compared values have no visual difference.
Expected: a value and completion at frame 0; received: nothing. When run() finished the virtual clock synchronously and compared, the Promise had not resolved yet; by the time it did, the test was long over. The line "Compared values have no visual difference" is misleading too: Vitest shortens the diff and shows both sides the same, but the [] in the first line is the real answer. The RxJS docs also say that code consuming a Promise cannot be tested reliably with TestScheduler. Solve it with an async test that waits with firstValueFrom, or by replacing the Promise with an Observable; on the Angular side HttpClient already returns an Observable, so the problem does not come up.
The second and third tests measure the same delay(1000). The durations:
✓ src/12/06-pitfalls.test.ts > a Promise does not run on virtual time 4ms
✓ src/12/06-pitfalls.test.ts > delay outside run() waits in real time 1002ms
✓ src/12/06-pitfalls.test.ts > delay inside run() uses virtual time 0ms
Outside run(), delay sets a real setTimeout and the test really waits a second. For one test it seems harmless, but a hundred such tests mean minutes in CI; worse, tests that depend on real time can fail at random under load.
In Production
Everything time-dependent should be testable: waits, randomness and the clock. Leave parameters, like the random option of retryWithBackoff, whose default is used in production but which a test can replace. In Angular you can do this with an injection token too; what matters is that no hidden Math.random() or Date.now() stays inside the operator.
In tests with fake timers, do not forget vi.useRealTimers() in afterEach. If you do, later tests in the same file run on the fake clock and an unrelated test may time out; these failures take long to find, because the failing test is not the one that made the mistake.
To keep marble tests readable in code review, align the input and the expected marble under each other and choose meaningful value letters. Let a test's name say what it protects: "cancels the request for a term the user has moved on from" is a rule, "switchMap test" is not. Finally, after writing a test, break the line it is meant to protect once, as above, and watch it fail; a test that never fails may be protecting nothing.
Common Pitfalls
- Testing time-based operators with a source that completes (
of,from(array)).debounceTimeand friends emit what is pending at once on completion; the test never measures time. - Forgetting that a value character also takes a frame in time progression. In
"a 100ms b",bis at millisecond 101. - Checking side effects with
expectinsiderun()without callingflush(). The virtual clock has not moved yet, so the values are empty. - Trying to test code that consumes a Promise with marbles. The Promise stays outside virtual time.
- Waiting in real time in a test that uses
delay,timerordebounceTimeoutsiderun(). Tests get slow and fail at random under load. - Not restoring real timers at the end of a test.
- Using
Math.random()orDate.now()directly inside an operator. No exact expectation can be written.
A Small Experiment
★☆☆ Add a test to 02-settled-term.test.ts that checks the empty string is emitted too when the user deletes the term. Which letter will you use for the empty string in the marble, and how will you define it in the values object?
★★☆ Turn the switchMap in 03-search.test.ts into concatMap and see which lines of the test fail. Rewrite the test for concatMap's behaviour: at which frame does the "emma" result arrive, and what is the subscription marble of the "em" request?
★★★ Write an Angular test for errorInterceptor: a GET request that gets 503 twice and then 200 should return a single answer and log two warnings; a POST request should never be retried on 503. Pin Math.random with vi.spyOn, move time with fake timers and answer the requests with HttpTestingController.
The series started with RxJS 7.8 and may look finished here, but RxJS itself is changing. In the last post we look at the changes from RxJS 6 to 7, at RxJS 8, which was stopped before release, at RxJS 9 in beta and at the native Observable arriving in browsers, and decide when an Angular project should upgrade.