Angular course ยท Module 11: Testing
Testing Services - Sparring Before Battle
In this lesson3
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.
import { TestBed } from '@angular/core/testing';
import { SamuraiService } from './samurai.service';
describe('SamuraiService', () => {
let service: SamuraiService;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [SamuraiService] });
service = TestBed.inject(SamuraiService);
});
it('should calculate power level correctly', () => {
const result = service.calculatePower(10, 5);
expect(result).toBe(50);
});
});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.
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('SamuraiApiService', () => {
let service: SamuraiApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SamuraiApiService, provideHttpClient(), provideHttpClientTesting()]
});
service = TestBed.inject(SamuraiApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // no request was left unanswered
it('should fetch samurai list', () => {
const mockSamurai = [{ id: 1, name: 'Musashi', clan: 'Ronin' }];
service.getAll().subscribe(samurai => {
expect(samurai[0].name).toBe('Musashi');
});
const req = httpMock.expectOne('/api/samurai'); // intercept the request
expect(req.request.method).toBe('GET');
req.flush(mockSamurai); // hand over the fake response
});
});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:
it('should send POST request to create samurai', () => {
const newSamurai = { name: 'Hanzo', clan: 'Iga' };
service.create(newSamurai).subscribe(result => expect(result.id).toBe(3));
const req = httpMock.expectOne('/api/samurai');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newSamurai); // check what was sent
req.flush({ id: 3, ...newSamurai });
});
it('should handle HTTP errors', () => {
service.getById(999).subscribe({
next: () => fail('Should have failed'),
error: (error) => expect(error.status).toBe(404)
});
const req = httpMock.expectOne('/api/samurai/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' }); // fake a failure
});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:
it('should pass query parameters', () => {
service.search('Musashi', 'Ronin').subscribe();
const req = httpMock.expectOne(
req => req.url === '/api/samurai/search' &&
req.params.get('q') === 'Musashi' &&
req.params.get('clan') === 'Ronin'
);
expect(req.request.method).toBe('GET');
req.flush([]);
});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.
describe('AuthInterceptor', () => {
let httpClient: HttpClient;
let httpMock: HttpTestingController;
let authService: jasmine.SpyObj<AuthService>;
beforeEach(() => {
const authSpy = jasmine.createSpyObj('AuthService', ['getToken']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authSpy }
]
});
httpClient = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
});
it('should add Authorization header when token exists', () => {
authService.getToken.and.returnValue('test-token'); // the spy fakes a token
httpClient.get('/api/samurai').subscribe();
const req = httpMock.expectOne('/api/samurai');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
req.flush([]);
});
});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:
it('should not add header when no token', () => {
authService.getToken.and.returnValue(null);
httpClient.get('/api/samurai').subscribe();
const req = httpMock.expectOne('/api/samurai');
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush([]);
});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.
Code for this lesson: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule],
template: `
<div class="service-testing">
<h1>Testing Services and HTTP</h1>
<section>
<h2>Testing a service with HttpClient</h2>
<pre><code>{{ serviceTestCode }}</code></pre>
</section>
<section>
<h2>Mocking dependencies</h2>
<pre><code>{{ mockingCode }}</code></pre>
</section>
<section>
<h2>Testing components with services</h2>
<pre><code>{{ componentServiceCode }}</code></pre>
</section>
<section class="commands">
<h2>Test commands</h2>
<div class="command-list">
<div class="command">
<code>ng test</code>
<span>Run unit tests</span>
</div>
<div class="command">
<code>ng test --watch=false</code>
<span>Run tests once (CI)</span>
</div>
<div class="command">
<code>ng test --code-coverage</code>
<span>Generate coverage report</span>
</div>
<div class="command">
<code>ng e2e</code>
<span>Run E2E tests</span>
</div>
</div>
</section>
</div>
`,
styles: [`
.service-testing {
font-family: 'Segoe UI', sans-serif;
padding: 20px;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
min-height: 100vh;
color: #eee;
}
h1 {
text-align: center;
color: #4ecca3;
}
h2 {
color: #4ecca3;
border-bottom: 2px solid #4ecca3;
padding-bottom: 10px;
}
section {
background: rgba(255,255,255,0.05);
border-radius: 10px;
padding: 20px;
margin: 20px 0;
}
pre {
background: #0a1628;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
border-left: 4px solid #4ecca3;
}
code {
color: #e6e6e6;
font-family: 'Fira Code', monospace;
font-size: 0.85em;
}
.command-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.command {
display: flex;
align-items: center;
gap: 20px;
padding: 12px;
background: rgba(0,0,0,0.3);
border-radius: 5px;
}
.command code {
background: #4ecca3;
color: #0d2137;
padding: 5px 12px;
border-radius: 4px;
font-weight: bold;
min-width: 200px;
}
.command span {
color: #aaa;
}
`]
})
export class AppComponent {
serviceTestCode = `// samurai.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { SamuraiService } from './samurai.service';
describe('SamuraiService', () => {
let service: SamuraiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [SamuraiService]
});
service = TestBed.inject(SamuraiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Check for unhandled requests
});
it('should fetch samurai list', () => {
const mockSamurai = [
{ id: 1, name: 'Musashi' },
{ id: 2, name: 'Kojiro' }
];
service.getSamuraiList().subscribe(samurai => {
expect(samurai.length).toBe(2);
expect(samurai[0].name).toBe('Musashi');
});
const req = httpMock.expectOne('/api/samurai');
expect(req.request.method).toBe('GET');
req.flush(mockSamurai);
});
it('should handle error', () => {
service.getSamuraiList().subscribe({
error: (error) => {
expect(error.status).toBe(500);
}
});
const req = httpMock.expectOne('/api/samurai');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
});
});`;
mockingCode = `// Creating a service mock
const mockSamuraiService = {
getSamuraiList: jasmine.createSpy().and.returnValue(of([
{ id: 1, name: 'Musashi' }
])),
addSamurai: jasmine.createSpy().and.returnValue(of({ id: 2, name: 'Kojiro' }))
};
// Usage in TestBed
TestBed.configureTestingModule({
imports: [SamuraiListComponent],
providers: [
{ provide: SamuraiService, useValue: mockSamuraiService }
]
});
// Spy on existing service
const service = TestBed.inject(SamuraiService);
spyOn(service, 'getSamuraiList').and.returnValue(of(mockData));
// Checking invocations
expect(mockSamuraiService.getSamuraiList).toHaveBeenCalled();
expect(mockSamuraiService.addSamurai).toHaveBeenCalledWith({ name: 'Hanzo' });`;
componentServiceCode = `// samurai-list.component.spec.ts
describe('SamuraiListComponent', () => {
let component: SamuraiListComponent;
let fixture: ComponentFixture<SamuraiListComponent>;
let samuraiService: jasmine.SpyObj<SamuraiService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('SamuraiService', ['getSamuraiList', 'deleteSamurai']);
await TestBed.configureTestingModule({
imports: [SamuraiListComponent],
providers: [
{ provide: SamuraiService, useValue: spy }
]
}).compileComponents();
fixture = TestBed.createComponent(SamuraiListComponent);
component = fixture.componentInstance;
samuraiService = TestBed.inject(SamuraiService) as jasmine.SpyObj<SamuraiService>;
});
it('should load samurai on init', fakeAsync(() => {
const mockSamurai = [{ id: 1, name: 'Musashi' }];
samuraiService.getSamuraiList.and.returnValue(of(mockSamurai));
fixture.detectChanges();
tick();
expect(component.samuraiList()).toEqual(mockSamurai);
expect(samuraiService.getSamuraiList).toHaveBeenCalled();
}));
it('should display samurai in template', () => {
samuraiService.getSamuraiList.and.returnValue(of([
{ id: 1, name: 'Musashi' }
]));
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Musashi');
});
});`;
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is HttpTestingController used for?
2. What does the req.flush(mockData) method do in an HTTP test?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Complete the HTTP service test that checks fetching the list of samurai.
- Vertical ordering
Arrange the steps of testing an HTTP request in a service test (top to bottom):
- Horizontal ordering
Arrange the import line for the testing HttpClient provider (one line):