Migrez vos tests Angular de Karma vers Vitest : installation, configuration vite, happy-dom, mocking moderne, coverage v8 et integration CI/CD complete.
Karma déprécié : pourquoi migrer maintenant
En 2023, l'équipe Angular a annoncé la dépréciation officielle de Karma, son runner de tests historique. Karma souffrait de problèmes structurels : lenteur (lancement d'un vrai navigateur Chrome), maintenance abandonnée par Google, instabilité dans les pipelines CI. Angular 21 introduit le builder officiel @angular/build:test compatible avec Vitest, et Angular 23 (prévu novembre 2026) supprimera définitivement le support Karma.
Vitest est le runner moderne qui s'impose dans l'écosystème JavaScript depuis 2023. Conçu par les créateurs de Vue et de Vite, il combine performance extrême, DX exemplaire (HMR pour les tests, watch mode instantané) et compatibilité quasi-totale avec l'API Jest qui domine le marché depuis 2017.
- Angular 21 (nov. 2025) — Vitest officiellement supporté via
@angular/build:test - Angular 22 (juin 2026) — Vitest devient le runner par défaut pour les nouveaux projets
- Angular 23 (nov. 2026) — Suppression définitive de Karma
Gains attendus en migrant
- Vitesse : tests 4 à 10× plus rapides selon la taille du projet
- Watch mode instantané : modifier un fichier relance UNIQUEMENT les tests impactés
- Pas de navigateur : exécution dans Node.js + happy-dom, CI plus simple
- UI moderne :
vitest --uiouvre un dashboard interactif - Coverage v8 natif : plus rapide que istanbul, format LCOV standard
- Snapshot testing : inline, sérialisable, mis à jour via
vi update
Vitest vs Karma : benchmarks réels
Voici des chiffres mesurés sur un projet Angular réel : workspace Nx avec 320 composants, 850 tests unitaires, exécutés sur GitHub Actions (ubuntu-latest, 2 vCPU).
| Métrique | Karma + Chrome Headless | Vitest + happy-dom |
|---|---|---|
| Cold start (premier run) | 68 secondes | 14 secondes (5× plus rapide) |
| Test single (watch) | ~12 secondes | ~0,4 seconde (30× plus rapide) |
| Coverage report | 92 secondes | 18 secondes |
| Empreinte CI (RAM) | ~1,2 GB (Chrome) | ~280 MB |
| Lignes de config | ~80 (karma.conf.js) | ~20 (vite.config.ts) |
Installation de Vitest dans Angular
La migration commence par installer les bonnes dépendances et activer le builder officiel.
Étape 1 : installer Vitest et happy-dom
// Installer Vitest et son DOM simulé (happy-dom plus rapide que jsdom)
npm install --save-dev vitest happy-dom @analogjs/vitest-angular
// Pour Angular 21+, utiliser le builder officiel
ng update @angular/cli@21 @angular/core@21
// Optionnel : UI interactive pour suivre les tests
npm install --save-dev @vitest/ui @vitest/coverage-v8
Étape 2 : modifier angular.json
// angular.json — remplacer le builder Karma par Vitest
{
"projects": {
"my-app": {
"architect": {
"test": {
// ❌ ANCIEN — builder Karma
// "builder": "@angular-devkit/build-angular:karma",
// "options": { "karmaConfig": "karma.conf.js", "main": "src/test.ts" }
// ✅ NOUVEAU — builder Vitest officiel (Angular 21+)
"builder": "@angular/build:test",
"options": {
"tsConfig": "tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"],
"include": ["**/*.spec.ts"],
"runner": "vitest"
}
}
}
}
}
}
Étape 3 : supprimer les fichiers Karma
// Fichiers à supprimer après validation que la migration fonctionne
rm karma.conf.js
rm src/test.ts
// Désinstaller les packages obsolètes
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
Lancez maintenant ng test. Vos tests existants Jasmine devraient s'exécuter directement, beaucoup plus vite.
Configurer vite.config.ts pour Angular
Pour personnaliser Vitest (alias de paths, plugins, configuration globale), créez un fichier vite.config.ts à la racine.
// vite.config.ts — configuration complète Vitest pour Angular
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';
import path from 'node:path';
export default defineConfig({
plugins: [
// Plugin officiel Analog : compile les composants Angular pour Vitest
angular(),
],
test: {
// Variables globales (describe, it, expect) disponibles sans import
globals: true,
// Environnement DOM simulé — happy-dom 3× plus rapide que jsdom
environment: 'happy-dom',
// Fichier exécuté avant chaque suite de tests
setupFiles: ['src/test-setup.ts'],
// Inclusions / exclusions
include: ['src/**/*.spec.ts'],
exclude: ['node_modules', 'dist', 'e2e'],
// Mode parallèle — par défaut, threads = nombre de CPU
pool: 'threads',
// Coverage v8 (intégré V8 — pas besoin d'instrumentation)
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: ['**/*.spec.ts', '**/test-setup.ts', '**/*.module.ts'],
// Seuils d'échec — le CI fail si en dessous
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
},
// Reporter par défaut (verbose, html, json, junit)
reporters: process.env.CI ? ['default', 'junit'] : ['default', 'html'],
outputFile: { junit: './reports/junit.xml' },
},
resolve: {
alias: {
// Vos alias TypeScript path déjà déclarés dans tsconfig
'@app': path.resolve(__dirname, 'src/app'),
'@env': path.resolve(__dirname, 'src/environments'),
},
},
});
Setup file Angular
// src/test-setup.ts — initialisation TestBed une seule fois
import 'zone.js';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
// Initialise l'environnement de test Angular au démarrage de Vitest
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {
teardown: { destroyAfterEach: true }, // évite les fuites entre tests
errorOnUnknownElements: true, // détecte les composants non importés
errorOnUnknownProperties: true, // détecte les inputs inconnus
});
errorOnUnknownElements ? Karma laissait passer silencieusement <my-component> non importé en générant un warning console. Avec Vitest + Angular, ces erreurs deviennent bloquantes dès le lancement des tests. Beaucoup de bugs masqués remontent à la migration.
TestBed et composants standalone
L'API TestBed d'Angular est identique entre Karma et Vitest. Vos tests de composants, services, pipes et directives ne changent pas. Voici les patterns standards.
Test d'un composant standalone
// product-card.component.spec.ts — test composant standalone Angular 19+
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { ProductCardComponent } from './product-card.component';
import { By } from '@angular/platform-browser';
describe('ProductCardComponent', () => {
let fixture: ComponentFixture<ProductCardComponent>;
let component: ProductCardComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
// standalone : on importe directement le composant
imports: [ProductCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProductCardComponent);
component = fixture.componentInstance;
});
it('affiche le nom du produit', () => {
// Avec inputs signal-based, utiliser setInput au lieu de l'assignation directe
fixture.componentRef.setInput('name', 'Sneakers Nike');
fixture.componentRef.setInput('priceCents', 9990);
fixture.detectChanges();
const title = fixture.debugElement.query(By.css('h3'));
expect(title.nativeElement.textContent).toBe('Sneakers Nike');
});
it('émet "add" au clic du bouton', () => {
fixture.componentRef.setInput('name', 'X');
fixture.componentRef.setInput('priceCents', 100);
fixture.detectChanges();
// vi.fn() = équivalent jest.fn() — espion mocké
const handler = vi.fn();
component.add.subscribe(handler);
fixture.debugElement.query(By.css('button')).nativeElement.click();
expect(handler).toHaveBeenCalledOnce();
});
});
Test d'un service avec Signals
// cart.store.spec.ts — test service Angular avec signal()
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { CartStore } from './cart.store';
describe('CartStore', () => {
let store: CartStore;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [CartStore] });
store = TestBed.inject(CartStore);
});
it('ajoute un item au panier', () => {
store.add({ productId: 'p1', name: 'X', unitPriceCents: 100, quantity: 1 });
// Lecture d'un Signal : appeler la fonction
expect(store.cart().items.length).toBe(1);
expect(store.cart().items[0].productId).toBe('p1');
});
it('calcule le total avec computed()', () => {
store.add({ productId: 'p1', name: 'X', unitPriceCents: 200, quantity: 3 });
store.add({ productId: 'p2', name: 'Y', unitPriceCents: 100, quantity: 2 });
// computed() expose un Signal calculé — lu pareil
expect(store.totalCents()).toBe(800);
});
});
Mocking moderne avec vi.fn() et vi.mock()
Vitest fournit une API de mocking complète, calquée sur Jest. Quelques différences mineures à connaître par rapport aux jasmine.createSpy() que vous utilisiez avec Karma.
Mocker un service injecté
// product-page.component.spec.ts — mocker une dépendance HttpClient
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ProductPageComponent } from './product-page.component';
import { ProductApi } from './product.api';
describe('ProductPageComponent', () => {
// vi.fn() crée un mock contrôlable — équivalent jasmine.createSpy
const apiMock = {
searchAsCartItem: vi.fn().mockResolvedValue([
{ productId: 'p1', name: 'Mocked', unitPriceCents: 500, quantity: 1 },
]),
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProductPageComponent],
providers: [{ provide: ProductApi, useValue: apiMock }],
});
});
it('appelle searchAsCartItem au démarrage', async () => {
const fixture = TestBed.createComponent(ProductPageComponent);
await fixture.whenStable();
expect(apiMock.searchAsCartItem).toHaveBeenCalledOnce();
expect(fixture.componentInstance.products().length).toBe(1);
});
});
Mocker un module entier avec vi.mock()
// auth.guard.spec.ts — mocker complètement un module
import { vi, describe, it, expect, beforeEach } from 'vitest';
// vi.mock() remplace l'export du module pour TOUS les imports suivants
vi.mock('@angular/router', () => ({
Router: vi.fn(() => ({
navigate: vi.fn().mockResolvedValue(true),
navigateByUrl: vi.fn().mockResolvedValue(true),
})),
}));
describe('AuthGuard', () => {
// ... tests utilisant le Router mocké
});
Timers et delays maîtrisés
// debounce.spec.ts — contrôler le temps avec vi.useFakeTimers()
import { vi, describe, it, expect } from 'vitest';
import { debounce } from './debounce.util';
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('n\'appelle qu\'une fois après le délai', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced(); debounced(); debounced(); // 3 appels rapides
expect(fn).not.toHaveBeenCalled();
// Avance le temps de 300ms instantanément
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
});
});
jasmine.createSpy('name')→vi.fn()spyOn(obj, 'method')→vi.spyOn(obj, 'method')jasmine.clock().install()→vi.useFakeTimers()spy.and.returnValue(x)→spy.mockReturnValue(x)spy.and.callFake(fn)→spy.mockImplementation(fn)expect(spy).toHaveBeenCalledWith(x)→ identique
Coverage et intégration CI
Le coverage Vitest est plus rapide et plus précis que celui de Karma. Voici la configuration recommandée pour CI/CD.
Coverage local
// package.json — scripts utiles
{
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:ci": "vitest run --coverage --reporter=verbose --reporter=junit"
}
}
GitHub Actions workflow
# .github/workflows/test.yml
name: Tests Angular
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install
run: npm ci
- name: Run tests with coverage
run: npm run test:ci
# Coverage uploadé vers Codecov, Coveralls ou SonarCloud
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
flags: unit-tests
# JUnit report visible dans l'onglet Tests du PR
- name: Publish JUnit report
if: always()
uses: dorny/test-reporter@v1
with:
name: Vitest results
path: reports/junit.xml
reporter: java-junit
Forcer un seuil minimum
// vite.config.ts — bloquer le merge si coverage en dessous
coverage: {
provider: 'v8',
thresholds: {
lines: 80, branches: 75, functions: 80, statements: 80,
// Seuils par fichier individuels (utile pour les fichiers critiques)
'src/app/billing/**': {
lines: 95, branches: 90, functions: 95, statements: 95,
},
},
},
Pièges classiques et solutions
Voici les écueils les plus fréquents rencontrés pendant une migration Karma → Vitest, et leurs solutions éprouvées.
Piège 1 : tests qui dépendent de window ou document
// ❌ Test qui passe sur Chrome mais échoue dans happy-dom
it('lit la largeur de la fenêtre', () => {
expect(window.innerWidth).toBe(1024); // 0 dans happy-dom par défaut
});
// ✅ Solution : stubber explicitement window dans test-setup.ts
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true });
// Ou pour un test ponctuel
it('lit la largeur', () => {
vi.stubGlobal('innerWidth', 1024);
// ...
vi.unstubAllGlobals();
});
Piège 2 : fakeAsync et tick() Angular vs vi.useFakeTimers()
// ❌ Mélanger les deux casse les tests
import { fakeAsync, tick } from '@angular/core/testing';
import { vi } from 'vitest';
it('mélange dangereux', fakeAsync(() => {
vi.useFakeTimers(); // ❌ conflit avec fakeAsync
// ...
}));
// ✅ Choisir UN seul système par test
// Option A : fakeAsync/tick (recommandé pour code Angular avec Observable)
it('avec fakeAsync', fakeAsync(() => {
let v: number | undefined;
of(42).pipe(delay(1000)).subscribe(x => v = x);
tick(1000);
expect(v).toBe(42);
}));
// Option B : vi.useFakeTimers (pour code TypeScript pur)
it('avec vi.useFakeTimers', () => {
vi.useFakeTimers();
const fn = vi.fn();
setTimeout(fn, 1000);
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalled();
});
Piège 3 : zone.js obligatoire pour les composants Angular
// ❌ Oublier zone.js → erreur "ZoneAwareError is not defined"
// vite.config.ts incorrect
test: {
setupFiles: ['src/test-setup.ts'],
// Manque le polyfill zone.js !
}
// ✅ Toujours importer zone.js en premier dans test-setup.ts
// src/test-setup.ts
import 'zone.js';
import 'zone.js/testing'; // OBLIGATOIRE pour fakeAsync, async, etc.
// ... reste du setup
Piège 4 : performance dégradée si tests pas isolés
// ❌ État partagé entre tests (mémoire qui gonfle)
let cachedComponent; // état module-level
describe('Foo', () => {
it('A', () => { cachedComponent = ...; });
it('B', () => { /* utilise cachedComponent */ });
});
// ✅ Toujours réinitialiser dans beforeEach
describe('Foo', () => {
let component;
beforeEach(() => { component = createFresh(); });
afterEach(() => { component = null; }); // libère la mémoire
// ...
});
pool: 'forks' au lieu de 'threads' si vous voyez des fuites mémoire entre tests. Légèrement plus lent mais isolation totale.
Conclusion et prochaines étapes
Migrer de Karma vers Vitest n'est plus une option — c'est une obligation à court terme avec la suppression de Karma annoncée pour Angular 23 (novembre 2026). La bonne nouvelle : la migration est largement automatisée, l'API TestBed ne change pas, et le retour sur investissement est immédiat (tests 5 à 30× plus rapides selon les cas).
Plan d'action recommandé :
- Mettre à jour vers Angular 21+ pour bénéficier du builder officiel
- Migrer un seul projet pilote sur 1 sprint pour mesurer les gains
- Définir un setup-file standard avec zone.js et TestBed init
- Ajouter les seuils de coverage au CI pour empêcher la régression
- Former l'équipe aux équivalences Jasmine → Vitest
- Étendre aux autres projets du monorepo une fois validé