We use cookies to enhance your experience on the site
CodeWorlds

Testing Services - Sparring Before Battle

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.

The Ritual of a Service Test

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.

Testing HTTP Against a Mock

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.

Testing an Interceptor

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.

Go to CodeWorlds