skip to content

With Angular's HttpTestingController, how do you test a user-profile service's handling of a 404 response versus a network failure?

level: middleimportance: should knowfreq 46%

answer

  1. two failure kinds, two calls
  2. flush can carry a status
  3. status text is not optional
  4. a network failure has status zero

basics

~10 s

Claim the request with expectOne, then answer a 404 with flush(body, { status: 404, statusText: 'Not Found' }) and a network failure with error(new ProgressEvent('error')); both surface as HttpErrorResponse, the second with status 0.

solid answer

~40 s

I claim the request with `expectOne` and answer it the way the failure would really arrive. A server error is `req.flush(body, { status: 404, statusText: 'Not Found' })`: any status outside 200-299 errors the stream with an `HttpErrorResponse` whose `status` is 404 and whose `error` is the flushed body. A custom status needs a `statusText`, or `flush()` throws. A network failure is `req.error(new ProgressEvent('error'))`, which produces an `HttpErrorResponse` with `status` 0 and the event as `error`. Then I assert the service's mapping: the 404 branch emits `null` and completes, the network branch propagates an error. If the service retries, each retry is a new request I must claim and answer separately.

code

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

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

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

  findProfile(id: string): Observable<UserProfile | null> {
    return this.http.get<UserProfile>(`/api/users/${id}`).pipe(
      catchError((err: HttpErrorResponse) => (err.status === 404 ? of(null) : throwError(() => err))),
    );
  }
}

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

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

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

  it('maps 404 to null', () => {
    let result: UserProfile | null | undefined;
    service.findProfile('7').subscribe((p) => (result = p));

    httpTesting
      .expectOne('/api/users/7')
      .flush({ message: 'No such user' }, { status: 404, statusText: 'Not Found' });

    expect(result).toBeNull();
  });

  it('propagates a network failure', () => {
    let failure: HttpErrorResponse | undefined;
    service.findProfile('7').subscribe({ error: (e: HttpErrorResponse) => (failure = e) });

    httpTesting.expectOne('/api/users/7').error(new ProgressEvent('error'));

    expect(failure?.status).toBe(0);
  });
});

go deeper

for a junior

Recall the two calls: flush with a status and statusText for a server error, error with a ProgressEvent for a network failure.

for a middle

Explain what each call produces: an HttpErrorResponse with the HTTP status and body, or one with status 0 and the event, and how a retry turns one request into several.

for a senior

Show that you test every mapped status the callers depend on, assert the error shape the UI consumes, and read a 'cancelled request' failure as evidence of an earlier unsubscribe.

for a principal

Discuss which failure modes deserve unit tests versus contract tests against a real backend, and how a team standardises error mapping so these tests stay small.

