Développement Web

Tester Angular avec Vitest et Playwright

14 min de lecture

L’arrivée de Vitest comme runner par défaut dans Angular 21 marque la fin d’une époque. Karma, fidèle compagnon depuis 2013, est officiellement remplacé par un outil moderne, rapide, et bien mieux intégré à l’écosystème JavaScript actuel. En parallèle, Playwright s’impose comme le standard pour les tests end-to-end depuis l’arrêt du support de Protractor. Ce tutoriel couvre la migration de Karma vers Vitest, l’écriture de tests unitaires sur composants standalone et signals, puis l’orchestration de tests E2E avec Playwright pour valider les parcours critiques.

Prérequis

  • Angular 16 minimum (21 recommandé pour bénéficier de Vitest par défaut).
  • Node.js 20 LTS ou plus récent.
  • Un projet Angular existant (avec ou sans Karma actuellement).
  • Une connaissance basique de Jasmine/Vitest et des concepts d’assertion (expect, toBe, etc.).
  • Une heure pour suivre l’ensemble du tutoriel, exécuter chaque commande et observer les résultats.

Étape 1 — Migrer Karma vers Vitest

Si vous êtes sur Angular 21 et créez un nouveau projet, Vitest est déjà installé et actif : ng test lance directement le nouveau runner. Pour un projet historique en Karma, Angular fournit un schematic dédié qui transforme la configuration, remplace Karma par Vitest dans angular.json, ajuste tsconfig.spec.json et installe les dépendances. La migration est conservatrice : elle ne touche pas vos tests existants tant qu’ils utilisent Jasmine ou Vitest comme API d’assertion.

ng update @angular/cli
ng generate @angular/core:karma-to-vitest

La commande analyse votre angular.json, identifie les cibles test basées sur Karma, et propose la conversion. Acceptez, puis lancez ng test : vous remarquez immédiatement le démarrage instantané (Vitest n’a pas besoin d’un navigateur pour la majorité des tests), et la sortie console plus compacte. Sur un projet de 200 tests, le gain typique va de 35 secondes (Karma + Chrome) à 4 secondes (Vitest + jsdom). Pour les rares tests qui exigent un vrai navigateur (mesures de layout, accès Canvas), Vitest supporte également Playwright comme environnement de test.

Étape 2 — Écrire un premier test unitaire

Un test Vitest ressemble beaucoup à un test Jasmine — c’est volontaire pour faciliter la migration. Les fonctions describe, it, expect sont les mêmes. Quelques différences subtiles : vi remplace jasmine pour les spies (vi.fn() au lieu de jasmine.createSpy()), et les matchers asymétriques utilisent expect.any() à l’identique.

import { describe, it, expect } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  it('incrémente la valeur au clic', () => {
    const fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();
    fixture.detectChanges();

    const display = fixture.nativeElement.querySelector('span');
    expect(display.textContent).toBe('1');
  });
});

Le pattern reste familier : on crée le composant, on simule un clic, on vérifie le DOM. La grande différence est dans la performance d’exécution. Vitest réutilise le même worker Node entre les tests, ce qui élimine l’overhead de spawn d’un navigateur. Lancez le test en mode watch (ng test --watch) et modifiez le composant : la ré-exécution prend environ 200 ms, là où Karma demandait 5 à 10 secondes pour relancer son cycle complet.

Étape 3 — Tester un composant avec signals

Les composants Angular modernes utilisent largement les signals. Les tester demande de comprendre une particularité : un signal lu dans un template ne déclenche pas directement un re-render dans un test, il faut appeler fixture.detectChanges() pour forcer la mise à jour. Pour tester la valeur d’un signal seul (hors template), il suffit de l’invoquer comme une fonction.

