skip to content

In Angular, how do you unit-test a service that loads a user profile through HttpClient without sending a real network request?

level: juniorimportance: must knowfreq 66%

answer

  1. swap the transport, not the client
  2. one provider function in TestBed
  3. a controller injected from TestBed
  4. claim, answer, then check leftovers

basics

~10 s

Add provideHttpClientTesting() to TestBed's providers, subscribe to the service call, then use HttpTestingController to expectOne the request, flush a canned body, assert the result, and call verify() so no unclaimed request remains.

solid answer

~30 s

I configure `TestBed` with `provideHttpClientTesting()`, which rebinds `HttpClient`'s `HttpBackend` to an in-memory test backend and provides `HttpTestingController`. The service keeps the real `HttpClient` and interceptor chain; only the transport is fake. I subscribe to `getProfile('42')`, because the request is only sent on subscribe, then `expectOne('/api/users/42')` hands me a `TestRequest` to assert on (method, headers, body). `req.flush({...})` answers it synchronously and I assert on what the service emitted. Finally `verify()`, usually in `afterEach`, fails if any request stayed unclaimed. Since v21 `HttpClient` is provided in root, so `provideHttpClient(...)` is only needed for features like interceptors, and it must come before `provideHttpClientTesting()`.

code

ts · 44 lines
ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs';

export interface UserProfile {
  id: string;
  displayName: string;
}

@Injectable({ providedIn: 'root' })
export class UserProfileService {
  private http = inject(HttpClient);

  getProfile(id: string): Observable<UserProfile> {
    return this.http.get<UserProfile>(`/api/users/${id}`);
  }
}

describe('UserProfileService', () => {
  let service: UserProfileService;
  let httpTesting: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [provideHttpClientTesting()] });
    service = TestBed.inject(UserProfileService);
    httpTesting = TestBed.inject(HttpTestingController);
  });

  afterEach(() => httpTesting.verify());

  it('loads the profile for an id', () => {
    let result: UserProfile | undefined;
    service.getProfile('42').subscribe((profile) => (result = profile));

    const req = httpTesting.expectOne('/api/users/42', 'load profile 42');
    expect(req.request.method).toBe('GET');

    req.flush({ id: '42', displayName: 'Ada' });

    expect(result).toEqual({ id: '42', displayName: 'Ada' });
  });
});

go deeper

for a junior

Recall the three names: provideHttpClientTesting in the providers, HttpTestingController from TestBed.inject, and the expectOne, flush, verify sequence. Remember to subscribe first.

for a middle

Explain that only HttpBackend is swapped, so interceptors still run, why provider order matters, and what each controller call removes from the open list.

for a senior

Show you keep these tests honest: verify() in afterEach, descriptive expectOne labels, asserting the outgoing request and not only the response, and knowing when faking the HTTP boundary is the wrong seam.

for a principal

Discuss where HTTP-level fakes sit in a suite's portfolio, how shared setup helpers keep provider order correct across hundreds of specs, and what the suite still cannot prove without a real backend.

