Testing
Services built on ApiService are tested exactly like services built on
HttpClient — with provideHttpClientTesting() and HttpTestingController.
The one addition is provideApi(), so URLs are built the way they are in the
application.
Setting up TestBed
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideApi } from '@ismailza/ngx-api-client';
import { OrderService } from './order.service';
describe('OrderService', () => {
let service: OrderService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideApi({ baseUrl: 'https://api.test' }),
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(OrderService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
});
The setup above registers no interceptors, which keeps a unit test focused on
the service. Add them with withInterceptors([...]) only when the behaviour
under test is retry, error normalisation or success messages.
Asserting the URL
The URL is part of the contract, so assert the whole thing — prefix, version and query included:
it('requests the paginated orders endpoint', () => {
service.list(0, 20).subscribe();
const req = httpMock.expectOne('https://api.test/api/v1/orders?page=0&size=20');
expect(req.request.method).toBe('GET');
req.flush({ content: [], page: { number: 0, size: 20, totalElements: 0, totalPages: 0 } });
});
For header- or media-type versioning, assert the header instead:
expect(req.request.headers.get('X-API-Version')).toBe('1');
Testing error normalisation
Register apiErrorInterceptor and flush a problem+json body:
TestBed.configureTestingModule({
providers: [
provideApi({ baseUrl: 'https://api.test' }),
provideHttpClient(withInterceptors([apiErrorInterceptor])),
provideHttpClientTesting(),
],
});
it('normalises a validation failure', async () => {
const thrown = new Promise<ApiError>((resolve) => {
service.create(draft).subscribe({ error: resolve });
});
httpMock.expectOne('https://api.test/api/v1/orders').flush(
{
type: 'about:blank',
title: 'Bad Request',
status: 400,
detail: 'Reference must not be blank.',
code: 'VALIDATION_ERROR',
errors: [{ field: 'reference', message: 'must not be blank' }],
},
{ status: 400, statusText: 'Bad Request' },
);
const error = await thrown;
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.errors).toHaveLength(1);
});
A network failure is produced with req.error(new ProgressEvent('error')), and
normalises to status: 0 with code: 'NETWORK_ERROR'.
Testing your handlers
ApiErrorHandler is an abstract class token, so a recording double slots
straight in:
@Injectable()
class RecordingErrorHandler extends ApiErrorHandler {
readonly handled: ApiError[] = [];
override handle(error: ApiError): void {
this.handled.push(error);
}
}
providers: [
// ...
{ provide: ApiErrorHandler, useClass: RecordingErrorHandler },
];
Then assert that a failure reached it — and that skipErrorHandler: true
doesn't:
const handler = TestBed.inject(ApiErrorHandler) as RecordingErrorHandler;
expect(handler.handled).toHaveLength(1);
Testing retry
Retry uses real timers for its backoff, so drive it with fakeAsync and tick:
it('retries a 503 and succeeds on the second attempt', fakeAsync(() => {
const result = vi.fn();
service.get('1').subscribe(result);
httpMock.expectOne(URL).flush(null, { status: 503, statusText: 'Unavailable' });
tick(2000); // initial delay plus jitter headroom
httpMock.expectOne(URL).flush({ id: '1' });
expect(result).toHaveBeenCalled();
}));
Jitter adds up to 30% to each delay, so tick() past the maximum rather than
the nominal value. When the test isn't about retry, either leave
retryInterceptor out of the TestBed or configure provideApi({ retry: false }).
Testing the loading signal
it('clears the loading state when the request fails', () => {
const loading = TestBed.inject(ApiLoadingService);
service.get('1').subscribe({ error: () => undefined });
expect(loading.loading()).toBe(true);
httpMock.expectOne(URL).flush(null, { status: 500, statusText: 'Error' });
expect(loading.loading()).toBe(false);
});
Faking ApiService in component tests
A component test rarely wants HTTP at all. Provide a stub instead:
TestBed.configureTestingModule({
imports: [OrderListComponent],
providers: [
{
provide: OrderService,
useValue: { list: () => of({ content: [order], page: pageMeta }) },
},
],
});
Keep provideApi() out of component tests entirely — if a component reaches
ApiService directly, that's usually a sign the call belongs in a service.