import { signal, Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';

@Component({
  selector: 'app-greeter',
  standalone: true,
  template: `<h1>Bonjour {{ name() }}</h1>`,
})
class GreeterComponent {
  name = signal('inconnu');
}

describe('GreeterComponent', () => {
  it('reflète la valeur du signal dans le template', () => {
    const fixture = TestBed.createComponent(GreeterComponent);
    fixture.componentInstance.name.set('Aïcha');
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('Bonjour Aïcha');
  });
});

Le test mute le signal avec set(), déclenche un cycle de change detection, puis vérifie le rendu. C’est exactement le même pattern qu’avec un @Input traditionnel. Pour tester un computed, lisez-le directement après avoir modifié ses dépendances — pas besoin de detectChanges tant que vous restez hors du DOM. Pour un effect, ouvrez un contexte d’injection via TestBed.runInInjectionContext(() => effect(...)) et déclenchez les changements dans ce contexte.

Étape 4 — Tester un service avec dépendances injectées

Les services constituent la couche métier de la plupart des applications Angular. Les tester en isolation demande de mocker leurs dépendances — typiquement HttpClient et d’autres services. Vitest fournit vi.fn() pour créer des fonctions espionnées, et Angular fournit provideHttpClientTesting() pour intercepter les requêtes HTTP sans toucher au réseau.

import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        UserService,
      ],
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('appelle l\'API et retourne les utilisateurs', async () => {
    const promise = service.list();
    const req = httpMock.expectOne('/api/users');
    req.flush([{ id: 1, name: 'Aïcha' }]);
    const data = await promise;
    expect(data).toHaveLength(1);
    expect(data[0].name).toBe('Aïcha');
  });
});

Trois éléments clés. provideHttpClientTesting() remplace l’implémentation réelle par un mock contrôlable. expectOne() vérifie qu’une et une seule requête a été émise vers l’URL attendue. flush() fournit la réponse simulée et résout la promesse. Le test n’effectue jamais d’appel réseau réel, ce qui le rend déterministe et rapide (typiquement quelques millisecondes). Pour tester l’erreur, remplacez req.flush(...) par req.flush('', { status: 500, statusText: 'Server Error' }) et vérifiez la gestion d’erreur du service.

Étape 5 — Tester un NgRx Signal Store

Un Signal Store NgRx se teste sans subscription RxJS ni TestBed lourde. L’idée est d’instancier le store dans un contexte d’injection, déclencher ses méthodes, et lire les signals comme dans un composant. C’est l’un des bénéfices DX majeurs de Signal Store.

import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { patchState } from '@ngrx/signals';
import { ProductStore } from './product.store';

describe('ProductStore', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [ProductStore] });
  });

  it('filtre les produits selon la query', () => {
    const store = TestBed.inject(ProductStore);
    patchState(store, { products: [
      { id: 1, name: 'Clavier' },
      { id: 2, name: 'Souris' },
    ]});
    store.setQuery('Clavier');
    expect(store.filtered()).toHaveLength(1);
    expect(store.filtered()[0].name).toBe('Clavier');
  });
});

Le test couvre la logique métier sans rendu DOM. C’est rapide (quelques millisecondes par test), isolé, et facile à comprendre. Pour les méthodes asynchrones du store, déclarez le it en async et utilisez await normalement — pas de marble testing à apprendre. Pour mocker les dépendances du store (typiquement HttpClient), suivez le même pattern qu’à l’étape précédente avec provideHttpClientTesting().

Étape 6 — Installer et configurer Playwright

Pour les tests end-to-end, Playwright s’est imposé comme la solution de référence. Il pilote des navigateurs réels (Chromium, Firefox, WebKit), supporte le testing parallèle, et offre un mode headless très rapide. L’installation passe par sa propre CLI, indépendamment d’Angular.

npm init playwright@latest

# Réponses suggérées :
# - Test directory : e2e
# - GitHub Actions : oui (si CI en place)
# - Install Playwright browsers : oui
# - TypeScript : oui

