Maîtrisez le testing HTTP dans Angular 19+ avec HttpTestingController : provideHttpClientTesting, flush, verify() et simulation d'erreurs avec exemples complets.
Pourquoi tester le HTTP ? Le problème sans mock
Quand vous développez un service Angular qui fait des appels HTTP, comment vérifiez-vous qu'il fonctionne correctement ? La première idée qui vient est de lancer l'application et de cliquer. C'est rapide pour un test ponctuel, mais catastrophique pour une équipe qui livre souvent.
Imaginez un FilmsService qui charge des films depuis une API. Sans mock HTTP, un test ressemblerait à ça :
// ❌ Test sans mock — dangereux et instable
it('devrait charger la liste des films', async () => {
const service = new FilmsService(new HttpClient(/* ... */));
// Ce test fait une VRAIE requête réseau vers le serveur
// Problèmes : lent (200ms+), dépend du serveur, échoue si hors ligne
const films = await firstValueFrom(service.chargerFilms());
expect(films.length).toBeGreaterThan(0); // résultat imprévisible !
});
Ce type de test pose quatre problèmes majeurs en production :
| Problème | Sans mock HTTP | Avec HttpTestingController |
|---|---|---|
| Vitesse | 200ms – 2s par test (réseau réel) | < 5ms (tout en mémoire) |
| Fiabilité | Échoue si le serveur est down | 100% déterministe |
| Données | Données réelles changeantes | Données fixées par le test |
| Erreurs | Difficile de simuler un 500 | Simuler n'importe quelle erreur en 1 ligne |
| CI/CD | Requiert une infra réseau dédiée | Fonctionne partout, même hors ligne |
HttpTestingController résout tous ces problèmes. Il intercepte les requêtes HttpClient avant qu'elles atteignent le réseau et vous laisse définir exactement quelle réponse le service recevra — succès, erreur 404, timeout, panne réseau — en une seule ligne.
Mise en place : provideHttpClientTesting
Angular 19 introduit une approche de configuration entièrement basée sur les providers fonctionnels, abandonnant progressivement les NgModules. La configuration des tests HTTP suit la même logique.
Ancienne approche vs nouvelle approche
// ❌ Ancienne approche NgModule (Angular < 17, toujours fonctionnelle)
import { HttpClientTestingModule } from '@angular/common/http/testing';
TestBed.configureTestingModule({
imports: [HttpClientTestingModule], // module NgModule
providers: [FilmsService]
});
// ✅ Nouvelle approche Angular 19+ (providers fonctionnels)
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
TestBed.configureTestingModule({
providers: [
FilmsService,
provideHttpClient(), // configure HttpClient standard
provideHttpClientTesting() // remplace le transport réseau par le mock
]
});
Récupérer HttpTestingController
Une fois le TestBed configuré, récupérez l'instance de HttpTestingController pour intercepter les requêtes :
import { TestBed } from '@angular/core/testing';
import { HttpTestingController } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { FilmsService } from './films.service';
describe('FilmsService', () => {
let service: FilmsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
FilmsService,
provideHttpClient(),
provideHttpClientTesting()
]
});
// inject() ou TestBed.inject() — les deux fonctionnent
service = TestBed.inject(FilmsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Vérifier qu'aucune requête non traitée ne traîne
httpMock.verify();
});
});
provideHttpClient() en plus de provideHttpClientTesting(). Le premier configure HttpClient, le second remplace uniquement la couche transport. Sans le premier, Angular ne sait pas comment créer un HttpClient.
Le service FilmsService à tester
Voici le service que nous allons tester tout au long de cet article :
// films.service.ts — service qui sera testé
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
export interface Film {
id: number;
titre: string;
realisateur: string;
annee: number;
note: number; // note sur 10
genres: string[];
}
@Injectable({ providedIn: 'root' })
export class FilmsService {
private http = inject(HttpClient);
private apiUrl = '/api/films';
// Charger tous les films
chargerFilms(): Observable<Film[]> {
return this.http.get<Film[]>(this.apiUrl);
}
// Charger un film par son ID
chargerFilm(id: number): Observable<Film> {
return this.http.get<Film>(`${this.apiUrl}/${id}`);
}
// Rechercher des films par titre
rechercherFilms(terme: string): Observable<Film[]> {
return this.http.get<Film[]>(`${this.apiUrl}/recherche`, {
params: { q: terme }
});
}
// Ajouter un nouveau film
ajouterFilm(film: Omit<Film, 'id'>): Observable<Film> {
return this.http.post<Film>(this.apiUrl, film);
}
// Supprimer un film
supprimerFilm(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
catchError((err: HttpErrorResponse) => throwError(() => err))
);
}
}
Anatomie d'un test HTTP Angular
Avant d'écrire des tests complexes, comprenons la structure standard. Chaque fichier de test suit le même squelette que l'on décortique ici :
// films.service.spec.ts — structure complète commentée
import { TestBed } from '@angular/core/testing';
import { HttpTestingController } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { FilmsService, Film } from './films.service';
// describe() regroupe tous les tests d'une même unité
describe('FilmsService', () => {
let service: FilmsService;
let httpMock: HttpTestingController;
// beforeEach s'exécute AVANT chaque test individuel
// Cela garantit un état propre pour chaque test
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
FilmsService,
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(FilmsService);
httpMock = TestBed.inject(HttpTestingController);
});
// afterEach s'exécute APRÈS chaque test
// verify() détecte les requêtes faites mais non interceptées
afterEach(() => {
httpMock.verify();
});
// Premier test — le plus simple possible
it('devrait être créé', () => {
// Vérifier simplement que le service s'instancie sans erreur
expect(service).toBeTruthy();
});
// Groupe de tests pour la méthode chargerFilms()
describe('chargerFilms()', () => {
it('devrait retourner la liste des films', async () => {
// Données fictives que le mock va "renvoyer"
const filmsMock: Film[] = [
{ id: 1, titre: 'Inception', realisateur: 'Nolan', annee: 2010, note: 9.2, genres: ['SF', 'Thriller'] },
{ id: 2, titre: 'Parasite', realisateur: 'Bong', annee: 2019, note: 9.5, genres: ['Drame', 'Thriller'] }
];
// 1. Appeler la méthode du service (la requête est maintenant "en attente")
const promesse = service.chargerFilms().toPromise();
// 2. Intercepter la requête et vérifier son URL
const req = httpMock.expectOne('/api/films');
expect(req.request.method).toBe('GET'); // vérifier que c'est bien un GET
// 3. Simuler la réponse du serveur
req.flush(filmsMock);
// 4. Attendre la résolution et vérifier le résultat
const films = await promesse;
expect(films).toHaveSize(2);
expect(films![0].titre).toBe('Inception');
});
});
});
describe() imbriqués — un par méthode du service. Cela structure le rapport de test : "FilmsService > chargerFilms() > devrait retourner la liste". Les erreurs sont localisées immédiatement.
La règle d'or : call → expectOne → flush → await
C'est le cœur de tout test avec HttpTestingController. La séquence doit être respectée scrupuleusement, sinon le test se bloque ou passe en faux positif.
Pourquoi cet ordre précis ?
// ❌ Mauvais ordre — test qui se bloque indéfiniment
it('mauvais ordre', async () => {
// 1. On attend la réponse EN PREMIER...
const films = await service.chargerFilms().toPromise(); // ← BLOQUÉ ICI
// 2. ...mais flush() qui débloque la requête n'est jamais appelé
httpMock.expectOne('/api/films').flush([]); // ← jamais atteint
});
// ✅ Bon ordre — la séquence correcte
it('bon ordre', async () => {
// 1. Lancer l'appel (crée la requête en attente)
const promesse = service.chargerFilms().toPromise();
// 2. Intercepter et vérifier (la requête existe maintenant)
const req = httpMock.expectOne('/api/films');
// 3. Simuler la réponse (débloque la promesse)
req.flush([{ id: 1, titre: 'Dune', realisateur: 'Villeneuve', annee: 2021, note: 8.8, genres: ['SF'] }]);
// 4. Attendre et vérifier le résultat (la promesse est maintenant résolue)
const films = await promesse;
expect(films).toHaveSize(1);
});
Vérifier l'URL avec des paramètres
// Tester rechercherFilms() — URL avec query params
it('devrait rechercher des films par titre', async () => {
const resultats: Film[] = [
{ id: 3, titre: 'Matrix', realisateur: 'Wachowski', annee: 1999, note: 9.0, genres: ['SF', 'Action'] }
];
// 1. Appel avec le terme de recherche
const promesse = service.rechercherFilms('matrix').toPromise();
// 2. expectOne avec une fonction de prédicat pour vérifier les query params
const req = httpMock.expectOne(r =>
r.url === '/api/films/recherche' &&
r.params.get('q') === 'matrix'
);
expect(req.request.method).toBe('GET');
// 3. Simuler la réponse
req.flush(resultats);
// 4. Vérifier
const films = await promesse;
expect(films![0].titre).toBe('Matrix');
});
Tester une requête POST avec body
// Tester ajouterFilm() — vérifier le body envoyé
it('devrait envoyer le bon body lors de l\'ajout', async () => {
const nouveauFilm = {
titre: 'Oppenheimer',
realisateur: 'Nolan',
annee: 2023,
note: 8.7,
genres: ['Drame', 'Historique']
};
const filmCree: Film = { id: 10, ...nouveauFilm };
// 1. Appel du service
const promesse = service.ajouterFilm(nouveauFilm).toPromise();
// 2. Intercepter et vérifier le contenu du POST
const req = httpMock.expectOne('/api/films');
expect(req.request.method).toBe('POST');
// Vérifier que le body contient bien les données envoyées
expect(req.request.body.titre).toBe('Oppenheimer');
expect(req.request.body.annee).toBe(2023);
// 3. Simuler la réponse avec le film créé (id inclus)
req.flush(filmCree);
// 4. Vérifier que le service retourne le film avec son nouvel ID
const resultat = await promesse;
expect(resultat!.id).toBe(10);
});
firstValueFrom(observable) importé depuis rxjs — c'est la syntaxe moderne recommandée depuis RxJS 7. toPromise() est déprécié mais encore fonctionnel.
Tester les erreurs HTTP et pannes réseau
Un service robuste doit gérer les erreurs. Il est donc essentiel de tester les cas d'erreur aussi rigoureusement que les cas de succès. HttpTestingController permet de simuler n'importe quelle situation d'échec.
Erreur 404 — ressource non trouvée
it('devrait gérer un film introuvable (404)', async () => {
// Préparer la capture de l'erreur
let erreurRecue: any;
// 1. Appeler avec un ID qui n'existe pas
service.chargerFilm(999).subscribe({
next: () => fail('Aurait dû échouer'), // si on arrive ici, le test échoue
error: err => { erreurRecue = err; } // capturer l'erreur
});
// 2. Intercepter la requête
const req = httpMock.expectOne('/api/films/999');
// 3. Simuler une réponse 404 avec flush()
req.flush(
{ message: 'Film non trouvé', code: 'FILM_NOT_FOUND' }, // body de l'erreur
{ status: 404, statusText: 'Not Found' } // status HTTP
);
// 4. Vérifier que l'erreur est bien une HttpErrorResponse avec status 404
expect(erreurRecue.status).toBe(404);
expect(erreurRecue.error.code).toBe('FILM_NOT_FOUND');
});
Erreur 500 — erreur serveur interne
it('devrait gérer une erreur serveur (500)', async () => {
let erreurRecue: any;
service.chargerFilms().subscribe({
next: () => fail('Aurait dû échouer'),
error: err => { erreurRecue = err; }
});
const req = httpMock.expectOne('/api/films');
// Simuler une erreur 500 Internal Server Error
req.flush(
{ message: 'Erreur interne du serveur' },
{ status: 500, statusText: 'Internal Server Error' }
);
expect(erreurRecue.status).toBe(500);
});
Erreur 401 — non autorisé
it('devrait gérer une réponse 401 non autorisé', async () => {
let erreurRecue: any;
service.ajouterFilm({ titre: 'Test', realisateur: 'X', annee: 2024, note: 5, genres: [] })
.subscribe({
next: () => fail('Aurait dû échouer'),
error: err => { erreurRecue = err; }
});
const req = httpMock.expectOne('/api/films');
// Simuler une réponse 401 — token expiré ou absent
req.flush(
{ message: 'Token invalide ou expiré' },
{ status: 401, statusText: 'Unauthorized' }
);
expect(erreurRecue.status).toBe(401);
expect(erreurRecue.statusText).toBe('Unauthorized');
});
Panne réseau avec error()
Une panne réseau (câble débranché, DNS qui échoue) n'a pas de code HTTP — c'est une erreur de transport. Pour la simuler, utilisez error() au lieu de flush() :
it('devrait gérer une panne réseau complète', async () => {
let erreurRecue: any;
service.chargerFilms().subscribe({
next: () => fail('Aurait dû échouer'),
error: err => { erreurRecue = err; }
});
const req = httpMock.expectOne('/api/films');
// error() simule une panne réseau — pas de réponse HTTP du tout
req.error(
new ProgressEvent('error'), // type d'événement réseau natif
{ status: 0, statusText: 'Unknown Error' }
);
// status 0 = pas de connexion réseau
expect(erreurRecue.status).toBe(0);
});
flush(body, {status, statusText}) quand le serveur répond (même avec une erreur 4xx/5xx). Utilisez error(progressEvent) quand il n'y a pas de réponse du tout — panne réseau, timeout CORS, DNS failure. Le service Angular reçoit dans les deux cas une HttpErrorResponse, mais avec status: 0 pour les pannes réseau.
verify() : détecter les requêtes oubliées
httpMock.verify() est le filet de sécurité de vos tests HTTP. Il vérifie qu'aucune requête n'a été faite par le service sans être interceptée et vérifiée dans le test. Sans lui, des requêtes parasites peuvent passer inaperçues.
Démonstration du problème
// Ce service fait DEUX requêtes lors de l'initialisation
@Injectable()
export class TableauDeBordService {
private http = inject(HttpClient);
// Cette méthode fait 2 appels HTTP en parallèle
chargerDashboard() {
// Requête 1 : films populaires
this.http.get('/api/films/populaires').subscribe();
// Requête 2 : statistiques (souvent oubliée dans les tests !)
this.http.get('/api/stats').subscribe();
}
}
// Test qui intercepte seulement la première requête
it('devrait charger le dashboard', () => {
service.chargerDashboard();
// On intercepte seulement /api/films/populaires
httpMock.expectOne('/api/films/populaires').flush([]);
// /api/stats est toujours en attente — mais on ne s'en rend pas compte...
// Sans verify() dans afterEach, ce test PASSE même si le service est cassé !
});
// afterEach avec verify() DÉTECTE le bug :
// "Expected no open requests, found 1: GET /api/stats"
afterEach(() => {
httpMock.verify(); // ← sauveur silencieux
});
Placer verify() au bon endroit
// ✅ Placer verify() dans afterEach — s'exécute après CHAQUE test
describe('FilmsService', () => {
afterEach(() => {
// verify() après chaque test, pas dans chaque it()
// Avantage : un seul endroit à maintenir
httpMock.verify();
});
it('test A', () => { /* ... */ });
it('test B', () => { /* ... */ });
// verify() s'exécute automatiquement après A et après B
});
// ❌ Piège classique : verify() AVANT flush()
it('piège à éviter', () => {
service.chargerFilms().subscribe();
// verify() est appelé AVANT flush()
// La requête est encore "en attente" → verify() échoue toujours ici
httpMock.verify(); // ← trop tôt !
httpMock.expectOne('/api/films').flush([]);
});
verify() détecte uniquement les requêtes ouvertes (faites mais non interceptées). Elle ne vérifie pas que les réponses que vous avez flushées étaient correctes. C'est le rôle de vos expect() dans le corps du test.
Scénarios avancés : match() et requêtes multiples
expectNone() : vérifier qu'aucune requête n'est faite
Utile pour tester qu'un cache fonctionne ou qu'une condition bloque bien les appels :
// Tester qu'un service ne fait pas de requête si le cache est valide
it('ne devrait pas appeler l\'API si les films sont en cache', () => {
// Pré-remplir le cache du service
service.cache = [
{ id: 1, titre: 'Interstellar', realisateur: 'Nolan', annee: 2014, note: 9.1, genres: ['SF'] }
];
// Appeler la méthode — le service devrait utiliser le cache
service.chargerFilms().subscribe();
// expectNone() vérifie qu'aucune requête vers cette URL n'a été faite
httpMock.expectNone('/api/films');
// Si une requête est faite malgré tout → le test échoue avec un message clair
});
match() : intercepter plusieurs requêtes
// Tester un service qui fait plusieurs requêtes similaires
it('devrait charger les films de plusieurs catégories', async () => {
const categories = ['action', 'drame', 'comedie'];
// Le service lance 3 requêtes en parallèle
const promesse = service.chargerParCategories(categories).toPromise();
// match() retourne UN TABLEAU de toutes les requêtes correspondantes
const requetes = httpMock.match(req =>
req.url.startsWith('/api/films') &&
req.method === 'GET'
);
// Vérifier que les 3 requêtes ont bien été faites
expect(requetes).toHaveSize(3);
// Répondre à chacune individuellement
requetes[0].flush([{ id: 1, titre: 'Mad Max', realisateur: 'Miller', annee: 2015, note: 8.1, genres: ['Action'] }]);
requetes[1].flush([{ id: 2, titre: 'Marriage Story', realisateur: 'Baumbach', annee: 2019, note: 7.9, genres: ['Drame'] }]);
requetes[2].flush([{ id: 3, titre: 'The Grand Budapest Hotel', realisateur: 'Anderson', annee: 2014, note: 8.1, genres: ['Comedie'] }]);
const resultats = await promesse;
expect(resultats).toHaveSize(3);
});
Tester un service avec retry automatique
// Service qui réessaie automatiquement en cas d'erreur réseau
@Injectable()
export class FilmsServiceAvecRetry {
private http = inject(HttpClient);
chargerFilmsAvecRetry(): Observable<Film[]> {
return this.http.get<Film[]>('/api/films').pipe(
retry(2) // réessayer 2 fois en cas d'erreur
);
}
}
// Test : les 2 premières tentatives échouent, la 3ème réussit
it('devrait réessayer 2 fois avant de réussir', async () => {
const filmsMock = [{ id: 1, titre: 'Blade Runner 2049', realisateur: 'Villeneuve', annee: 2017, note: 8.0, genres: ['SF'] }];
const promesse = service.chargerFilmsAvecRetry().toPromise();
// Tentative 1 — simuler une panne réseau
httpMock.expectOne('/api/films').error(new ProgressEvent('error'));
// Tentative 2 — autre panne réseau
httpMock.expectOne('/api/films').error(new ProgressEvent('error'));
// Tentative 3 — succès
httpMock.expectOne('/api/films').flush(filmsMock);
const films = await promesse;
expect(films).toHaveSize(1);
});
Vérifier les headers d'une requête
// Vérifier que l'intercepteur d'auth ajoute bien le token
it('devrait envoyer le header Authorization', () => {
service.chargerFilms().subscribe();
const req = httpMock.expectOne('/api/films');
// Vérifier la présence et la valeur d'un header
expect(req.request.headers.has('Authorization')).toBeTrue();
expect(req.request.headers.get('Authorization')).toMatch(/^Bearer /);
req.flush([]);
});
Bonnes pratiques et checklist finale
Helper factory pour les données de test
Évitez de répéter la création d'objets Film dans chaque test. Créez une factory qui génère des données valides avec des valeurs par défaut :
// test-helpers/film.factory.ts — factory réutilisable
export function creerFilm(overrides: Partial<Film> = {}): Film {
return {
id: 1,
titre: 'Film de test',
realisateur: 'Réalisateur Test',
annee: 2024,
note: 7.5,
genres: ['Action'],
...overrides // écraser seulement ce qui est pertinent pour le test
};
}
// Utilisation dans les tests — concis et explicite
it('devrait afficher le titre du film', async () => {
const promesse = service.chargerFilm(5).toPromise();
httpMock.expectOne('/api/films/5').flush(
creerFilm({ id: 5, titre: 'Dunkerque' }) // seul le titre nous intéresse
);
const film = await promesse;
expect(film!.titre).toBe('Dunkerque');
});
Nommer les tests comme une documentation
// ❌ Noms vagues — ne décrivent pas le comportement attendu
it('test 1', () => { /* ... */ });
it('charge les films', () => { /* ... */ });
it('erreur', () => { /* ... */ });
// ✅ Noms précis — lisibles comme une spec fonctionnelle
it('devrait retourner un tableau vide si l\'API retourne []', () => {});
it('devrait lancer une erreur 404 si le film n\'existe pas', () => {});
it('devrait envoyer le body correct lors de la création', () => {});
it('devrait NE PAS appeler l\'API si le terme de recherche est vide', () => {});
Checklist avant de merger
provideHttpClient()ETprovideHttpClientTesting()sont tous les deux dans les providershttpMock.verify()est dans leafterEach()— pas dans chaqueit()- Chaque méthode HTTP du service a au moins un test de succès ET un test d'erreur
- Les tests de POST/PUT vérifient le contenu du
req.request.body - Les pannes réseau sont testées avec
error(), pasflush() - Les URLs avec query params sont testées avec une fonction prédicat
- Une factory de données de test est utilisée pour éviter la répétition
- Les noms de tests décrivent le comportement attendu (should + action + condition)
- Aucun
console.logde debug n'a été laissé dans les tests - Les tests passent en isolation ET en série complète
HttpTestingController est l'outil indispensable pour tester les services HTTP Angular de façon fiable et rapide. La clé est de respecter la séquence call → expectOne → flush → await, de placer verify() dans afterEach(), et de couvrir autant les cas d'erreur que les cas de succès. Des tests HTTP solides vous permettront de modifier vos services en toute confiance.