Testing Angular Services — HttpClient, Signals and RxJS Observables

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');
  });
});
Note: 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.
Tip: Use 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.”
Warning: The test JWT token used in the AuthService test must be a real base64-encoded JWT payload so the service’s 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.

🧠 Test Yourself

httpMock.verify() is called after a test. An HTTP request was intercepted and flushed, but a second request was made and not flushed. What happens?