Cours Angular · Module 11: Tests

Tester les services - l'entraînement avant la bataille

6 min de lecture
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. 1. À quoi sert HttpTestingController ?

  2. 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) :

Articles utiles