Testing Angular Services: HttpClientTestingModule, Mocking Dependencies, RxJS Marble Testing

Testing Angular Services: HttpClientTestingModule, Mocking Dependencies, RxJS Marble Testing

Angular services are the workhorses of Angular applications — they handle HTTP calls, state management, caching, and business logic. Testing services involves two main concerns: intercepting HTTP requests with HttpClientTestingModule, and isolating service dependencies with spy objects. For complex observable chains, RxJS marble testing gives you frame-level control over timing. This guide covers all three.

Key Takeaways

HttpTestingController.expectOne() is your assertion AND your response setter. It both verifies a request was made with the right URL/method AND lets you flush a mock response. Call httpMock.verify() in afterEach to catch unexpected requests.

jasmine.createSpyObj() is the cleanest way to create service mocks. Pass method names as strings; get a typed spy object back. Every method becomes a spy with .and.returnValue() available.

TestBed.inject() returns the same instance as the DI container. When you inject UserService in beforeEach, you get the exact instance used by whatever component or service you're testing — mutations to the spy affect the tested code.

RxJS marble testing uses virtual time. TestScheduler doesn't use real timers. tick(1000) in a marble means 1000 virtual frames, not 1 second. Use scheduler.run() to control virtual time.

throwError(() => new Error()) is the correct RxJS v7 syntax. The older throwError(new Error()) is deprecated. Always use the factory function form to avoid unexpected behavior with eager error creation.

Testing Services with HTTP

Most Angular services use HttpClient to call APIs. The HttpClientTestingModule provides a mock backend that lets you control responses:

// src/app/services/product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  constructor(private http: HttpClient) {}

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products');
  }

  getProduct(id: string): Observable<Product> {
    return this.http.get<Product>(`/api/products/${id}`);
  }

  createProduct(data: Partial<Product>): Observable<Product> {
    return this.http.post<Product>('/api/products', data);
  }

  deleteProduct(id: string): Observable<void> {
    return this.http.delete<void>(`/api/products/${id}`);
  }
}
// src/app/services/product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ProductService],
    });

    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // ensures no unexpected requests were made
  });

  describe('getProducts()', () => {
    it('returns a list of products', () => {
      const mockProducts: Product[] = [
        { id: '1', name: 'Widget', price: 9.99 },
        { id: '2', name: 'Gadget', price: 24.99 },
      ];

      service.getProducts().subscribe(products => {
        expect(products).toEqual(mockProducts);
      });

      const req = httpMock.expectOne('/api/products');
      expect(req.request.method).toBe('GET');
      req.flush(mockProducts);
    });

    it('handles HTTP 500 error', () => {
      service.getProducts().subscribe({
        next: () => fail('Should have thrown'),
        error: (error) => {
          expect(error.status).toBe(500);
        },
      });

      const req = httpMock.expectOne('/api/products');
      req.flush('Internal Server Error', {
        status: 500,
        statusText: 'Internal Server Error',
      });
    });
  });

  describe('createProduct()', () => {
    it('sends POST with the product data', () => {
      const newProduct = { name: 'New Widget', price: 12.99 };
      const createdProduct = { id: '3', ...newProduct };

      service.createProduct(newProduct).subscribe(product => {
        expect(product).toEqual(createdProduct);
      });

      const req = httpMock.expectOne('/api/products');
      expect(req.request.method).toBe('POST');
      expect(req.request.body).toEqual(newProduct);
      req.flush(createdProduct);
    });
  });

  describe('deleteProduct()', () => {
    it('sends DELETE to the correct URL', () => {
      service.deleteProduct('1').subscribe();

      const req = httpMock.expectOne('/api/products/1');
      expect(req.request.method).toBe('DELETE');
      req.flush(null);
    });
  });
});

Testing HTTP Headers and Parameters

it('sends authorization header', () => {
  service.getSecuredProducts().subscribe();

  const req = httpMock.expectOne('/api/products');
  expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
  req.flush([]);
});

it('includes query parameters', () => {
  service.searchProducts({ query: 'widget', page: 2 }).subscribe();

  const req = httpMock.expectOne(r => r.url.includes('/api/products') && r.params.get('page') === '2');
  expect(req.request.params.get('query')).toBe('widget');
  req.flush([]);
});

Testing Service Dependencies

Services often depend on other services. Use Jasmine spies to mock them:

// src/app/services/notification.service.ts
@Injectable({ providedIn: 'root' })
export class NotificationService {
  constructor(
    private userService: UserService,
    private emailService: EmailService,
    private logService: LogService,
  ) {}