L’installation crée un dossier e2e/ avec un fichier d’exemple, un playwright.config.ts à la racine, et télécharge les binaires des trois navigateurs (environ 300 Mo au total). Vérifiez l’installation avec npx playwright test --list qui doit afficher au moins le test d’exemple. Pour que Playwright lance automatiquement votre serveur Angular avant les tests, ajoutez la section webServer dans playwright.config.ts.

Étape 7 — Configurer le démarrage automatique du serveur

Sans configuration, Playwright suppose que le serveur est déjà démarré quand il lance les tests. C’est rarement le cas en CI. La directive webServer demande à Playwright de démarrer une commande, d’attendre qu’une URL réponde, puis de lancer les tests — et de tout arrêter à la fin.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4200',
    timeout: 120_000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry',
  },
});

L’option reuseExistingServer: !process.env.CI est précieuse en développement : si vous avez déjà un ng serve qui tourne, Playwright le réutilise au lieu d’en lancer un deuxième. En CI, où aucun serveur n’est démarré, Playwright le lance lui-même. Le trace: 'on-first-retry' capture une trace complète (DOM, network, console) lors d’un échec, accessible via npx playwright show-trace trace.zip pour déboguer post-mortem.

Étape 8 — Écrire un parcours utilisateur end-to-end

Un test Playwright décrit une interaction utilisateur de bout en bout : navigation, remplissage de formulaires, attente d’une réponse, vérification du résultat affiché. L’API page expose toutes les méthodes utiles (goto, click, fill, locator) avec un système de retries automatiques qui rend les tests fiables même quand les éléments apparaissent de manière asynchrone.

import { test, expect } from '@playwright/test';

test('un utilisateur peut chercher et supprimer un produit', async ({ page }) => {
  await page.goto('/');

  await expect(page.locator('h1')).toHaveText('Catalogue produits');

  await page.fill('input[placeholder="Rechercher"]', 'Clavier');
  await expect(page.locator('article')).toHaveCount(1);

  await page.click('article button:has-text("Supprimer")');
  await expect(page.locator('article')).toHaveCount(0);
  await expect(page.locator('p')).toHaveText('Aucun produit trouvé.');
});

Le test simule un parcours réaliste. expect(locator).toHaveText(...) attend automatiquement que le sélecteur trouve un élément avec le texte attendu, jusqu’à 5 secondes par défaut. Pas besoin de waitForSelector manuel ni de setTimeout précaire. Pour faire tourner ce test, lancez npx playwright test et observez : Playwright démarre votre ng serve, attend qu’il réponde, exécute le test sur Chromium, et arrête tout. Sur un test simple comme celui-ci, comptez 3 à 5 secondes au total.

Étape 9 — Tester en mode mobile et desktop

Playwright permet de tester votre application sur plusieurs configurations en parallèle : Chromium desktop, Firefox, WebKit Safari, et même des émulations mobiles précises (iPhone 14, Pixel 7). C’est l’occasion d’attraper des bugs spécifiques à un viewport ou à un user agent sans installer manuellement chaque environnement.

// playwright.config.ts (extension)
import { devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
  ],
});

Lancez npx playwright test : tous les projets s’exécutent en parallèle, et le rapport indique pour chaque test sur quelle config il a passé ou échoué. Pour exécuter un seul projet pendant le développement, ajoutez --project=chromium. Pour un site grand public, surveillez particulièrement les versions mobiles — c’est là que la majorité des bugs UX se révèlent. Une commande utile en complément : npx playwright codegen http://localhost:4200 ouvre un navigateur en mode enregistrement, et chaque clic produit le code Playwright correspondant, ce qui accélère l’écriture des tests initiaux.

Étape 10 — Intégrer les tests en CI

