Testing Angular services that make HTTP calls uses HttpClientTestingModule — it replaces the real HttpClient with a test version that doesn’t make real network requests. The HttpTestingController intercepts requests, lets you inspect them (URL, method, body, headers), and flush mock responses. This is Angular’s equivalent of Moq’s MockHttpMessageHandler — the standard way to test services that depend on HttpClient.
Testing Services with HttpClientTestingModule
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule,
HttpTestingController } from '@angular/common/http/testing';
import { PostsApiService } from './posts-api.service';
describe('PostsApiService', () => {
let service: PostsApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
PostsApiService,
{ provide: APP_CONFIG, useValue: { apiUrl: 'http://test-api' } },
],
});
service = TestBed.inject(PostsApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Verify no unexpected requests were made
httpMock.verify();
});
it('should GET /api/posts with pagination params', () => {
const mockResponse: PagedResult<PostSummaryDto> = {
items: [{ id: 1, title: 'Test Post', slug: 'test-post', viewCount: 0 }],
total: 1, page: 1, pageSize: 10,
hasNextPage: false, hasPrevPage: false,
};
let result: PagedResult<PostSummaryDto> | undefined;
service.getPublished(1, 10).subscribe(r => result = r);
// Expect exactly one request to this URL
const req = httpMock.expectOne(r =>
r.url === 'http://test-api/api/posts' &&
r.params.get('page') === '1' &&
r.params.get('size') === '10');
expect(req.request.method).toBe('GET');
// Flush the mock response
req.flush(mockResponse);
// Now the Observable has emitted
expect(result).toBeDefined();
expect(result!.items).toHaveSize(1);
expect(result!.items[0].title).toBe('Test Post');
});
it('should handle 404 by rethrowing ApiError', () => {
let error: ApiError | undefined;
service.getBySlug('non-existent').subscribe({
error: (e: ApiError) => error = e,
});
const req = httpMock.expectOne('http://test-api/api/posts/non-existent');
req.flush(
{ title: 'Not Found', detail: 'Post not found.' },
{ status: 404, statusText: 'Not Found' }
);
expect(error).toBeDefined();
expect(error!.status).toBe(404);
});
it('should POST with Authorization header when authenticated', () => {
const authService = TestBed.inject(AuthService);
spyOn(authService, 'accessToken').and.returnValue('test-jwt-token');
service.create({ title: 'New Post', body: 'Content', status: 'draft' }).subscribe();
const req = httpMock.expectOne('http://test-api/api/posts');
expect(req.request.method).toBe('POST');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-jwt-token');
req.flush({ id: 1, slug: 'new-post' });
});
});
// ── Testing AuthService signals ───────────────────────────────────────────
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService,
{ provide: APP_CONFIG, useValue: { apiUrl: '' } }],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should update signals after successful login', () => {
expect(service.isLoggedIn()).toBeFalse();
service.login('alice@test.com', 'password').subscribe();
const req = httpMock.expectOne('/api/auth/login');
req.flush({
accessToken: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImRpc3BsYXlOYW1lIjoiQWxpY2UiLCJlbWFpbCI6ImFsaWNlQHRlc3QuY29tIiwicm9sZXMiOlsiQXV0aG9yIl0sImV4cCI6OTk5OTk5OTk5OX0.ignored',
expiresIn: 900,
displayName: 'Alice',
roles: ['Author'],
});
expect(service.isLoggedIn()).toBeTrue();
expect(service.displayName()).toBe('Alice');
});
});
httpMock.verify() in afterEach() is essential — it fails the test if any HTTP requests were made that weren’t explicitly expected and flushed, or if expected requests were not made. Without verify(), unexpected requests silently go unanswered, potentially causing downstream test failures that are hard to diagnose. Always include afterEach(() => httpMock.verify()) in every test class that uses HttpClientTestingModule.httpMock.expectOne(req => req.url.includes('/api/posts') && req.params.get('page') === '1') with a predicate function when you need to check multiple aspects of the request URL and parameters simultaneously. The string overload (expectOne('/api/posts')) only checks the URL, ignoring query parameters. For endpoints with query parameters, the predicate form gives you full control over what constitutes a “match.”decodeJwt() can parse it. A random string like 'fake-token' will cause the decode to throw, leaving the service in an unexpected state. Use a real (but unsigned or test-signed) JWT with the correct claim structure, or mock the decodeJwt method directly. Online tools like jwt.io can generate test tokens with specific payloads.Common Mistakes
Mistake 1 — Forgetting httpMock.verify() (unexpected requests not caught)
❌ Wrong — no afterEach verify; service makes unexpected extra request; test passes with silent extra HTTP call.
✅ Correct — always afterEach(() => httpMock.verify()); fails if any request was made but not expected.
Mistake 2 — Not flushing the request before asserting on results (Observable never emits)
❌ Wrong — service.getPublished().subscribe(r => result = r); expect(result).toBeDefined() — flush never called; result stays undefined.
✅ Correct — subscribe → expectOne → flush → assert: the flush triggers the Observable emission before the assertion runs.