  async notifyUser(userId: string, message: string): Promise<void> {
    const user = await firstValueFrom(this.userService.getUser(userId));
    await firstValueFrom(this.emailService.send({ to: user.email, body: message }));
    this.logService.info(`Notification sent to ${userId}`);
  }
}
describe('NotificationService', () => {
  let service: NotificationService;
  let userServiceSpy: jasmine.SpyObj<UserService>;
  let emailServiceSpy: jasmine.SpyObj<EmailService>;
  let logServiceSpy: jasmine.SpyObj<LogService>;

  beforeEach(() => {
    userServiceSpy = jasmine.createSpyObj('UserService', ['getUser']);
    emailServiceSpy = jasmine.createSpyObj('EmailService', ['send']);
    logServiceSpy = jasmine.createSpyObj('LogService', ['info', 'error']);

    TestBed.configureTestingModule({
      providers: [
        NotificationService,
        { provide: UserService, useValue: userServiceSpy },
        { provide: EmailService, useValue: emailServiceSpy },
        { provide: LogService, useValue: logServiceSpy },
      ],
    });

    service = TestBed.inject(NotificationService);
  });

  it('sends email to the correct user', async () => {
    userServiceSpy.getUser.and.returnValue(
      of({ id: 'user-1', email: 'alice@example.com', name: 'Alice' })
    );
    emailServiceSpy.send.and.returnValue(of({ messageId: 'msg-123' }));

    await service.notifyUser('user-1', 'Hello!');

    expect(emailServiceSpy.send).toHaveBeenCalledWith({
      to: 'alice@example.com',
      body: 'Hello!',
    });
    expect(logServiceSpy.info).toHaveBeenCalledWith('Notification sent to user-1');
  });

  it('does not log on email failure', async () => {
    userServiceSpy.getUser.and.returnValue(
      of({ id: 'user-1', email: 'alice@example.com', name: 'Alice' })
    );
    emailServiceSpy.send.and.returnValue(throwError(() => new Error('SMTP error')));

    await expectAsync(service.notifyUser('user-1', 'Hello!')).toBeRejected();
    
    expect(logServiceSpy.info).not.toHaveBeenCalled();
  });
});

RxJS Marble Testing

Marble testing uses ASCII diagrams to represent observable timing:

'-'  = 10ms virtual frame (one frame)
'a'  = emit value 'a'
'|'  = complete
'#'  = error
'(ab|)' = emit a, b, complete synchronously
import { TestScheduler } from 'rxjs/testing';
import { debounceTime, map, retry, delay } from 'rxjs/operators';

describe('SearchService', () => {
  let scheduler: TestScheduler;

  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('debounces search by 300 virtual frames', () => {
    scheduler.run(({ hot, expectObservable }) => {
      const keystrokes$ = hot('a-b-c------d|', {
        a: 'a', b: 'ab', c: 'abc', d: 'abcd',
      });
      
      const result$ = keystrokes$.pipe(debounceTime(300, scheduler));
      
      // After 300 frames of silence, 'c' emits; then 'abcd' after final silence
      expectObservable(result$).toBe('--------c--300ms d|', {
        c: 'abc', d: 'abcd',
      });
    });
  });

  it('retries once on error', () => {
    scheduler.run(({ cold, expectObservable }) => {
      // First subscription errors, second succeeds
      let attempts = 0;
      const source$ = cold('--#').pipe(
        map(() => 'data'),
        retry(1)
      );

      expectObservable(source$).toBe('----#');
    });
  });

  it('maps values correctly', () => {
    scheduler.run(({ cold, expectObservable }) => {
      const source$ = cold('--a--b--c|', { a: 1, b: 2, c: 3 });
      const result$ = source$.pipe(map(x => x * 2));
      
      expectObservable(result$).toBe('--a--b--c|', { a: 2, b: 4, c: 6 });
    });
  });
});

Error Handling Tests

it('retries HTTP request on 503 Service Unavailable', () => {
  let callCount = 0;

  service.getProducts().subscribe({
    next: (products) => expect(products).toEqual(mockProducts),
    error: () => fail('Should not error'),
  });

  // First call fails
  const req1 = httpMock.expectOne('/api/products');
  req1.flush('Service unavailable', { status: 503, statusText: 'Service Unavailable' });

  // Second call (after retry) succeeds
  const req2 = httpMock.expectOne('/api/products');
  req2.flush(mockProducts);
});

Running Service Tests

ng test --include=<span class="hljs-string">"**/*.service.spec.ts"  <span class="hljs-comment"># service tests only
ng <span class="hljs-built_in">test --no-watch                         <span class="hljs-comment"># single run
ng <span class="hljs-built_in">test --code-coverage                    <span class="hljs-comment"># with coverage

Production Service Monitoring

Service tests verify API call logic with mocked HTTP responses. They don't verify that the real API returns the expected data, handles authentication correctly, or responds within acceptable time.

HelpMeTest tests your live Angular application against real APIs continuously. The free tier includes 10 automated tests — no infrastructure setup required.

Read more