Pour qu’une suite de tests garde sa valeur, elle doit s’exécuter à chaque pull request. GitHub Actions est l’outil le plus répandu pour Angular ; voici une configuration minimale qui exécute Vitest puis Playwright. Le cache npm et le cache des binaires Playwright divisent par trois le temps d’exécution sur les runs successifs.

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx ng test --watch=false
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Cette configuration tient en une vingtaine de lignes. Elle exécute les tests unitaires d’abord (rapides, ils échouent vite si un bug logique est introduit), puis les E2E (plus lents, ils valident l’intégration). En cas d’échec, le rapport Playwright avec traces et captures est uploadé comme artifact, ce qui permet de déboguer depuis l’interface GitHub sans rejouer le test localement. Pour confirmer la mise en place, poussez la branche et vérifiez dans l’onglet Actions du dépôt que le job test apparaît et passe.

Erreurs fréquentes

Erreur Cause Solution
Cannot find module 'zone.js/testing' Projet zoneless mais setup test ancien Mettre à jour test.ts selon la doc Angular 21
Test Vitest qui passe en local mais échoue en CI Différence de timezone ou de locale Définir TZ=UTC et LANG=fr_FR.UTF-8 dans la CI
Playwright timeout sur une assertion Sélecteur trop générique Cibler par rôle (page.getByRole('button', { name: 'Supprimer' }))
HttpClient appelle l’API réelle dans un test provideHttpClientTesting() oublié L’ajouter aux providers du TestBed
Test E2E lent : démarrage Angular trop long Build dev compilé à chaque run Activer reuseExistingServer et garder ng serve ouvert
NG0203 dans un test de signals Effect créé hors injection context Utiliser TestBed.runInInjectionContext()

Adaptation aux environnements contraints

Vitest réduit drastiquement le besoin de ressources machine pour faire tourner la suite de tests. Là où Karma exigeait un Chrome headless de 200 Mo de RAM par worker, Vitest fonctionne dans des workers Node de 30-50 Mo. C’est un avantage net pour les développeurs travaillant sur des laptops modestes ou pour les pipelines CI à budget limité — un runner GitHub Actions standard exécute la suite complète en moins de cinq minutes pour un projet de taille moyenne, là où la même suite Karma pouvait dépasser quinze minutes. Pour Playwright, l’option --workers=2 limite l’usage de RAM si vous testez sur une machine modeste, au prix d’un temps total un peu plus long.

FAQ

Vitest est-il compatible avec mes tests Jasmine existants ?
Oui dans la majorité des cas. Les API describe, it, expect, beforeEach sont identiques. Les spies (jasmine.createSpy) doivent être remplacés par vi.fn(), ce que le schematic de migration fait automatiquement. Quelques matchers très spécifiques à Jasmine peuvent demander un adaptateur, mais c’est rare.

Faut-il garder Karma pour certains tests ?
Non, pas dans un projet récent. Karma reste supporté pour préserver l’existant historique, mais aucun nouveau développement n’a de raison de l’utiliser. Tous les cas d’usage de Karma sont couverts par Vitest, parfois mieux (mode HMR, parallélisation).

Cypress ou Playwright pour le E2E ?
Les deux sont valables. Playwright a l’avantage du multi-navigateurs natif, du mode parallèle plus mature, et de la stratégie de retry automatique. Cypress a un studio visuel plus aboutie pour le développement interactif. Pour un projet Angular moderne, Playwright est généralement le meilleur choix en raison de son intégration directe avec le runner Node et de l’absence de limitations cross-origin.

Comment couvrir un test qui dépend du fuseau horaire ?
Fixez le fuseau dans la configuration de test : process.env.TZ = 'Europe/Paris' au début du fichier de setup. Pour les composants qui formatent des dates, mockez Date.now() avec vi.useFakeTimers() pour rendre les tests déterministes.

Combien de tests E2E sont raisonnables ?
La règle empirique : un parcours utilisateur critique par fonctionnalité majeure (inscription, connexion, achat, recherche). Les tests E2E sont coûteux en temps d’exécution ; sur une suite de 50 parcours, on observe typiquement 10 à 15 minutes d’exécution en CI. Au-delà, redonnez la priorité aux tests unitaires et d’intégration plus rapides.

Pour aller plus loin

Références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité