Cours Angular · Module 11: Tests
Tester les services - l'entraînement avant la bataille
Dans cette leçon3
Un vrai samouraï ne monte pas au combat sans avoir croisé le fer au dojo. Le test, c'est exactement cet entraînement, mais pour ton code : tu vérifies que la technique fonctionne avant que l'ennemi - l'utilisateur - ne la mette à l'épreuve. Dans cette leçon tu vas apprendre à tester des services, et tu verras que chaque test rejoue le même rituel en trois temps : prépare → exécute → vérifie.
Le rituel du test de service
Commençons par le service le plus simple qui soit. Lis le code et repère les trois temps, car ce sont eux qui reviendront dans absolument tous les tests de cette leçon.
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});Voilà les trois temps du rituel. Prépare (beforeEach) : Angular construit le service dans un environnement de test et te le remet via TestBed.inject - chaque test reçoit ainsi un exemplaire neuf et propre. Exécute : tu appelles la méthode (calculatePower). Vérifie (expect) : tu compares le résultat à ce que tu attendais. Beaucoup d'équipes ouvrent aussi le fichier par un test de fumée, expect(service).toBeTruthy(), juste pour prouver que le service se construit. Retiens ce schéma : tout le reste de la leçon n'est que ce même rituel dans des conditions plus difficiles.
Tester le HTTP avec un mock
Un service qui interroge un serveur pose un problème : dans un test, tu ne veux surtout pas envoyer de vraies requêtes - ce serait lent et dépendant du réseau. La solution s'appelle le mock, une doublure. provideHttpClientTesting() fournit un HttpClient simulé, et HttpTestingController intercepte les requêtes pour que ce soit toi, et non le serveur, qui fournisse la réponse. Les deux viennent du même paquet, que tu importes avec import { provideHttpClientTesting } from '@angular/common/http/testing';. Retiens bien son rôle : il simule des réponses et vérifie les requêtes - il n'envoie jamais de vraie requête HTTP, ne contrôle pas la vitesse du réseau et ne sert pas de journal.
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()); // aucune requete ne reste sans reponse
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'); // intercepte la requete
26 expect(req.request.method).toBe('GET');
27 req.flush(mockSamurai); // fournit la reponse simulee
28 });
29});Suis bien la chorégraphie, de haut en bas, car elle est contre-intuitive. Tu commences par service.getAll().subscribe(...) : tu passes commande. Ensuite httpMock.expectOne('/api/samurai') intercepte la requête partie vers cette adresse et te la rend. Tu vérifies alors le verbe avec expect(req.request.method).toBe('GET') - note bien req.request.method, et non req.method : req est la requête interceptée, et la vraie requête HTTP vit dans sa propriété request (quant à httpMock, il n'a aucune propriété method). Et c'est seulement en dernier que req.flush(mockSamurai) simule la réponse du serveur et livre les données à l'abonné : sans lui, le code du subscribe ne s'exécuterait jamais. Ne te trompe pas sur son rôle - flush n'annule pas la requête interceptée, ne vide aucun cache du navigateur et n'envoie rien sur le réseau. Enfin, afterEach(httpMock.verify) garde le dojo propre : aucune requête ne doit rester sans réponse. C'est ton garde-fou contre les erreurs silencieuses.
Le même mock sert aussi à tester ce que tu envoies et ce qui se passe quand tout va mal. Regarde comment on teste la situation où le serveur répond 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); // verifie ce qui a ete envoye
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' }); // simule une erreur
20});Sur un POST, tu inspectes req.request.body - c'est-à-dire ce que le service a réellement envoyé, et pas seulement l'adresse où il l'a envoyé. Le verbe se contrôle toujours de la même manière, avec expect(req.request.method).toBe('POST'). Pour l'erreur, tu demandes au mock de renvoyer un statut 404 grâce au second argument de flush : c'est alors error qui doit se déclencher, et non next. D'où le fail posé dans next - si le chemin heureux s'exécutait quand même, le test tomberait. C'est le motif classique : prouver que la mauvaise route est bien mauvaise.
Quand l'adresse seule ne suffit pas à identifier la requête, expectOne accepte aussi un prédicat. Pratique pour verrouiller les paramètres d'une recherche :
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});Ici tu ne compares plus une simple chaîne : tu décris la requête que tu attends. L'url doit valoir /api/samurai/search et les paramètres q et clan doivent porter les bonnes valeurs. Si le service en oublie un, plus aucune requête ne correspond au prédicat et le test échoue sur-le-champ - la façon la plus fine de figer un contrat d'API.
Tester un interceptor
Un interceptor ajoute quelque chose à chaque requête - un token, par exemple. Tu le testes de la même façon, sur le mock, à un détail près : le service dont il dépend est remplacé par un spy, une doublure dont c'est toi qui pilotes les réponses.
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'); // le spy simule un 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 crée un spy : une fausse version du service à qui tu ordonnes de renvoyer précisément ce dont le test a besoin (getToken.and.returnValue('test-token')). Tu ne dépends donc plus de la vraie logique de connexion - tu poses les conditions, puis tu vérifies un seul fait : l'interceptor a-t-il bien ajouté l'en-tête Authorization ? Un second test, sans token, prouve l'inverse et montre que l'en-tête est alors absent.
De cette leçon, retiens un seul rituel : prépare → exécute → vérifie. Les tests HTTP y ajoutent le mock - expectOne pour intercepter, flush pour répondre - afin que l'entraînement se déroule dans la salle du dojo, et jamais sur un vrai serveur.
Code de cette leçon : src/app/app.component.ts
1import { Component, signal } from '@angular/core';
2import { CommonModule } from '@angular/common';
3
4@Component({
5 selector: 'app-root',
6 standalone: true,
7 imports: [CommonModule],
8 template: `
9 <div class="service-testing">
10 <h1>Tester les Services et HTTP</h1>
11
12 <section>
13 <h2>Tester un service avec HttpClient</h2>
14 <pre><code>{{ serviceTestCode }}</code></pre>
15 </section>
16
17 <section>
18 <h2>Simuler les dépendances</h2>
19 <pre><code>{{ mockingCode }}</code></pre>
20 </section>
21
22 <section>
23 <h2>Tester des composants avec services</h2>
24 <pre><code>{{ componentServiceCode }}</code></pre>
25 </section>
26
27 <section class="commands">
28 <h2>Commandes de test</h2>
29 <div class="command-list">
30 <div class="command">
31 <code>ng test</code>
32 <span>Lancer les tests unitaires</span>
33 </div>
34 <div class="command">
35 <code>ng test --watch=false</code>
36 <span>Lancer les tests une fois (CI)</span>
37 </div>
38 <div class="command">
39 <code>ng test --code-coverage</code>
40 <span>Générer un rapport de couverture</span>
41 </div>
42 <div class="command">
43 <code>ng e2e</code>
44 <span>Lancer les tests E2E</span>
45 </div>
46 </div>
47 </section>
48 </div>
49 `,
50 styles: [`
51 .service-testing {
52 font-family: 'Segoe UI', sans-serif;
53 padding: 20px;
54 background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
55 min-height: 100vh;
56 color: #eee;
57 }
58
59 h1 {
60 text-align: center;
61 color: #4ecca3;
62 }
63
64 h2 {
65 color: #4ecca3;
66 border-bottom: 2px solid #4ecca3;
67 padding-bottom: 10px;
68 }
69
70 section {
71 background: rgba(255,255,255,0.05);
72 border-radius: 10px;
73 padding: 20px;
74 margin: 20px 0;
75 }
76
77 pre {
78 background: #0a1628;
79 padding: 15px;
80 border-radius: 8px;
81 overflow-x: auto;
82 border-left: 4px solid #4ecca3;
83 }
84
85 code {
86 color: #e6e6e6;
87 font-family: 'Fira Code', monospace;
88 font-size: 0.85em;
89 }
90
91 .command-list {
92 display: flex;
93 flex-direction: column;
94 gap: 10px;
95 }
96
97 .command {
98 display: flex;
99 align-items: center;
100 gap: 20px;
101 padding: 12px;
102 background: rgba(0,0,0,0.3);
103 border-radius: 5px;
104 }
105
106 .command code {
107 background: #4ecca3;
108 color: #0d2137;
109 padding: 5px 12px;
110 border-radius: 4px;
111 font-weight: bold;
112 min-width: 200px;
113 }
114
115 .command span {
116 color: #aaa;
117 }
118 `]
119})
120export class AppComponent {
121 serviceTestCode = `// samurai.service.spec.ts
122import { TestBed } from '@angular/core/testing';
123import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
124import { SamuraiService } from './samurai.service';
125
126describe('SamuraiService', () => {
127 let service: SamuraiService;
128 let httpMock: HttpTestingController;
129
130 beforeEach(() => {
131 TestBed.configureTestingModule({
132 imports: [HttpClientTestingModule],
133 providers: [SamuraiService]
134 });
135
136 service = TestBed.inject(SamuraiService);
137 httpMock = TestBed.inject(HttpTestingController);
138 });
139
140 afterEach(() => {
141 httpMock.verify(); // Vérifier s'il y a des requêtes non traitées
142 });
143
144 it('devrait récupérer la liste des samouraïs', () => {
145 const mockSamurai = [
146 { id: 1, name: 'Musashi' },
147 { id: 2, name: 'Kojiro' }
148 ];
149
150 service.getSamuraiList().subscribe(samurai => {
151 expect(samurai.length).toBe(2);
152 expect(samurai[0].name).toBe('Musashi');
153 });
154
155 const req = httpMock.expectOne('/api/samurai');
156 expect(req.request.method).toBe('GET');
157 req.flush(mockSamurai);
158 });
159
160 it('devrait gérer les erreurs', () => {
161 service.getSamuraiList().subscribe({
162 error: (error) => {
163 expect(error.status).toBe(500);
164 }
165 });
166
167 const req = httpMock.expectOne('/api/samurai');
168 req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
169 });
170});`;
171
172 mockingCode = `// Création d'un mock de service
173const mockSamuraiService = {
174 getSamuraiList: jasmine.createSpy().and.returnValue(of([
175 { id: 1, name: 'Musashi' }
176 ])),
177 addSamurai: jasmine.createSpy().and.returnValue(of({ id: 2, name: 'Kojiro' }))
178};
179
180// Utilisation dans TestBed
181TestBed.configureTestingModule({
182 imports: [SamuraiListComponent],
183 providers: [
184 { provide: SamuraiService, useValue: mockSamuraiService }
185 ]
186});
187
188// Espionner (spy) un service existant
189const service = TestBed.inject(SamuraiService);
190spyOn(service, 'getSamuraiList').and.returnValue(of(mockData));
191
192// Vérifier les appels
193expect(mockSamuraiService.getSamuraiList).toHaveBeenCalled();
194expect(mockSamuraiService.addSamurai).toHaveBeenCalledWith({ name: 'Hanzo' });`;
195
196 componentServiceCode = `// samurai-list.component.spec.ts
197describe('SamuraiListComponent', () => {
198 let component: SamuraiListComponent;
199 let fixture: ComponentFixture<SamuraiListComponent>;
200 let samuraiService: jasmine.SpyObj<SamuraiService>;
201
202 beforeEach(async () => {
203 const spy = jasmine.createSpyObj('SamuraiService', ['getSamuraiList', 'deleteSamurai']);
204
205 await TestBed.configureTestingModule({
206 imports: [SamuraiListComponent],
207 providers: [
208 { provide: SamuraiService, useValue: spy }
209 ]
210 }).compileComponents();
211
212 fixture = TestBed.createComponent(SamuraiListComponent);
213 component = fixture.componentInstance;
214 samuraiService = TestBed.inject(SamuraiService) as jasmine.SpyObj<SamuraiService>;
215 });
216
217 it("devrait charger les samouraïs à l'initialisation", fakeAsync(() => {
218 const mockSamurai = [{ id: 1, name: 'Musashi' }];
219 samuraiService.getSamuraiList.and.returnValue(of(mockSamurai));
220
221 fixture.detectChanges();
222 tick();
223
224 expect(component.samuraiList()).toEqual(mockSamurai);
225 expect(samuraiService.getSamuraiList).toHaveBeenCalled();
226 }));
227
228 it('devrait afficher les samouraïs dans le template', () => {
229 samuraiService.getSamuraiList.and.returnValue(of([
230 { id: 1, name: 'Musashi' }
231 ]));
232
233 fixture.detectChanges();
234
235 const compiled = fixture.nativeElement;
236 expect(compiled.textContent).toContain('Musashi');
237 });
238});`;
239}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. À quoi sert HttpTestingController ?
2. Que fait la méthode req.flush(mockData) dans un test HTTP ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Complète le test du service HTTP qui vérifie la récupération de la liste des samouraïs.
- Tri vertical
Ordonne les étapes du test d'une requête HTTP dans un test de service (de haut en bas) :
- Tri horizontal
Ordonne la ligne d'import du provider HttpClient de test (une seule ligne) :