There are two common ways to test a service that talks to an API, and only one of them tests the service. The first replaces HttpClient with a stub whose get() returns of(fakeData). That proves your map operator works, and nothing else. It won’t catch a misspelled query parameter, a POST that should have been a PUT, a missing Idempotency-Key header or an ID that wasn’t URL-encoded. Those are exactly the bugs that reach production, because the backend team sees a malformed request and the frontend team sees a passing test.
The second way keeps the real HttpClient and swaps only the transport. Angular’s testing backend records every request your service makes and lets the test inspect it and decide how it ends. That’s what this guide calls a request contract test: it pins down what your frontend promises to send, and how it reacts to each kind of answer.
Setting up the testing backend
On Angular 15 and later, with standalone providers, the setup is two lines in TestBed:
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const service = TestBed.inject(OrdersService);
const httpTesting = TestBed.inject(HttpTestingController);Order matters. provideHttpClientTesting() replaces the backend that provideHttpClient() registers, so it has to come second. If your app config passes interceptors with withInterceptors([...]), pass the same call in the test and the interceptors run for real, which is usually what you want. Older code uses HttpClientTestingModule; it still works but has been deprecated since Angular 18 in favour of the provider functions.
Requests made through this backend never touch the network. They sit in a queue until the test finds them with expectOne, match or expectNone, and completes them with flush() (a response) or error() (a network failure).
The service under test
Here’s a realistic service. The backend speaks snake_case and cents; the frontend wants camelCase, euros and Date objects. A missing order is an expected outcome, so find turns a 404 into null. Anything else becomes a typed error.
export interface Order { id: string; total: number; placedAt: Date; }
interface OrderDto { id: string; total_cents: number; placed_at: string; }
export interface OrderPage { orders: Order[]; total: number; }
export class OrderLoadError extends Error {
constructor(readonly status: number) {
super(`Orders failed to load (HTTP ${status})`);
}
}
@Injectable({ providedIn: 'root' })
export class OrdersService {
private readonly http = inject(HttpClient);
search(status: 'open' | 'shipped', page = 1): Observable<OrderPage> {
const params = new HttpParams().set('status', status).set('page', page);
return this.http.get<{ items: OrderDto[]; total: number }>('/api/orders', { params }).pipe(
map((res) => ({ total: res.total, orders: res.items.map(toOrder) })),
catchError((err: HttpErrorResponse) => throwError(() => new OrderLoadError(err.status))),
);
}
find(id: string): Observable<Order | null> {
return this.http.get<OrderDto>(`/api/orders/${encodeURIComponent(id)}`).pipe(
map(toOrder),
catchError((err: HttpErrorResponse) =>
err.status === 404 ? of(null) : throwError(() => new OrderLoadError(err.status)),
),
);
}
cancel(id: string, reason: string, idempotencyKey: string): Observable<void> {
return this.http.post<void>(`/api/orders/${encodeURIComponent(id)}/cancel`, { reason }, {
headers: { 'Idempotency-Key': idempotencyKey },
});
}
}
function toOrder(dto: OrderDto): Order {
return { id: dto.id, total: dto.total_cents / 100, placedAt: new Date(dto.placed_at) };
}Each test below was run against Angular 20’s real HttpTestingController; the error messages quoted are the ones it produced.
Asserting the request, then the mapping
it('asks for one page of open orders and maps the DTO', () => {
let result: OrderPage | undefined;
service.search('open', 2).subscribe((r) => (result = r));
const req = httpTesting.expectOne((r) => r.url === '/api/orders');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('status')).toBe('open');
expect(req.request.params.get('page')).toBe('2');
req.flush({
total: 41,
items: [{ id: 'A-1001', total_cents: 4999, placed_at: '2026-10-01T09:30:00Z' }],
});
expect(result!.total).toBe(41);
expect(result!.orders[0].total).toBe(49.99);
expect(result!.orders[0].placedAt.toISOString()).toBe('2026-10-01T09:30:00.000Z');
});Two details. First, subscribe() comes before expectOne. HttpClient observables are cold: no subscription, no request, and expectOne fails with “found none”. Second, the test uses a predicate on r.url and then checks params one by one. That’s a deliberate choice, because a plain string doesn’t do what most people expect:
httpTesting.expectOne('/api/orders');Expected one matching request for criteria "Match URL: /api/orders", found none.
Requests received are: GET /api/orders?status=open&page=1.A string is compared against the full URL including the query string. You can match '/api/orders?status=open&page=1' exactly, but then the test also depends on the order in which parameters were added. The predicate plus params.get() asserts what matters and nothing more. For writes, the object form reads well:
service.cancel('A-9', 'duplicate', 'key-123').subscribe();
const req = httpTesting.expectOne({ method: 'POST', url: '/api/orders/A-9/cancel' });
expect(req.request.body).toEqual({ reason: 'duplicate' });
expect(req.request.headers.get('Idempotency-Key')).toBe('key-123');
req.flush(null, { status: 204, statusText: 'No Content' });Failure paths are part of the contract
Most real outages show up here, so give each failure type its own test.
An expected 4xx. Pass a status to flush:
service.find('A/7').subscribe((r) => (found = r));
httpTesting.expectOne('/api/orders/A%2F7')
.flush({ message: 'not found' }, { status: 404, statusText: 'Not Found' });
expect(found).toBeNull();The URL assertion doubles as an encoding test. Without encodeURIComponent, an ID containing a slash would hit /api/orders/A/7, a different route entirely.
A server error. flush(null, { status: 500, statusText: 'Server Error' }) should make the observable error with OrderLoadError, and the message reads Orders failed to load (HTTP 500).
A network failure. This is the case people forget. When the connection drops, a CORS preflight fails or an ad blocker eats the request, there’s no HTTP response at all. Simulate it with error():
httpTesting.expectOne((r) => r.url === '/api/orders').error(new ProgressEvent('error'));The resulting HttpErrorResponse has status 0, so the service throws Orders failed to load (HTTP 0). If your UI shows “Server error, try later” for status 0, that’s misleading: the server may be fine and the user offline. A test that pins status 0 to its own message forces someone to decide what the user should see.
verify() catches the requests you didn’t expect
End each test (or an afterEach) with httpTesting.verify(). It fails if any request is still open:
Expected no open requests, found 1: GET /api/orders/A-2That catches real bugs: a component that calls search() twice on init, a retry operator firing an extra request, an interceptor making a token refresh call you didn’t account for. Without verify(), those requests just sit unanswered and the test passes.
Interview tip
No
fakeAsyncorsetTimeoutis needed for any of this.flush()delivers the response synchronously, so assertions on the next line already see the mapped result. If you find yourself adding arbitrary waits to an HTTP test, something else (adebounceTime, adelay) is in the pipeline, and that’s what should be controlled.
Keeping fixtures honest
Inline response objects drift. Six months later, the DTO interface has a new required currency field and every flush({...}) in the suite still compiles, because flush accepts any body. Move response bodies into typed constants instead: const openOrderDto: OrderDto = { id: 'A-1001', total_cents: 4999, placed_at: '2026-10-01T09:30:00Z' }. Now a change to OrderDto breaks the fixtures at compile time, and you’re forced to decide how the mapping should treat the new field. Keep the fixtures small and specific to each test’s question; a 300-line JSON dump copied from a real response hides which field the test actually cares about.
What these tests don’t prove
They prove your frontend sends what you think the API wants. They don’t prove the API agrees. If the backend renames total_cents to totalCents, every test here keeps passing. Closing that gap is a different tool: types generated from an OpenAPI spec, a consumer-driven contract tool such as Pact, or a small end-to-end smoke test against a real environment. Saying this unprompted in an interview shows you know where unit tests stop.
Practice question: the double request
“Your component calls ordersService.search('open') from ngOnInit, and its template also uses orders$ | async on the same observable. The page works. How would a contract test reveal a problem, and what’s the problem?”
Answer. The observable is cold, so the manual subscribe() in ngOnInit and the async pipe each trigger their own GET. In a component test with the testing backend, httpTesting.expectOne((r) => r.url === '/api/orders') would fail with “found 2 requests”, and even with match() the duplicate would be obvious. In production it shows up as double traffic and, with different timing, as flicker when the two responses arrive. The fix is to subscribe once: drop the manual subscription and let the template own it, or convert the observable with toSignal() and read the signal in the template. Then the test asserts exactly one request, flushes it, and calls verify() to prove no second request is hiding.