Testing Services, Pipes, Signals and Resources in Angular: TestBed.inject, HttpTestingController and Fake Timers
httpResource reports loading right away but sends the request inside an effect; a test needs TestBed.tick() to see the request and whenStable() to read the value after the response. Using Bookstore's CatalogService, PricePipe and RecentlyViewed, we show runInInjectionContext, effect tests, cancellation and the error path with HttpTestingController, fake timers for debounced(), and the resource timing change in Angular 22, all with real runs.
Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Vitest 5.1 and jsdom 30 on Node.js 24.
Guess First
In the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, the catalog comes from a service. CatalogService loads the book list from /api/books with httpResource and turns the category and the search term into query parameters:
@Injectable({ providedIn: 'root' })
export class CatalogService {
readonly category = signal<string | undefined>(undefined);
readonly search = signal('');
private readonly term = debounced(() => this.search().trim(), 300);
readonly books = httpResource<Book[]>(
() => {
const category = this.category();
const search = this.term.value();
return {
url: '/api/books',
params: { ...(category ? { category } : {}), ...(search ? { search } : {}) },
};
},
{ defaultValue: [] },
);
readonly inStock = computed(() => this.books.value().filter((book) => book.stock > 0));
}
The first test checks that the request goes out as soon as the service is created:
it('loads the whole catalog', () => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
const catalog = TestBed.inject(CatalogService);
const http = TestBed.inject(HttpTestingController);
expect(catalog.books.status()).toBe('loading');
http.expectOne('/api/books');
});
If the resource's status is loading, the request must be on its way, right? Which of the two assertions passes, and which one fails?
Answer: The Resource Looks Loading, but the Request Has Not Gone Out Yet
The first assertion passed and the second one failed:
FAIL bookstore-web src/app/catalog/catalog-service.spec.ts > CatalogService > loads the whole catalog
Error: Expected one matching request for criteria "Match URL: /api/books", found none.
❯ src/app/catalog/catalog-service.spec.ts:13:10
status() returns loading the moment the service is created, but the code that sends the HTTP request has not run yet. Angular's resource implementation shows why: every resource does its loading inside an effect called loadEffect. Effects do not run when they are created; they run when Angular performs its next synchronization. In the app the scheduler that plans change detection does this; in a test we have to trigger it ourselves. The API for that is TestBed.tick(). According to its type definition it runs the pending work needed to synchronize the model with the UI; it has been public API since Angular 20, and the old TestBed.flushEffects() is deprecated in its favor.
The correct test has three steps: send the request with TestBed.tick(), answer it with flush, and finally wait for Angular to finish its work:
it('loads the whole catalog when no filter is set', async () => {
TestBed.tick();
const request = http.expectOne('/api/books');
expect(request.request.method).toBe('GET');
expect(catalog.books.status()).toBe('loading');
request.flush([dune, emma]);
await TestBed.inject(ApplicationRef).whenStable();
expect(catalog.books.status()).toBe('resolved');
expect(catalog.books.value().map((book) => book.title)).toEqual(['Dune', 'Emma']);
});
The last wait is needed too. When I logged the state right after flush, the resource was still loading and value() returned an empty array; after await appRef.whenStable() it was resolved with one book. The resource handles the response through a promise, so you need to give Angular a turn before asserting. whenStable() waits until the app has no pending work left, and a resource that is still loading counts as pending work.
Testing a Service with and without TestBed
In its first version, RecentlyViewed from post 1 was a plain class; you could create it with new RecentlyViewed() and test it without TestBed. In this post we added an effect that writes the list to localStorage:
const storageKey = 'bookstore.recentlyViewed';
@Injectable({ providedIn: 'root' })
export class RecentlyViewed {
private readonly isbns = signal<readonly string[]>(JSON.parse(localStorage.getItem(storageKey) ?? '[]'));
readonly list = this.isbns.asReadonly();
constructor() {
effect(() => localStorage.setItem(storageKey, JSON.stringify(this.isbns())));
}
mark(isbn: string): void {
this.isbns.update((list) => [isbn, ...list.filter((item) => item !== isbn)].slice(0, 3));
}
}
effect() and inject() need an injection context. When you create such a class with new, Angular throws this error (I ran the same experiment with PricePipe, which uses inject(LOCALE_ID)):
Error: NG0203: The `InjectionToken LocaleId` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
The rule is simple: if a class has something like inject(), effect() or httpResource() in its constructor or fields, you need TestBed. You get providedIn: 'root' services with TestBed.inject(). For a class that is not registered with the injector, a pipe for example, there is TestBed.runInInjectionContext():
describe('PricePipe', () => {
let pipe: PricePipe;
beforeEach(() => {
pipe = TestBed.runInInjectionContext(() => new PricePipe());
});
it('formats Turkish lira with the Turkish locale', () => {
expect(pipe.transform(1234.5)).toBe('₺1.234,50');
});
it('formats other currencies with their own symbol', () => {
expect(pipe.transform(1234.5, 'EUR')).toBe('€1.234,50');
});
it('shows free books as "Free"', () => {
expect(pipe.transform(0)).toBe('Free');
});
it('rounds to two decimals', () => {
expect(pipe.transform(0.1 + 0.2)).toBe('₺0,30');
});
});
PricePipe gets the locale with inject(LOCALE_ID) and calls formatCurrency with the currency's narrow symbol. Once the pipe exists, the rest is a pure function test: pass an input, compare the string. You could also render the pipe inside a component and read it from the DOM, but then a failure makes you hunt for the cause between the template and the pipe. Checking the pipe's own behavior here and its use in the template in a component test is clearer.
We set up in post 1 why the 'tr' locale works in tests: providersFile gives every test 'tr' as LOCALE_ID, and setupFiles calls registerLocaleData(localeTr). To see what happens without that registration, I changed LOCALE_ID to the unregistered 'de': Error: NG0701: Missing locale data for the locale "de". When a locale-dependent pipe test fails, check these two files first.
Testing Signals and Effects
computed signals are synchronous; they are computed when you read them. Testing CatalogService.inStock needs nothing extra, filling its source and reading it is enough:
it('lists only the books that are in stock', async () => {
TestBed.tick();
http.expectOne('/api/books').flush([dune, emma]);
await TestBed.inject(ApplicationRef).whenStable();
expect(catalog.inStock().map((book) => book.title)).toEqual(['Dune']);
});
Effects, on the other hand, are scheduled. To make that visible in the RecentlyViewed test I wrote two assertions: right after mark() localStorage is empty, and after TestBed.tick() it is filled.
describe('RecentlyViewed', () => {
beforeEach(() => localStorage.clear());
it('saves the list to localStorage', () => {
const recent = TestBed.inject(RecentlyViewed);
recent.mark('9780441013593');
expect(localStorage.getItem('bookstore.recentlyViewed')).toBeNull();
TestBed.tick();
expect(localStorage.getItem('bookstore.recentlyViewed')).toBe('["9780441013593"]');
});
it('restores the list saved by an earlier visit', () => {
localStorage.setItem('bookstore.recentlyViewed', '["9780141439587"]');
const recent = TestBed.inject(RecentlyViewed);
expect(recent.list()).toEqual(['9780141439587']);
});
});
The first assertion may look like it checks an implementation detail. It is there on purpose: it documents that the effect is scheduled, and if someone makes the write synchronous or removes the effect, the test tells you. The localStorage.clear() in beforeEach is required. TestBed resets the injector after each test, but jsdom's localStorage lives on between tests in the same file; when I called setItem in one test and getItem in the next, the value was still there. It is the same problem as the module state in post 1, this time in a browser API.
httpResource Tests: Catching Requests with HttpTestingController
provideHttpClientTesting() replaces HttpClient's backend with a test backend that never actually sends the request. Requests pile up in HttpTestingController; the test finds them and answers them. The Angular documentation says provideHttpClient() goes first and provideHttpClientTesting() after it, because the second one overrides parts of the first. Since Angular 21 HttpClient is available on the root injector by default; the test also worked when I wrote provideHttpClientTesting() on its own. Adding provideHttpClient() is enough when you test a customization such as an interceptor; in Bookstore we write both, in the order the documentation gives. The skeleton of the test file:
describe('CatalogService', () => {
let catalog: CatalogService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
catalog = TestBed.inject(CatalogService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
});
The service's contract is on the request side: which URL it calls and with which parameters. expectOne takes a URL string or a function. With a string it matches the full URL including the query string; when you want to check a parameter separately, a function reads better:
it('sends the selected category as a query parameter', () => {
catalog.category.set('classics');
TestBed.tick();
const request = http.expectOne((req) => req.url === '/api/books');
expect(request.request.params.get('category')).toBe('classics');
});
it('cancels the running request when the category changes', () => {
TestBed.tick();
const first = http.expectOne('/api/books');
catalog.category.set('classics');
TestBed.tick();
expect(first.cancelled).toBe(true);
http.expectOne('/api/books?category=classics');
});
The second test checks an important behavior of httpResource: when a parameter changes, it cancels the request in flight. For a user who switches categories quickly, it is what keeps an old response from overwriting the new list. TestRequest.cancelled shows this behavior directly.
The error path deserves a test as well. When you pass a status code to flush, HttpClient reports it as an HttpErrorResponse:
it('moves to the error state when the API fails', async () => {
TestBed.tick();
http.expectOne('/api/books').flush({ title: 'Database unavailable' }, { status: 503, statusText: 'Service Unavailable' });
await TestBed.inject(ApplicationRef).whenStable();
expect(catalog.books.status()).toBe('error');
expect(catalog.books.error()).toBeInstanceOf(HttpErrorResponse);
expect((catalog.books.error() as HttpErrorResponse).status).toBe(503);
expect(() => catalog.books.value()).toThrow(/error state/);
});
The last line pins down a behavior that is easy to miss: in the error state value() does not return the defaultValue, it throws. The Resource type definition says so too and recommends hasValue(). The message starts with "Resource is currently in an error state (see Error.cause for details)". Every computed that reads value(), such as inStock, threw the same error. If the catalog page template reads inStock() without @if (catalog.books.hasValue()) or a status() check, an API error shows up as a runtime error instead of an empty list. That is something the component test in post 3 should check.
For a network error there is request.error(new ProgressEvent('error')) instead of flush; according to the documentation HttpClient reports it as an error with status 0. To reload a resource manually there is reload(): calling reload() in the error state followed by TestBed.tick() moved the status to reloading.
Time: debounced() and Fake Timers
To avoid a request on every keystroke in the search box, CatalogService delays the search term with debounced(), which arrived in Angular 22. debounced() is experimental in 22.0; it returns a Resource, not a signal: value() is the delayed value and isLoading() tells you the user is still typing. Actually waiting 300 ms would slow the test down and make it timing-dependent; Vitest's fake timers let you move time forward by hand:
describe('CatalogService search', () => {
let catalog: CatalogService;
let http: HttpTestingController;
beforeEach(async () => {
vi.useFakeTimers();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
catalog = TestBed.inject(CatalogService);
http = TestBed.inject(HttpTestingController);
TestBed.tick();
http.expectOne('/api/books').flush([dune, emma]);
await vi.advanceTimersByTimeAsync(0);
});
afterEach(() => {
http.verify();
vi.useRealTimers();
});
it('waits 300 ms after the last keystroke before searching', async () => {
catalog.search.set('du');
await vi.advanceTimersByTimeAsync(200);
catalog.search.set('dune ');
await vi.advanceTimersByTimeAsync(299);
TestBed.tick();
http.expectNone((req) => req.url === '/api/books');
await vi.advanceTimersByTimeAsync(1);
TestBed.tick();
http.expectOne('/api/books?search=dune').flush([dune]);
await vi.advanceTimersByTimeAsync(0);
expect(catalog.books.value()).toEqual([dune]);
});
});
The test checks four things at once: the second keystroke arriving after 200 ms resets the timer, 299 ms after the last keystroke there is still no request, at the 300th millisecond exactly one request goes out, and the trailing space has been removed by trim(). Unlike its synchronous counterpart, advanceTimersByTimeAsync also runs the promises pending between timers; because debounced() publishes its value through a promise, the async version is required.
One thing to watch: do not use whenStable() while fake timers are on. When I wrote the same loading test with fake timers and await appRef.whenStable(), it failed after 5013 ms with "Test timed out in 5000ms." The reason is Angular's zoneless scheduler: a function called scheduleCallbackWithRafRace ties synchronization to a race between a setTimeout and a requestAnimationFrame, and fake timers stop both. Time does not move unless you move it, so whenStable() never returns. In tests with fake timers we use await vi.advanceTimersByTimeAsync(0) instead.
Resource Timing Changed in Angular 22
Angular 22 made it possible for resources that use a stream, and therefore rxResource, to provide their value synchronously (PR #67382). The change was made for SSR scenarios, but after it was merged it was labeled a breaking change because it broke some unit tests. To see the difference I ran the same test in an Angular 21.2 and an Angular 22.2 project:
it('rxResource with a synchronous observable', async () => {
const books = TestBed.runInInjectionContext(() =>
rxResource({ stream: () => of(['Dune']), defaultValue: [] as string[] }),
);
const log = [`created: ${books.status()} ${JSON.stringify(books.value())}`];
TestBed.tick();
log.push(`after tick: ${books.status()} ${JSON.stringify(books.value())}`);
await Promise.resolve();
log.push(`after microtask: ${books.status()} ${JSON.stringify(books.value())}`);
throw new Error(log.join(' | '));
});
Angular 21.2: created: loading [] | after tick: loading [] | after microtask: resolved ["Dune"]
Angular 22.2: created: loading [] | after tick: resolved ["Dune"] | after microtask: resolved ["Dune"]
In Angular 21 the resource was still loading after TestBed.tick(), and the value arrived one microtask later. In Angular 22 the value of a synchronous observable arrives inside the tick. An Angular 21 test that stubs the service with something returning of(...) and asserts "the loading indicator is visible after the tick" fails after the upgrade to 22. The behavior did not change in the code but in the test's assumption about timing. That is why tying assertions to the final state after whenStable() rather than to intermediate states makes tests more robust against version changes. If you really need to test an intermediate state, such as a loading indicator, writing the stub with a Subject controlled by the test instead of a synchronous of() makes the timing explicit.
In Production
Test the service's contract. For CatalogService that is the request URL, its parameters, the cancellation behavior and what it exposes in the error state. Verifying every internal state of httpResource is the job of Angular's own tests; yours should check what the app expects from it.
HttpTestingController knows nothing about the real backend's behavior. If the API contract changes, say the category parameter is renamed to genre, these tests stay green. Checking both sides of the contract together, end to end, is the subject of the Integration and E2E Testing series; on the frontend side, the MSW handlers in post 5 reduce this risk somewhat, because the same handlers can also be used by the development server.
Watch the test duration. At the end of this post the suite finishes 5 files and 22 tests in 452 ms, and 65% of that is still environment setup. A single setTimeout(…, 300) waiting in real time would double that. Use fake timers in every time-dependent test.
Common Pitfalls
Forgetting verify(). Without http.verify() in afterEach, a service sending one unexpected request more breaks no test. In one experiment I matched only the second request with match after a category change; verify() failed the test with this message: Expected no open requests, found 1: GET /api/books. The first, cancelled request also counts as open until it is matched.
Skipping TestBed.tick(). That is the bug at the start of this post: the resource looks loading, but there is no request yet. The same holds after changing a signal; when the category changes, the new request goes out on the next tick.
Reading the value right after flush. At the moment the response is delivered the resource is still loading. There has to be an await appRef.whenStable() in between, or await vi.advanceTimersByTimeAsync(0) when fake timers are on.
Combining fake timers with whenStable(). The test waits five seconds without an error and then fails with a timeout, and the message does not tell you why.
Assuming value() returns the defaultValue in the error state. value() throws; check with status() or hasValue(), both in tests and in templates.
A Small Experiment
★☆☆ Add a test for negative prices to PricePipe. What does -5 produce? In Bookstore, is a negative price a bug or a refund line? Once you decide, adjust the pipe and the test accordingly.
★★☆ Test a network error: use request.error(new ProgressEvent('error')) to check that the HttpErrorResponse has status 0 and the resource moves to the error state. Then call reload(), answer the second request successfully and check that the status goes back to resolved.
★★★ Add polling to CatalogService that refreshes stock every 60 seconds (setInterval + books.reload(), cleaned up with DestroyRef.onDestroy). Write a test that checks the request count at seconds 59 and 60 with fake timers, then verify that the interval stops after TestBed.resetTestingModule().
The service layer is ready. In the next post we move to the components that use these services: giving BookCard a book with setInput, listening to an output(), whenStable with zoneless and default OnPush, @defer blocks, and a host component for content projection.