Front-end angularforall.com

- Migrer Karma vers Vitest : guide Angular

Angular Vitest Karma Migration Jasmine Happy-Dom Tests-Unitaires Testbed Angular-21 Ci-Cd Coverage Fake-Timers Mocking Testing
Migrer Karma vers Vitest : guide Angular

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.

Calendrier officiel Angular :
  • 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 --ui ouvre 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)
À nuancer : les tests qui dépendent du moteur de rendu réel (mesures de layout, scrolling, certains comportements WebGL) doivent rester en e2e (Playwright/Cypress). Vitest cible les tests unitaires et composants, pas les tests d'intégration browser.

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
});
Pourquoi 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();
    });
});
Équivalences Jasmine → Vitest :
  • 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
    // ...
});
Tip CI : activez 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é :

  1. Mettre à jour vers Angular 21+ pour bénéficier du builder officiel
  2. Migrer un seul projet pilote sur 1 sprint pour mesurer les gains
  3. Définir un setup-file standard avec zone.js et TestBed init
  4. Ajouter les seuils de coverage au CI pour empêcher la régression
  5. Former l'équipe aux équivalences Jasmine → Vitest
  6. Étendre aux autres projets du monorepo une fois validé
Pour aller plus loin : intégrez Vitest avec votre stratégie e2e (Playwright recommandé) pour couvrir 100% des couches de tests. Vitest pour les unitaires + composants, Playwright pour les flux utilisateur complets. Cette combinaison est le standard 2026.

Partager