Angular HttpTestingController : tester les HTTP

Front-end Mezgani said
Angular Testing Httptestingcontroller Unit Test Angular 19
Angular HttpTestingController : tester les HTTP

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
La règle d'or du test unitaire : Un test unitaire doit être FIRST — Fast (rapide), Isolated (isolé), Repeatable (reproductible), Self-validating (résultat clair), Timely (écrit avant ou avec le code). Un test qui fait de vraies requêtes HTTP viole les trois premiers critères.

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() obligatoire : Vous devez toujours fournir 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 : Organisez vos tests avec des 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);
});
toPromise() vs firstValueFrom() : Les deux convertissent un Observable en Promise. Préférez 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() vs error() : Utilisez 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() ne vérifie pas les réponses : 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() ET provideHttpClientTesting() sont tous les deux dans les providers
  • httpMock.verify() est dans le afterEach() — pas dans chaque it()
  • 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(), pas flush()
  • 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.log de debug n'a été laissé dans les tests
  • Les tests passent en isolation ET en série complète
Résumé : 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.

Partager