## Why the error path needs its own tests A user-profile service rarely just forwards the server's answer. It usually **maps failures**: a `404` becomes "no profile", a `401` triggers a sign-in, a dropped connection becomes a retryable error the UI can show. Those branches are exactly the code that the happy-path test never executes, and they are where production bugs live. Angular's HTTP testing API lets a test play each failure on demand through the **`TestRequest`** that `HttpTestingController.expectOne()` returns. There are two different kinds of failure, and Angular models them differently: - **Server error response**: the server answered, with a status outside 200-299. `HttpClient` errors the stream with an `HttpErrorResponse` whose `status` is the HTTP status and whose `error` is the **response body**. - **Network failure**: no response arrived at all (DNS failure, connection refused, CORS rejection, offline). `HttpClient` errors the stream with an `HttpErrorResponse` whose `status` is **`0`** and whose `error` is the low-level event. ## Playing a server error: flush() with a status `req.flush(body, { status, statusText, headers })` answers the request. The rules, as implemented in `TestRequest`: 1. No `status` given: the response is `200 OK`, or `204 No Content` when the body is `null`. 2. A custom `status` given: you **must also give `statusText`**. Omitting it makes `flush()` itself throw `statusText is required when setting a custom status.` 3. A status from 200 to 299 is delivered as a successful `HttpResponse`. 4. Any other status errors the stream with an `HttpErrorResponse` carrying that status, text, URL and the body as `error`. So `req.flush({ message: 'No such user' }, { status: 404, statusText: 'Not Found' })` is the canonical 404. ## Playing a network failure: error() with a ProgressEvent `req.error(new ProgressEvent('error'))` simulates a request that never got a response. By default the resulting `HttpErrorResponse` has `status: 0` and an empty `statusText`; you can pass `{ status, statusText, headers }` as a second argument when a test needs something else. The overload that accepts an `ErrorEvent` is **deprecated**, because real HTTP requests never emit one; use `ProgressEvent`. | What you simulate | Call | `status` | `error` holds | |---|---|---|---| | Server says "not found" | `flush(body, { status: 404, statusText: 'Not Found' })` | 404 | the body you flushed | | Server crashes | `flush(body, { status: 500, statusText: 'Server Error' })` | 500 | the body you flushed | | Connection never completes | `error(new ProgressEvent('error'))` | 0 | the `ProgressEvent` | | Empty success | `flush(null)` | 204 | not an error | ## What to assert - **The mapped result**: the 404 branch emits `null` (or a typed "missing" value) and completes, with no error reaching the subscriber. - **The propagated error**: the network branch errors the stream with the shape your callers expect. Capture it in the `error` callback and assert on its `status`. - **The outgoing request**, as in the happy path: method and URL. - **Nothing else was sent**, with `verify()`. Two details trip people up. First, if the service **retries**, every retry re-subscribes to the cold `HttpClient` observable and produces a **new request**. The test must claim and answer each attempt in turn with a fresh `expectOne`; answering the first one only leaves the retry open and `verify()` fails. Second, flushing or erroring a request whose subscriber already unsubscribed **throws** (`Cannot flush a cancelled request.`), which usually means the service or component cancelled earlier than the test assumed. ## A checklist for each failure test - **Subscribe with an `error` callback** when the branch should propagate, and capture the `HttpErrorResponse` rather than letting the runner report an unhandled error. - **Assert that no value was emitted** on the error branch, and that the stream **completed** on the mapped branch; a branch that both emits and errors is a bug. - **Flush a realistic body.** If the service reads `err.error.message`, the flushed body must have that shape, or the test proves nothing about the real server contract. - **Cover each status the callers distinguish** (for example 401, 404 and 5xx) instead of one generic failure. - **Keep `verify()`** in `afterEach`, so a retry you did not expect shows up as an open request. ## Where the boundary sits The operators that implement the mapping (`catchError`, `retry`) are RxJS material, and the interceptor that might turn a `401` into a redirect has its own tests. This question is about **driving** those branches from a unit test: choosing `flush` with a status or `error` with a `ProgressEvent`, and asserting the result each one produces.

  • The service pipes the request through retry(2). How does the network-failure test change?
    Each retry re-subscribes to the cold `HttpClient` observable, so a failed attempt is followed at once by a new request. The test calls `expectOne('/api/users/7').error(new ProgressEvent('error'))` three times in a row, once per attempt, before asserting the final error. Answering only the first leaves the retry open, and `verify()` then fails.
  • Why does req.flush(body, { status: 404 }) throw inside the test?
    `TestRequest.flush()` only defaults the status text when no status is given (`OK`, or `No Content` for a null body). With a custom status and no `statusText` it throws `statusText is required when setting a custom status.` Pass both, for example `{ status: 404, statusText: 'Not Found' }`.

saying these in an interview costs you the question

  • A 404 is simulated with req.error(), since it is an error
  • flush() with status 404 delivers a normal response whose body you inspect
  • flush() fills in a default statusText for any custom status
  • A network failure arrives with status 500
  • error(new ErrorEvent(...)) is the recommended way to fake a network failure
  • One flush answers every retry attempt of the same URL