Angular 18 HttpClient Testing with provideHttpClientTesting
Angular 15 introduced provideHttpClient() as a functional alternative to HttpClientModule. Angular 18 completed the migration story with provideHttpClientTesting() — the functional equivalent of HttpClientTestingModule. This guide covers how to test HTTP requests, intercept calls, simulate errors, and mock responses in standalone Angular apps.
Key Takeaways
Replace HttpClientTestingModule with provideHttpClientTesting(). In standalone apps, use providers: [provideHttpClient(), provideHttpClientTesting()] instead of imports: [HttpClientTestingModule].
HttpTestingController is the same. Inject it with TestBed.inject(HttpTestingController). All the same APIs: expectOne(), expectNone(), match(), verify().
Always call controller.verify() in afterEach. This fails the test if there are unexpected pending requests — a critical safety net.
expectOne() returns a TestRequest. Call req.flush(data) to respond with data, req.error(error) to simulate a network error, or req.flush(null, { status: 404, statusText: 'Not Found' }) for HTTP error status codes.
Interceptors still work. Add them with withInterceptors([myInterceptorFn]) in provideHttpClient() — they're tested alongside your service.
The New Functional API
Angular 15–18 progressively replaced module-based HTTP configuration with functional providers:
// OLD (still works, but deprecated for standalone apps)
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
// NEW (Angular 15+, required for standalone)
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
});Both set up HttpTestingController in the same way — the difference is syntax, not behavior.
Setup
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';Testing a Service with HttpClient
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly baseUrl = '/api/users';
constructor(private http: HttpClient) {}
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.baseUrl, user);
}
updateUser(id: number, changes: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/${id}`, changes);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';
describe('UserService', () => {
let service: UserService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
UserService
]
});
service = TestBed.inject(UserService);
controller = TestBed.inject(HttpTestingController);
});
afterEach(() => {
controller.verify(); // fail if any unexpected requests remain
});
it('GET /api/users/:id returns user', () => {
const mockUser: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
service.getUser(1).subscribe(user => {
expect(user).toEqual(mockUser);
});
const req = controller.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser); // respond with mock data
});
it('POST /api/users creates user and returns it', () => {
const newUser = { name: 'Bob', email: 'bob@example.com' };
const created: User = { id: 2, ...newUser };
service.createUser(newUser).subscribe(user => {
expect(user.id).toBe(2);
expect(user.name).toBe('Bob');
});
const req = controller.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(created);
});
it('PATCH /api/users/:id sends correct body', () => {
service.updateUser(1, { name: 'Alice Smith' }).subscribe();
const req = controller.expectOne('/api/users/1');
expect(req.request.method).toBe('PATCH');
expect(req.request.body).toEqual({ name: 'Alice Smith' });
req.flush({ id: 1, name: 'Alice Smith', email: 'alice@example.com' });
});
it('DELETE /api/users/:id sends no body', () => {
service.deleteUser(1).subscribe();
const req = controller.expectOne('/api/users/1');
expect(req.request.method).toBe('DELETE');
req.flush(null);
});
});Simulating HTTP Errors
describe('UserService error handling', () => {
let service: UserService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), UserService]
});
service = TestBed.inject(UserService);
controller = TestBed.inject(HttpTestingController);
});
afterEach(() => controller.verify());
it('handles 404 Not Found', () => {
service.getUser(999).subscribe({
next: () => fail('Should have errored'),
error: err => {
expect(err.status).toBe(404);
expect(err.statusText).toBe('Not Found');
}
});
const req = controller.expectOne('/api/users/999');
req.flush('User not found', { status: 404, statusText: 'Not Found' });
});
it('handles 500 Server Error', () => {
service.getUser(1).subscribe({
next: () => fail('Should have errored'),
error: err => {
expect(err.status).toBe(500);
}
});
const req = controller.expectOne('/api/users/1');
req.flush('Internal server error', { status: 500, statusText: 'Internal Server Error' });
});
it('handles network error', () => {
service.getUser(1).subscribe({
next: () => fail('Should have errored'),
error: err => {
expect(err.error instanceof ProgressEvent).toBeTrue();
}
});
const req = controller.expectOne('/api/users/1');
req.error(new ProgressEvent('error')); // simulate network failure
});
});Testing Request Headers and Params
// auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
getProfile(token: string): Observable<any> {
return this.http.get('/api/profile', {
headers: { Authorization: `Bearer ${token}` }
});
}
searchUsers(query: string, page: number): Observable<any> {
return this.http.get('/api/users', {
params: { q: query, page: page.toString() }
});
}
}it('sends Authorization header', () => {
service.getProfile('my-token').subscribe();
const req = controller.expectOne('/api/profile');
expect(req.request.headers.get('Authorization')).toBe('Bearer my-token');
req.flush({});
});
it('sends correct query params', () => {
service.searchUsers('alice', 2).subscribe();
const req = controller.expectOne(r =>
r.url === '/api/users' &&
r.params.get('q') === 'alice' &&
r.params.get('page') === '2'
);
req.flush([]);
});Testing Interceptors
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('token');
if (token) {
return next(req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
}));
}
return next(req);
};import { provideHttpClient, withInterceptors } from '@angular/common/http';
describe('authInterceptor', () => {
let controller: HttpTestingController;
let http: HttpClient;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting()
]
});
controller = TestBed.inject(HttpTestingController);
http = TestBed.inject(HttpClient);
});
afterEach(() => controller.verify());
it('adds Authorization header when token exists', () => {
localStorage.setItem('token', 'test-jwt');
http.get('/api/data').subscribe();
const req = controller.expectOne('/api/data');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-jwt');
req.flush({});
localStorage.removeItem('token');
});
it('does not add header when no token', () => {
localStorage.removeItem('token');
http.get('/api/data').subscribe();
const req = controller.expectOne('/api/data');
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
});Testing Multiple Concurrent Requests
// dashboard.service.ts
@Injectable({ providedIn: 'root' })
export class DashboardService {
getAll() {
return forkJoin({
users: this.http.get<User[]>('/api/users'),
stats: this.http.get<any>('/api/stats'),
notifications: this.http.get<any[]>('/api/notifications')
});
}
}it('makes all three requests in parallel', () => {
service.getAll().subscribe(result => {
expect(result.users).toEqual([]);
expect(result.stats).toEqual({ total: 0 });
expect(result.notifications).toEqual([]);
});
// controller.match() handles multiple requests to the same URL
const requests = controller.match(req =>
['/api/users', '/api/stats', '/api/notifications'].includes(req.url)
);
expect(requests.length).toBe(3);
requests.find(r => r.request.url === '/api/users')!.flush([]);
requests.find(r => r.request.url === '/api/stats')!.flush({ total: 0 });
requests.find(r => r.request.url === '/api/notifications')!.flush([]);
});Testing Component HTTP Calls
When testing a component that uses a service with HTTP:
describe('UsersListComponent', () => {
let fixture: ComponentFixture<UsersListComponent>;
let controller: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UsersListComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
}).compileComponents();
fixture = TestBed.createComponent(UsersListComponent);
controller = TestBed.inject(HttpTestingController);
fixture.detectChanges(); // triggers ngOnInit → HTTP call
});
afterEach(() => controller.verify());
it('displays users after successful load', () => {
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
const req = controller.expectOne('/api/users');
req.flush(mockUsers);
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('[data-testid="user-item"]');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('Alice');
});
it('shows error message on failed load', () => {
const req = controller.expectOne('/api/users');
req.flush('Server error', { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="error"]')).toBeTruthy();
});
});Migrating from HttpClientTestingModule
| Before | After |
|---|---|
imports: [HttpClientTestingModule] |
providers: [provideHttpClient(), provideHttpClientTesting()] |
imports: [HttpClientModule] |
providers: [provideHttpClient()] |
Interceptors via HTTP_INTERCEPTORS token |
provideHttpClient(withInterceptors([fn])) |
| Class-based interceptors | Functional interceptors (HttpInterceptorFn) |
HttpTestingController itself is unchanged — expectOne(), match(), flush(), error(), verify() all work exactly as before.
HelpMeTest for HTTP Flows
Unit tests with HttpTestingController verify that your service sends correct requests. But they can't catch:
- Auth token expiry and refresh flows
- Rate limiting behavior
- Real network errors in production
- API contract mismatches with your actual backend
HelpMeTest's browser tests hit your real API (or staging), catching these gaps:
When the user logs in with valid credentials
Then the dashboard loads within 2 seconds
And the user list shows at least one result
And the API calls include the Authorization headerSummary
Angular 18 HttpClient testing with provideHttpClientTesting():
- Replace
HttpClientTestingModulewithproviders: [provideHttpClient(), provideHttpClientTesting()] - Inject
HttpTestingControllerto intercept and assert on requests - Use
controller.expectOne(url)for single requests,controller.match()for multiple - Respond with
req.flush(data)orreq.error(error)orreq.flush(data, {status: 404}) - Always call
controller.verify()inafterEach - Test interceptors by including them in
provideHttpClient(withInterceptors([...]))