## What the test actually replaces A service that talks to a server through Angular's `HttpClient` should be unit-tested **without a real network**: the test must be fast, deterministic and able to play any server behaviour on demand. Angular solves this at the **transport layer**, not by mocking `HttpClient`. - **`HttpClient`** is the service your code injects. It builds an `HttpRequest`, runs it through the **interceptor chain**, and finally hands it to an `HttpBackend`. - **`HttpBackend`** is the transport. Since v22 the default is `FetchBackend` (the `fetch` API); `provideHttpClient(withXhr())` selects `HttpXhrBackend`. - **`provideHttpClientTesting()`**, from `@angular/common/http/testing`, rebinds `HttpBackend` to an in-memory **test backend** and provides **`HttpTestingController`** to talk to it. So the service under test still uses the real `HttpClient`, the real URL building and the real interceptors; only the last hop is fake. Requests are held in a list of **open requests** until the test answers them. The testing providers also stop those pending requests from counting against application stability, so an unanswered request does not keep the test's `whenStable()` waiting. ## Setting up the test 1. Call `TestBed.configureTestingModule({ providers: [provideHttpClientTesting()] })`. Since v21 `HttpClient` is provided in root, so `provideHttpClient()` is only needed when the test must configure features such as interceptors. 2. If you do add `provideHttpClient(...)`, put it **before** `provideHttpClientTesting()`. Both register an `HttpBackend` binding and the later one wins; reversed, the real backend comes back. 3. Get the service with `TestBed.inject(UserProfileService)` and the controller with `TestBed.inject(HttpTestingController)`. 4. **Subscribe** to the service call. `HttpClient` observables are cold: nothing reaches the backend until someone subscribes. 5. Claim the request, assert on it, answer it, assert on what the service emitted. 6. Call `verify()`, usually in `afterEach`, to prove nothing unexpected was sent. `HttpClientTestingModule` still exists for NgModule-era suites, but it is **deprecated**; its note says to add `provideHttpClientTesting()` to the providers instead. ## The controller's four calls | Call | Returns | Fails when | |---|---|---| | `expectOne(matcher, description?)` | one `TestRequest` | zero or more than one open request matches | | `match(matcher)` | `TestRequest[]` | never; it only collects | | `expectNone(matcher, description?)` | nothing | any open request matches | | `verify(opts?)` | nothing | any request is still open (unclaimed) | A matcher is a **URL string**, a `{ method, url }` object, or a **predicate** over the `HttpRequest`. String and object matchers compare the **full URL including the query string**. Every request returned by `expectOne` or `match` is **removed from the open list**, so it cannot match twice and `verify()` no longer sees it. ## Reading and answering a TestRequest - `req.request` is the outgoing `HttpRequest`: assert `method`, `headers`, `body`, `params`. - `req.flush(body, opts?)` answers it. With no status it is `200 OK`, or `204 No Content` when the body is `null`; a status outside 200-299 errors the stream with an `HttpErrorResponse`. - `req.error(new ProgressEvent('error'))` simulates a network failure. - `req.event(...)` pushes an arbitrary `HttpEvent`, such as a progress event. - `req.cancelled` tells you whether the subscriber already unsubscribed; flushing a cancelled request throws. `flush()` delivers the response **synchronously** through the observable chain, so the assertion on the emitted value can follow it on the next line unless the service itself adds asynchronous steps. ## Mistakes interviewers look for - Calling `service.getProfile('42')` and never subscribing, then wondering why `expectOne` finds nothing. - Believing `provideHttpClientTesting()` replaces `HttpClient` itself with a stub; it replaces only the backend. - Forgetting `verify()`, so an extra or duplicated request goes unnoticed. - Thinking `verify()` checks that every claimed request was flushed; it checks only for **unclaimed** open requests. - Registering `provideHttpClient()` after the testing providers and silently re-enabling the real transport. ## Why the backend is the right place for the fake Replacing `HttpClient` itself with a hand-made object is possible, but it throws away most of what the test could check: - **URL and parameter building** in the service would no longer run through `HttpClient`, so a wrong query parameter goes unnoticed. - **Interceptors** configured through `provideHttpClient(withInterceptors([...]))` would be skipped, so the test no longer sees the headers the real request carries. - **Response typing and error wrapping** (`HttpErrorResponse` for non-2xx statuses) would have to be imitated by the fake, and imitations drift. - The fake would need a method per verb and overload the service uses, while the test backend handles every request the same way. Keeping the real client and faking only the transport means the test observes the **exact request** the app would send, and answers it with the **exact response objects** the app would receive. Choosing whether to fake at this layer or at the application's own API-client module is a broader testing-strategy question; this setup is Angular's built-in answer for the HTTP boundary.

  • Why must provideHttpClient(...) come before provideHttpClientTesting() when the test needs interceptors?
    Both register a binding for `HttpBackend`: `provideHttpClient()` points it at the real transport and `provideHttpClientTesting()` points it at the test backend. For the same token the later provider wins, so the testing providers must come last. Reversed, the real backend is back in charge and the test tries to reach the network while `expectOne` finds nothing.
  • Is HttpClientTestingModule still the way to set this up?
    No. It still exists for NgModule-era suites but is deprecated, and its own note says to add `provideHttpClientTesting()` to the providers instead. The module simply imports `HttpClientModule` and provides `provideHttpClientTesting()`, so migrating is a one-line change in `configureTestingModule`.
  • How do you match on the HTTP method as well as the URL?
    Pass the object form, `expectOne({ method: 'GET', url: '/api/users/42' })`, or a predicate such as `expectOne((req) => req.method === 'GET' && req.url === '/api/users/42')`. The string and object forms compare the full URL including its query string, so a predicate is the tool when you want to ignore or inspect parameters separately.

saying these in an interview costs you the question

  • provideHttpClientTesting() replaces HttpClient itself with a stub object
  • Calling the service method sends the request even if nobody subscribes
  • verify() fails for a request that was claimed but never flushed
  • expectOne returns the first match when several requests fit
  • flush() always answers asynchronously, so every assertion needs an await
  • HttpClientTestingModule is the recommended setup for new tests