A true samurai never steps onto the battlefield without sparring first. A test is that sparring session for your code: you prove the technique works before the enemy - your user - gets a chance to break it. In this lesson you will learn to test services, and you will see that every test follows the same three-beat ritual: arrange -> act -> assert.
Let us start with the simplest service imaginable. Read the code and look for the three beats that will come back in every test you ever write.
1import { TestBed } from '@angular/core/testing';
2import { SamuraiService } from './samurai.service';
3
4describe('SamuraiService', () => {
5 let service: SamuraiService;
6
7 beforeEach(() => {
8 TestBed.configureTestingModule({ providers: [SamuraiService] });
9 service = TestBed.inject(SamuraiService);
10 });
11
12 it('should calculate power level correctly', () => {
13 const result = service.calculatePower(10, 5);
14 expect(result).toBe(50);
15 });
16});There are the three beats. Arrange (
beforeEach): Angular builds the service inside a testing environment and hands it to you through TestBed.inject, so every test gets a fresh, untouched instance. Act: you call the method (calculatePower). Assert (expect): you compare the result against what you expected. Many suites open with one extra warm-up test, expect(service).toBeTruthy(), which proves nothing more than that the service could be created at all. Memorise this shape - the rest of the lesson is the same ritual under harder conditions.A service that talks to a server brings a problem: in a test you do not want to fire real requests, because they would be slow and would depend on the network. The answer is a mock.
HttpTestingController intercepts the request and lets you supply the response yourself. That is its entire purpose - mocking HTTP responses and verifying the requests your service made. It never sends real traffic, it does not control network speed, and it is not a logger.1import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
2import { provideHttpClient } from '@angular/common/http';
3
4describe('SamuraiApiService', () => {
5 let service: SamuraiApiService;
6 let httpMock: HttpTestingController;
7
8 beforeEach(() => {
9 TestBed.configureTestingModule({
10 providers: [SamuraiApiService, provideHttpClient(), provideHttpClientTesting()]
11 });
12 service = TestBed.inject(SamuraiApiService);
13 httpMock = TestBed.inject(HttpTestingController);
14 });
15
16 afterEach(() => httpMock.verify()); // no request was left unanswered
17
18 it('should fetch samurai list', () => {
19 const mockSamurai = [{ id: 1, name: 'Musashi', clan: 'Ronin' }];
20
21 service.getAll().subscribe(samurai => {
22 expect(samurai[0].name).toBe('Musashi');
23 });
24
25 const req = httpMock.expectOne('/api/samurai'); // intercept the request
26 expect(req.request.method).toBe('GET');
27 req.flush(mockSamurai); // hand over the fake response
28 });
29});Two providers make this work, and they come from two different places:
provideHttpClient() lives in @angular/common/http, while the testing one and the controller itself come from the testing entry point, on a line that reads import { provideHttpClientTesting } from '@angular/common/http/testing'; - keep that path in mind, it is easy to import from the wrong one.Now follow the new dance, because the order matters and it is not the order you would guess. First you place the order:
service.getAll().subscribe(...) registers what should happen once data arrives. Then httpMock.expectOne('/api/samurai') intercepts that pending request and hands you a TestRequest object. Then you inspect it - and mind the shape: a TestRequest is only a wrapper, and the real outgoing request hangs off req.request, so you assert with expect(req.request.method).toBe('GET'). The wrapper itself has no method and no type of its own, and the controller has no method either, so req.method, req.type and httpMock.method are all dead ends. Finally req.flush(mockSamurai) simulates the server response and pushes mockSamurai down to the subscriber - that single call, and nothing else, is what runs the code inside subscribe. It does not cancel the intercepted request, it does not clear any cache, and it never reaches a real server. And afterEach(httpMock.verify) stands guard over all of it: the test fails if any request was left without an answer. That is your protection against silent bugs.The same mock also covers sending data and handling failures. Notice how you test the case where the server answers with a 404:
1it('should send POST request to create samurai', () => {
2 const newSamurai = { name: 'Hanzo', clan: 'Iga' };
3
4 service.create(newSamurai).subscribe(result => expect(result.id).toBe(3));
5
6 const req = httpMock.expectOne('/api/samurai');
7 expect(req.request.method).toBe('POST');
8 expect(req.request.body).toEqual(newSamurai); // check what was sent
9 req.flush({ id: 3, ...newSamurai });
10});
11
12it('should handle HTTP errors', () => {
13 service.getById(999).subscribe({
14 next: () => fail('Should have failed'),
15 error: (error) => expect(error.status).toBe(404)
16 });
17
18 const req = httpMock.expectOne('/api/samurai/999');
19 req.flush('Not found', { status: 404, statusText: 'Not Found' }); // fake a failure
20});With a POST you can also assert
req.request.body - that is, exactly what the service sent, not merely where it sent it. Note the doubled word once more: both the method and the body hang off req.request. The failure path is tested by telling the mock to answer with status 404 through the second argument of flush: the error callback should then fire and next should not, which is why next calls fail - the test collapses if the error never arrives. That is the "prove the bad path really is bad" pattern.One more variant is worth knowing, because a URL alone is not always enough to identify a request. When the service builds query parameters,
expectOne also accepts a predicate function, so you can match on anything the request carries:1it('should pass query parameters', () => {
2 service.search('Musashi', 'Ronin').subscribe();
3
4 const req = httpMock.expectOne(
5 req => req.url === '/api/samurai/search' &&
6 req.params.get('q') === 'Musashi' &&
7 req.params.get('clan') === 'Ronin'
8 );
9 expect(req.request.method).toBe('GET');
10
11 req.flush([]);
12});The predicate is handed every pending request and must return true for exactly one of them, otherwise the test fails immediately. Here it checks the bare
url and the parsed params, so a typo in a parameter name is caught by your test rather than by a user. The closing beat never changes either: even when the payload is irrelevant, you still call req.flush([]), because otherwise httpMock.verify() in afterEach would report a request left hanging.An interceptor adds something to every outgoing request - a token, for example. You test it exactly the same way, against the mock, except that the service it depends on is replaced by a spy, so that you control the answers it gives.
1describe('AuthInterceptor', () => {
2 let httpClient: HttpClient;
3 let httpMock: HttpTestingController;
4 let authService: jasmine.SpyObj<AuthService>;
5
6 beforeEach(() => {
7 const authSpy = jasmine.createSpyObj('AuthService', ['getToken']);
8 TestBed.configureTestingModule({
9 providers: [
10 provideHttpClient(withInterceptors([authInterceptor])),
11 provideHttpClientTesting(),
12 { provide: AuthService, useValue: authSpy }
13 ]
14 });
15 httpClient = TestBed.inject(HttpClient);
16 httpMock = TestBed.inject(HttpTestingController);
17 authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
18 });
19
20 it('should add Authorization header when token exists', () => {
21 authService.getToken.and.returnValue('test-token'); // the spy fakes a token
22
23 httpClient.get('/api/samurai').subscribe();
24
25 const req = httpMock.expectOne('/api/samurai');
26 expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
27 req.flush([]);
28 });
29});jasmine.createSpyObj creates a spy - a stand-in for the real service that returns precisely what the test needs (getToken.and.returnValue('test-token')). Thanks to that you no longer depend on real login logic: you set the conditions and then verify one single fact, namely that the interceptor attached the Authorization header. And the header, like everything else about the outgoing call, is read from req.request.One test is only half the contract, though. The mirror-image test proves the other half - no token, no header:
1it('should not add header when no token', () => {
2 authService.getToken.and.returnValue(null);
3
4 httpClient.get('/api/samurai').subscribe();
5
6 const req = httpMock.expectOne('/api/samurai');
7 expect(req.request.headers.has('Authorization')).toBeFalse();
8 req.flush([]);
9});The only thing that changed is what the spy returns, and the assertion flips from
headers.get(...) to headers.has(...) - because now you are proving an absence. Two tests, two conditions, and the behaviour of the interceptor is fully described.Take one ritual away from this lesson: arrange -> act -> assert. HTTP tests simply add a mock to it (
expectOne plus flush), so that your sparring happens in the training hall and never on a real server.