~8 min de lecture
Arrête de mocker HttpClient : les 5 pièges de HttpTestingController
TL;DR
Mocker HttpClient à la main, c'est tester que ton mock renvoie ce que tu lui as dit de renvoyer. HttpTestingController intercepte la vraie requête sortante : tu vérifies l'URL, la méthode, les params, les headers, le body, puis tu injectes la réponse. Cinq pièges guettent : provideHttpClientTesting() posé avant provideHttpClient() se fait écraser, expectOne(url) matche l'URL avec ses query params, un verify() oublié laisse passer les requêtes fantômes (celles qu'aucune assertion n'a réclamées), une erreur HTTP (flush + status) et une erreur réseau (error()) ne testent pas le même chemin, et deux requêtes identiques font jeter expectOne. Tout ce qui suit est vérifié sur Angular 22 ; l'API par providers existe depuis Angular 15.
Ouvre un de tes fichiers de test de service HTTP. Il y a de bonnes chances que tu tombes sur un truc comme ça, écrit du temps où UsersApi prenait encore son HttpClient par constructeur :
it('charge les utilisateurs', () => {
const httpMock = { get: vi.fn().mockReturnValue(of([{ id: 1, name: 'Ada' }])) };
const api = new UsersApi(httpMock as unknown as HttpClient);
api.search(1).subscribe((users) => {
expect(users.length).toBe(1);
});
});
Ce test est vert. Il resterait vert si tu te trompais d'URL, si tu oubliais les query params, ou si tu envoyais page en string là où ton backend attend un number. Et si tu passais la requête en POST, il virerait au rouge pour la mauvaise raison : this.http.post is not a function, parce que ton mock n'a pas de post, pas parce que la méthode HTTP est fausse. Il vérifie une seule chose : que ton mock renvoie ce que tu lui as demandé de renvoyer. C'est un test de vi.fn(), pas un test de ton code.
Et le jour où tu passes ton service à inject(), le new UsersApi(...) ne compile même plus. Double peine.
Angular embarque l'outil qui fait ça proprement depuis l'arrivée de HttpClient : HttpTestingController. Il ne mocke pas ton service, il remplace le backend HTTP. Ton code exécute son vrai chemin, la requête part réellement dans le client HTTP d'Angular, et elle est interceptée juste avant le réseau. Toi, tu joues le serveur.
Le setup tient en deux providers. Mais cinq comportements non intuitifs transforment régulièrement cet outil en source de faux verts et de messages cryptiques. On les prend un par un, sur ce service :
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UsersApi {
private readonly http = inject(HttpClient);
search(page: number): Observable<User[]> {
return this.http.get<User[]>('/api/users', { params: { page } });
}
}
Le setup de base
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
describe('UsersApi', () => {
let api: UsersApi;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(UsersApi);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTesting.verify();
});
it('récupère la page demandée', () => {
let users: User[] | undefined;
api.search(2).subscribe((result) => (users = result));
const req = httpTesting.expectOne('/api/users?page=2');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1, name: 'Ada' }]);
expect(users).toEqual([{ id: 1, name: 'Ada' }]);
});
});
Trois temps : le code déclenche la requête, expectOne la récupère et te laisse l'inspecter, flush injecte la réponse. La livraison est synchrone : dès le flush, ton subscribe a reçu la valeur. Dans un test de service comme ici, pas besoin d'await ni de whenStable. Dans un test de composant en revanche, la valeur est arrivée mais le DOM n'est pas encore rafraîchi : il te faut encore un await fixture.whenStable() ou un detectChanges() avant de vérifier le rendu. Ça reste rapide et déterministe : zéro réseau, zéro timer.
Maintenant, les pièges.
Piège 1 : l'ordre des providers n'est pas décoratif
provideHttpClientTesting() fonctionne en écrasant le backend que provideHttpClient() vient de déclarer. En DI Angular, le dernier provider d'un même token gagne (hors providers multi, qui s'accumulent au lieu de s'écraser). Donc si tu écris :
TestBed.configureTestingModule({
providers: [provideHttpClientTesting(), provideHttpClient()], // inversé
});
... c'est provideHttpClient() qui gagne, et ton test parle au vrai backend : FetchBackend, le défaut sur Angular 22. Sous jsdom, cette requête ne va nulle part (le fetch de Node refuse une URL relative) et ton subscribe reçoit un HttpErrorResponse avec status: 0 ; sans handler error, Vitest te remonte en plus une exception non attrapée. Mais expectOne, lui, interroge le backend de test, qui n'a jamais rien reçu :
Error: Expected one matching request for criteria "Match URL: /api/users", found none.
Le message est perfide : il te fait chercher un bug dans ton service (« la requête ne part pas ? ») alors que la requête est bien partie. Elle a traversé la même chaîne d'interceptors, mais elle se termine dans FetchBackend, pas dans le backend de test.
Règle simple : provideHttpClientTesting() se place toujours après provideHttpClient(). Et comme tes options d'interceptors vivent dans provideHttpClient(withInterceptors([...])), cet ordre te permet au passage de tester tes interceptors avec le même outillage : la requête que tu inspectes est celle qui a traversé toute la chaîne d'interceptors.
Piège 2 : expectOne compare urlWithParams, pas ton path
Le test naïf de search(2) s'écrit comme ça :
api.search(2).subscribe();
const req = httpTesting.expectOne('/api/users'); // boom
Et il explose :
Error: Expected one matching request for criteria "Match URL: /api/users", found none. Requests received are: GET /api/users?page=2.
Quand tu passes une string à expectOne, elle est comparée à l'URL complète, query params compris (urlWithParams), pas au path seul. La requête est bien là, le message te la montre, mais /api/users et /api/users?page=2 sont deux strings différentes.
Deux options. Soit tu assumes l'URL complète, et tu gagnes une assertion sans effort :
const req = httpTesting.expectOne('/api/users?page=2');
Soit les params sont nombreux, soit leur ordre fragilise ton test, et tu passes un prédicat. Dans un prédicat, req.url est l'URL telle que tu l'as passée à HttpClient : avec l'option params, comme ici, c'est le path nu, et les valeurs se lisent dans req.params :
const req = httpTesting.expectOne(
(r) => r.url === '/api/users' && r.params.get('page') === '2',
);
Note le '2' : dans la requête sortante, ton page: number est devenu une string, parce que c'est ce qui part sur le fil. C'est exactement le genre de détail que ton mock de HttpClient ne verra jamais.
Piège 3 : sans verify(), les requêtes fantômes passent
Imagine que quelqu'un ajoute un appel de tracking dans search(), ou déclenche le même appel deux fois par accident. Ton test existant reste vert : il attrape la requête qu'il attend, il ignore les autres.
verify() ferme ce trou : il jette si des requêtes sont parties sans qu'aucun expectOne / match ne les ait réclamées.
Error: Expected no open requests, found 1: GET /api/tracking
C'est le filet de sécurité de tout le fichier de tests, donc il ne se négocie pas test par test : il vit dans un afterEach.
afterEach(() => {
httpTesting.verify();
});
Deux précisions pour éviter les mauvaises surprises :
verify()compte les requêtes jamais réclamées. Une requête que tu as récupérée viaexpectOnemais jamais résolue par unflushpasse leverify()sans broncher. C'est utile (tester un état de chargement pendant que la requête est en vol, sans devoir la résoudre), mais ça veut dire queverify()ne te garantit pas que tout a été résolu, seulement que rien n'est parti à ton insu.- Si ton composant déclenche des requêtes à l'init, le
verify()d'afterEacht'obligera à les réclamer dans chaque test. C'est volontaire. Le jour où l'init déclenche un appel de plus, tous les tests du fichier te le disent.
Piège 4 : une erreur HTTP et une erreur réseau ne testent pas le même chemin
Pour tester le chemin d'erreur, le réflexe est de chercher un flushError. Il n'existe pas, et c'est normal : une réponse d'erreur HTTP est une réponse. Tu la produis avec flush, en passant le status :
it('propage une 404 en erreur typée', () => {
let error: HttpErrorResponse | undefined;
api.search(99).subscribe({ error: (e) => (error = e) });
httpTesting
.expectOne('/api/users?page=99')
.flush({ message: 'not found' }, { status: 404, statusText: 'Not Found' });
expect(error?.status).toBe(404);
expect(error?.error).toEqual({ message: 'not found' });
});
Le body passé à flush se retrouve dans error.error : tu testes aussi que ton code sait lire le payload d'erreur de ton backend.
Mais une 404, c'est un serveur qui a répondu. Un serveur injoignable, un DNS qui échoue, un CORS qui bloque, c'est un autre chemin : pas de status HTTP, pas de body. Ça se simule avec error() et un ProgressEvent, et ça produit un HttpErrorResponse avec status: 0 :
it('gère la panne réseau', () => {
let error: HttpErrorResponse | undefined;
api.search(1).subscribe({ error: (e) => (error = e) });
httpTesting.expectOne('/api/users?page=1').error(new ProgressEvent('error'));
expect(error?.status).toBe(0);
});
Si ton service a un comportement du genre « retry sur panne réseau, pas sur 4xx », il te faut les deux tests : un seul des deux au vert ne prouve rien sur l'autre branche.
Piège 5 : deux requêtes identiques, et expectOne te lâche
expectOne porte son contrat dans son nom : exactement une. Deux appels en vol vers la même URL, et il jette :
Error: Expected one matching request for criteria "Match URL: /api/users", found 2 requests.
Ce cas arrive plus souvent qu'on ne croit : un même Observable de requête souscrit deux fois (chaque subscribe relance l'appel), un polling, un composant qui recharge à chaque changement de filtre, ou plus généralement les souscriptions mal maîtrisées du genre double subscribe. L'outil pour ça, c'est match, qui rend toutes les requêtes correspondantes :
api.search(1).subscribe();
api.search(1).subscribe();
const reqs = httpTesting.match((r) => r.url === '/api/users');
expect(reqs.length).toBe(2);
reqs.forEach((req) => req.flush([]));
Dernier détail, le plus vicieux du lot : expectOne et match consomment les requêtes qu'ils matchent, y compris quand expectOne finit par jeter son found 2. Si tu attrapes son erreur et que tu retentes un match derrière, il rendra une liste vide : les deux requêtes ont été retirées au moment où expectOne les a comptées, et même le verify() d'afterEach ne les verra plus. Un expectOne qui jette sur des requêtes multiples n'est donc pas une opération blanche (celui qui jette found none l'est : rien de matché, rien de consommé). Ne construis pas de logique de repli dessus : choisis expectOne ou match selon ce que le test attend, et tranche avant de l'écrire.
Before / after
Le test du début, réécrit :
// Avant : on teste le mock
const httpMock = { get: vi.fn().mockReturnValue(of([{ id: 1, name: 'Ada' }])) };
const api = new UsersApi(httpMock as unknown as HttpClient);
// Après : on teste la requête
api = TestBed.inject(UsersApi);
api.search(2).subscribe((result) => (users = result));
const req = httpTesting.expectOne('/api/users?page=2');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1, name: 'Ada' }]);
Même longueur, à peu de chose près. Sauf que la version d'après casse si l'URL change, si la méthode HTTP change, si les params changent, si un appel part en double. Et elle sait tester les 404 et les pannes réseau. La version d'avant ne réagit qu'à la surface de ton service : renommer search, ou changer de verbe HTTP, et encore, sur un TypeError qui ne dit rien de ce que tu voulais vérifier.
Récap actionnable
provideHttpClient()d'abord,provideHttpClientTesting()ensuite. L'inverse te connecte au vrai backend etexpectOnene voit plus rien.expectOne(string)compareurlWithParams, l'URL avec ses query params. Pour matcher le path seul : prédicat surreq.url, et les params passés par l'optionparamsse vérifient viareq.params.httpTesting.verify()dans unafterEach, systématiquement. C'est lui qui attrape les requêtes que ton test n'attendait pas.- Erreur HTTP :
flush(body, { status, statusText }). Panne réseau :error(new ProgressEvent('error')), qui donnestatus: 0. Deux chemins, deux tests. - Plusieurs requêtes identiques :
match, pasexpectOne. Et pas de repli de l'un vers l'autre : unexpectOnequi jette surfound 2a déjà consommé les requêtes.
Ton HttpClient mocké à la main, lui, ne t'aurait rien dit de tout ça.