Front-end angularforall.com

- Tester une app Angular avec Vitest

Angular Vitest Testing Analogjs Karma-Deprecated Jasmine-Migration Testbed Signal-Testing Http-Testing Vi-Mock Coverage-V8 Ci-Cd
Tester une app Angular avec Vitest

Migrez Karma vers Vitest sur Angular 17+ : setup analogjs, tester Signals, HttpClientTesting, mocks vi.fn, coverage V8 et CI GitHub Actions.

Pourquoi migrer Karma vers Vitest en 2026 ?

Karma a accompagné Angular pendant plus de dix ans. Mais depuis Angular 16, le builder Karma est officiellement déprécié. Il continue de fonctionner, mais n'est plus activement développé et sera retiré du CLI dans une version majeure à venir. L'équipe Angular pousse aujourd'hui deux alternatives officielles : Web Test Runner (en bêta) et Vitest via @analogjs/vitest-angular (stable, maintenu).

Vitest est le choix communautaire qui s'impose. Sur une suite réaliste de 800 tests d'une application Angular de production, on observe un passage de 95 secondes (Karma + Chrome Headless) à 4 secondes (Vitest + happy-dom + parallélisme natif). Le watch mode démarre instantanément et ne re-exécute que les tests impactés par le diff. La barrière à l'entrée est faible : l'API Jest-compatible signifie qu'un développeur venant d'un projet React/Vue est productif en minutes.

Le contexte historique en bref

Karma a été créé en 2012 pour exécuter des tests JavaScript dans un vrai navigateur. C'était la bonne approche pour l'époque — Node.js ne fournissait pas encore d'environnement DOM crédible, et la diversité des moteurs JavaScript imposait des tests cross-browser. Quinze ans plus tard, le paysage a complètement changé : jsdom et happy-dom émulent fidèlement le DOM standard, V8 et SpiderMonkey ont convergé sur ECMAScript, et la majorité des bugs cross-browser modernes concernent le CSS, pas le JavaScript. Le coût de lancer Chrome pour chaque suite de tests n'est plus justifié dans 90 % des cas.

Trois raisons concrètes de migrer maintenant

  1. Vitesse de feedback — passer de 90s à 4s change le rapport à l'écriture des tests : on en écrit plus, on les lance plus souvent, on garde la suite verte plus longtemps.
  2. Outillage moderne — UI web (vitest --ui), HMR, snapshots, watch intelligent, coverage V8 native, support TypeScript natif sans Babel ni ts-jest.
  3. Avenir d'Angular — l'équipe Angular elle-même teste son code interne avec Bazel/Web Test Runner ; la communauté converge vers Vitest. Karma n'a plus d'horizon.
À retenir : migrer aujourd'hui prend une demi-journée sur un projet moyen, contre plusieurs jours forcés quand Karma sera supprimé. L'investissement est immédiatement rentable grâce au watch mode.

Cet article suit un projet Angular 19+ standard, généré avec ng new ou Nx. Tous les exemples utilisent les conventions modernes : composants Standalone, Signal inputs/outputs, contrôle de flux @if/@for, et providers fonctionnels (provideHttpClient, provideRouter). Si votre projet est plus ancien, la majorité de l'article reste valable — les changements concernent principalement les helpers de TestBed (anciens modules vs nouveaux providers).

Vitest vs Karma/Jasmine — comparaison chiffrée

CritèreKarma + JasmineVitest + Analog
Vitesse 800 tests (cold)~95 s~4 s
Watch mode (1 fichier modifié)~30 s~200 ms
Parallélisation nativeNon (1 navigateur)Oui (workers)
API testsJasmineJest-compatible
Environnement DOMChrome Headless réelhappy-dom / jsdom
CoverageIstanbul (lent)V8 natif (rapide)
UI webvitest --ui
SnapshotsPlugins externesNatif
Mock auto modulesManuel via providersvi.mock() natif
Support officiel AngularDéprécié depuis v16Builder @analogjs stable

Quand Vitest n'est PAS le bon choix

Deux cas où vous gardez Karma temporairement. Premièrement, une application qui dépend lourdement d'API navigateur non émulées par happy-dom (Canvas avancé, WebGL, IndexedDB complexe) — un vrai navigateur reste plus fiable. Deuxièmement, une application qui vise un haut niveau de couverture cross-browser avec Sauce Labs ou BrowserStack — ces intégrations existent en Karma de longue date. Dans tous les autres cas (90 % des projets), Vitest est supérieur.

Et Jest dans tout ça ?

Jest est resté longtemps l'alternative principale à Karma, et de nombreux projets Angular ont migré vers jest-preset-angular. Pourquoi alors préférer Vitest aujourd'hui ? Trois raisons. La compatibilité TypeScript de Vitest est native, sans transformation Babel ni configuration ts-jest coûteuse. Le watch mode est radicalement plus rapide grâce à l'écosystème Vite (HMR, esbuild, parallélisme). Et surtout, l'API vi.* est identique à jest.* — un projet Jest se migre vers Vitest avec un simple codemod, sans réécrire les specs. Quand un nouveau projet doit choisir, Vitest est le défaut moderne ; quand un projet Jest existant tourne, la migration reste une option à coût modéré.

Setup complet pas-à-pas

1. Installation des dépendances

# Stack Vitest + plugin Angular officiel d'Analog
npm install -D vitest @analogjs/vitest-angular @analogjs/platform \
               @vitest/coverage-v8 @vitest/ui \
               jsdom happy-dom

2. Configuration vitest.config.ts

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';

export default defineConfig(({ mode }) => ({
  plugins: [angular()],
  test: {
    globals: true,                       // describe/it/expect sans import
    environment: 'jsdom',                // ou 'happy-dom' (2x plus rapide)
    setupFiles: ['src/test-setup.ts'],
    include: ['src/**/*.spec.ts'],
    reporters: ['default'],
    cache: { dir: './node_modules/.vitest' },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 },
      exclude: [
        '**/*.module.ts',
        '**/main.ts',
        '**/environment*.ts',
        '**/index.ts',
      ],
    },
  },
  define: { 'import.meta.vitest': mode !== 'production' },
}));

3. Fichier src/test-setup.ts

// src/test-setup.ts — patche zone.js et initialise TestBed
import '@analogjs/vitest-angular/setup-snapshots';
import 'zone.js';
import 'zone.js/testing';

import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting(),
  { teardown: { destroyAfterEach: true } },
);

4. tsconfig.spec.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["vitest/globals", "node"]
  },
  "files": ["src/test-setup.ts"],
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

5. Builder dans angular.json

"test": {
  "builder": "@analogjs/vitest-angular:test",
  "options": {
    "config": "vitest.config.ts",
    "tsConfig": "tsconfig.spec.json"
  }
}

6. Scripts package.json

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}
Astuce performance : happy-dom est environ deux fois plus rapide que jsdom et couvre 95 % des besoins Angular. Conservez jsdom si vous testez du code qui utilise localStorage, FormData ou des APIs DOM avancées non implémentées par happy-dom.

Tester un composant Standalone avec Signal inputs

Le composant

// counter.component.ts
import { Component, input, output, signal, computed } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <button (click)="dec()" [disabled]="value() === min()">-</button>
    <span data-testid="value">{{ value() }}</span>
    <button (click)="inc()">+</button>
  `,
})
export class CounterComponent {
  readonly initial = input(0);
  readonly min = input(0);
  readonly changed = output<number>();

  protected readonly value = signal(0);

  constructor() {
    // Reset interne quand initial change
    this.value.set(this.initial());
  }

  inc() { this.value.update(v => v + 1); this.changed.emit(this.value()); }
  dec() { this.value.update(v => v - 1); this.changed.emit(this.value()); }
}

Le spec

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

describe('CounterComponent', () => {
  let fixture: ComponentFixture<CounterComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(CounterComponent);
    fixture.componentRef.setInput('initial', 5);
    fixture.componentRef.setInput('min', 0);
    fixture.detectChanges();
  });

  it('rend la valeur initiale', () => {
    const span: HTMLElement = fixture.nativeElement.querySelector(
      '[data-testid="value"]',
    );
    expect(span.textContent?.trim()).toBe('5');
  });

  it('incrémente au clic sur le bouton +', () => {
    const inc = fixture.nativeElement.querySelectorAll('button')[1];
    inc.click();
    fixture.detectChanges();

    const span = fixture.nativeElement.querySelector('[data-testid="value"]');
    expect(span.textContent?.trim()).toBe('6');
  });

  it('désactive le bouton - quand value === min', () => {
    fixture.componentRef.setInput('initial', 0);
    // Recrée le fixture pour que initial soit pris en compte au constructor
    fixture = TestBed.createComponent(CounterComponent);
    fixture.componentRef.setInput('initial', 0);
    fixture.detectChanges();

    const dec = fixture.nativeElement.querySelector('button');
    expect(dec.disabled).toBe(true);
  });

  it('émet changed à chaque interaction', () => {
    const handler = vi.fn();
    fixture.componentInstance.changed.subscribe(handler);

    const inc = fixture.nativeElement.querySelectorAll('button')[1];
    inc.click(); inc.click();

    expect(handler).toHaveBeenCalledTimes(2);
    expect(handler).toHaveBeenLastCalledWith(7);
  });
});

Points clés : fixture.componentRef.setInput() est l'API officielle pour fournir un signal input dans un test. Après chaque action qui change l'état, un fixture.detectChanges() est nécessaire pour propager la mise à jour vers le DOM — c'est identique à Karma/Jasmine.

Attribut data-testid plutôt que sélecteurs CSS

Notez l'attribut data-testid="value" dans le template du composant. C'est une bonne pratique éprouvée par Kent C. Dodds et la communauté Testing Library : un attribut dédié aux tests reste stable même si on renomme les classes CSS, on change le tag HTML, ou on refactorise la structure du template. À l'inverse, querySelector('.counter-value') casse dès qu'un designer remplace counter-value par amount. Préfixer par data-testid rend l'intention explicite : ce sélecteur sert aux tests, pas au style. Beaucoup d'équipes l'inscrivent dans leur ESLint avec une règle qui interdit les sélecteurs CSS dans les fichiers .spec.ts — c'est un investissement rentable dès quelques dizaines de tests.

Tester un service HTTP avec provideHttpClientTesting

Depuis Angular 18, HttpClientTestingModule est déprécié au profit des fonctions provideHttpClient() et provideHttpClientTesting(). C'est aligné avec la migration générale des modules vers les providers fonctionnels.

// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import {
  HttpTestingController,
  provideHttpClientTesting,
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { describe, it, expect, beforeEach, afterEach } 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);
  });

  afterEach(() => httpMock.verify());

  it('GET /api/users/:id', () => {
    const mock = { id: 1, name: 'Alice' };

    service.getUser(1).subscribe(u => {
      expect(u).toEqual(mock);
    });

    const req = httpMock.expectOne('/api/users/1');
    expect(req.request.method).toBe('GET');
    req.flush(mock);
  });

  it('remonte une 500 comme erreur Observable', () => {
    let caught: Error | null = null;
    service.getUser(1).subscribe({
      next: () => expect.fail('ne devrait pas réussir'),
      error: e => { caught = e; },
    });

    const req = httpMock.expectOne('/api/users/1');
    req.flush('Server error', { status: 500, statusText: 'Internal' });

    expect(caught).not.toBeNull();
  });
});

Tester un interceptor

// auth.interceptor.spec.ts
it('ajoute le header Authorization sur les requêtes /api', () => {
  TestBed.configureTestingModule({
    providers: [
      provideHttpClient(withInterceptors([authInterceptor])),
      provideHttpClientTesting(),
      { provide: AuthService, useValue: { token: () => 'abc123' } },
    ],
  });
  const http = TestBed.inject(HttpClient);
  const mock = TestBed.inject(HttpTestingController);

  http.get('/api/me').subscribe();
  const req = mock.expectOne('/api/me');

  expect(req.request.headers.get('Authorization')).toBe('Bearer abc123');
  req.flush({});
});

Tester un Signal Store et computed()

Les Signal et leurs computed sont synchrones et déterministes — c'est l'un des plus grands cadeaux d'Angular 17+ pour les tests. Plus besoin de fakeAsync ni de tick().

// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';

interface Item { id: string; price: number; qty: number; }

@Injectable({ providedIn: 'root' })
export class CartService {
  readonly items = signal<Item[]>([]);
  readonly total = computed(() =>
    this.items().reduce((s, i) => s + i.price * i.qty, 0),
  );
  readonly count = computed(() =>
    this.items().reduce((s, i) => s + i.qty, 0),
  );

  add(item: Item) { this.items.update(a => [...a, item]); }
  remove(id: string) { this.items.update(a => a.filter(i => i.id !== id)); }
  clear() { this.items.set([]); }
}
// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { CartService } from './cart.service';

describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [CartService] });
    service = TestBed.inject(CartService);
  });

  it('total et count valent 0 au démarrage', () => {
    expect(service.total()).toBe(0);
    expect(service.count()).toBe(0);
  });

  it('total se met à jour quand on ajoute un item', () => {
    service.add({ id: 'a', price: 10, qty: 2 });
    expect(service.total()).toBe(20);
    expect(service.count()).toBe(2);
  });

  it('remove enlève l'item correspondant', () => {
    service.add({ id: 'a', price: 10, qty: 1 });
    service.add({ id: 'b', price: 20, qty: 1 });
    service.remove('a');
    expect(service.items()).toEqual([{ id: 'b', price: 20, qty: 1 }]);
  });
});

Aucun fixture.detectChanges() — les computed sont recalculés instantanément à chaque lecture. C'est environ 3 fois plus rapide à écrire et à exécuter que l'équivalent BehaviorSubject + combineLatest.

Tester un effect()

Pour tester un effect() qui dépend d'un signal, il faut le créer dans un injection context. TestBed.runInInjectionContext() est l'outil officiel, et la propagation de l'effect se déclenche au prochain microtask — un await Promise.resolve() ou un tick() rend le test déterministe.

// notification.service.spec.ts
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { effect, signal } from '@angular/core';
import { describe, it, expect, vi } from 'vitest';

describe('effect autoSave', () => {
  it('déclenche une sauvegarde quand le draft change', fakeAsync(() => {
    const draft = signal({ title: '' });
    const save = vi.fn();

    TestBed.runInInjectionContext(() => {
      effect(() => save(draft()));
    });

    tick(); // premier flush — effect initial
    expect(save).toHaveBeenCalledOnce();

    draft.set({ title: 'Hello' });
    tick();
    expect(save).toHaveBeenCalledTimes(2);
    expect(save).toHaveBeenLastCalledWith({ title: 'Hello' });
  }));
});

Tester du code asynchrone et RxJS

Quatre approches existent pour tester de l'async, chacune adaptée à un contexte. Choisir la bonne réduit le bruit dans vos tests et améliore la lisibilité.

1. async/await + firstValueFrom

import { firstValueFrom, of, delay } from 'rxjs';

it('récupère la valeur via firstValueFrom', async () => {
  const result = await firstValueFrom(of(42).pipe(delay(10)));
  expect(result).toBe(42);
});

2. fakeAsync + tick — temps virtuel

import { fakeAsync, tick } from '@angular/core/testing';
import { timer } from 'rxjs';

it('timer 1000 ms', fakeAsync(() => {
  let value: number | undefined;
  timer(1000).subscribe(v => { value = v; });

  expect(value).toBeUndefined();
  tick(999); expect(value).toBeUndefined();
  tick(1); expect(value).toBe(0);
}));

3. vi.useFakeTimers — alternative Vitest pure

it('debounce avec vi.useFakeTimers', () => {
  vi.useFakeTimers();
  const cb = vi.fn();
  const debounced = debounce(cb, 300);

  debounced(); debounced(); debounced();
  expect(cb).not.toHaveBeenCalled();

  vi.advanceTimersByTime(300);
  expect(cb).toHaveBeenCalledTimes(1);

  vi.useRealTimers();
});

4. TestScheduler — marble testing

import { TestScheduler } from 'rxjs/testing';

it('debounceTime via marbles', () => {
  const scheduler = new TestScheduler((actual, expected) =>
    expect(actual).toEqual(expected),
  );

  scheduler.run(({ cold, expectObservable }) => {
    const source$  = cold(' -a-b-c----d|');
    const expect$  = '        ----------c-|';
    expectObservable(source$.pipe(debounceTime(4, scheduler))).toBe(expect$);
  });
});

Mocks et espions avec vi.fn, vi.mock, vi.spyOn

1. vi.fn — mock simple

const onSubmit = vi.fn();
component.submit(onSubmit);
expect(onSubmit).toHaveBeenCalledOnce();
expect(onSubmit).toHaveBeenCalledWith({ valid: true });

2. vi.spyOn — espionner sans remplacer

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
service.logError('boom');
expect(logSpy).toHaveBeenCalledWith('[ERR]', 'boom');
logSpy.mockRestore();

3. vi.mock — auto-mock d'un module entier

// En haut du spec, hoisté avant les imports par Vitest
vi.mock('./analytics.service', () => ({
  AnalyticsService: vi.fn(() => ({
    track: vi.fn(),
    page:  vi.fn(),
  })),
}));

4. Mock via TestBed providers — la voie Angular

TestBed.configureTestingModule({
  providers: [
    {
      provide: AuthService,
      useValue: {
        token: signal('mock-token'),
        login: vi.fn().mockResolvedValue({ ok: true }),
      },
    },
  ],
});
Quand utiliser quoi : vi.fn() pour les callbacks isolés ; vi.spyOn() quand vous voulez vérifier qu'une méthode existante est appelée sans changer son comportement ; vi.mock() pour remplacer un import entier (analytics, logger, services non testés) ; TestBed providers pour les dépendances Angular injectées.

Tester directive et pipe custom

Test d'une directive avec composant host

// highlight.directive.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect } from 'vitest';
import { HighlightDirective } from './highlight.directive';

@Component({
  standalone: true,
  imports: [HighlightDirective],
  template: `<p [appHighlight]="color">Hello</p>`,
})
class HostComponent { color = '#ff0'; }

describe('HighlightDirective', () => {
  it('applique la couleur au mouseenter', () => {
    const fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();

    const p: HTMLElement = fixture.debugElement.query(By.css('p')).nativeElement;
    p.dispatchEvent(new MouseEvent('mouseenter'));
    fixture.detectChanges();

    expect(p.style.background).toBe('rgb(255, 255, 0)');
  });
});

Test d'un pipe pur

// truncate.pipe.spec.ts
import { describe, it, expect } from 'vitest';
import { TruncatePipe } from './truncate.pipe';

describe('TruncatePipe', () => {
  const pipe = new TruncatePipe();

  it('ne tronque pas si longueur < limite', () => {
    expect(pipe.transform('hi', 10)).toBe('hi');
  });

  it('ajoute … quand on dépasse', () => {
    expect(pipe.transform('abcdefghij', 5)).toBe('abcde…');
  });
});

Les pipes purs sont les composants les plus simples à tester : ils n'ont pas besoin de TestBed, juste un new Pipe() et un appel à transform(). Ils sont parfaits pour viser une couverture haute sans effort.

Coverage V8, seuils et exclusions

Générer et lire le rapport

npm run test:coverage
# Génère :
#   coverage/index.html         ← rapport visuel (browse it!)
#   coverage/lcov.info          ← pour SonarQube / Codecov
#   coverage-final.json         ← rapport brut

Configuration recommandée

coverage: {
  provider: 'v8',
  all: true, // inclut les fichiers non testés dans le rapport
  reporter: ['text', 'html', 'lcov'],
  thresholds: {
    lines:      80,
    functions:  80,
    branches:   70,
    statements: 80,
    autoUpdate: false, // ne pas augmenter automatiquement les seuils
  },
  exclude: [
    '**/*.module.ts',        // modules : pas de logique
    '**/main.ts',            // bootstrap
    '**/environment*.ts',    // config statique
    '**/index.ts',           // barrels
    '**/*.d.ts',             // types only
    '**/test-setup.ts',
    '**/*.spec.ts',
  ],
}

Objectifs réalistes par type de code

  • Services et stores (logique métier) : 90 % — c'est là que la valeur est.
  • Validators et pipes : 100 % — fonctions pures, faciles à couvrir.
  • Directives : 80 % — un host component suffit.
  • Composants présentationnels : 60-70 % — tester les interactions principales, pas chaque ligne de template.
  • Routing config : couverture inutile, exclure.
Anti-pattern : viser 100 % de coverage. Le code devient pollué de tests sans valeur (« il appelle setState ») et la suite ralentit. Mieux vaut 80 % avec des tests significatifs que 100 % avec des tests qui reproduisent l'implémentation.

Intégration CI/CD GitHub Actions

# .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: npm run test:coverage

      # Upload du rapport pour visualiser dans l'UI GitHub
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/

      # Push vers Codecov pour les badges et l'analyse par PR
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info

Le job tourne en moins de 30 secondes sur un projet de taille moyenne avec 500-800 tests, là où une suite équivalente sous Karma demandait 3 à 5 minutes par run de CI. Cette accélération transforme l'expérience des Pull Requests : les checks redeviennent rapides, les développeurs ne procrastinent plus en lançant les tests, et le bottleneck typique des grosses équipes (queue CI saturée) disparaît.

Sharder une grosse suite de tests

Pour les projets > 2000 tests, Vitest sait diviser la suite en N runs parallèles dans des jobs GitHub Actions distincts via l'option --shard.

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx vitest run --shard=${{ matrix.shard }}/4

Avec 4 shards, une suite de 90 s passe à environ 25 s en CI — sans coût supplémentaire si vos runners sont déjà mutualisés.

Pièges et bonnes pratiques

À faire
  • Utiliser data-testid="..." dans le template plutôt que des sélecteurs CSS fragiles. Le refactor de classes ne casse pas les tests.
  • Préférer fixture.componentRef.setInput() à l'affectation directe d'un signal — c'est l'API officielle et type-safe.
  • Tester les comportements visibles, pas l'implémentation interne. Un test ne doit pas connaître la liste des méthodes privées.
  • Garder un test sous 100 ms en moyenne. Au-dessus, vous mélangez probablement plusieurs scénarios — découpez.
  • Nommer les tests en « should X when Y ». La phrase doit décrire l'effet visible pour l'utilisateur.
À éviter
  • any dans les vi.fn<...>() — vous perdez les vérifications de signatures que Vitest fournit gratuitement.
  • Tester chaque get et set d'une classe — c'est tester le langage, pas votre logique.
  • Mocker tout — un test qui mocke 8 dépendances ne teste plus rien d'utile. Mockez le strict nécessaire.
  • Laisser des fdescribe, fit, .only en main — configurez ESLint vitest/no-focused-tests.
  • Brancher la suite sur un vrai backend — un test cassé doit pointer le code Angular, pas l'API en panne.

Conclusion

Vitest n'est plus un choix alternatif ; c'est la voie officielle pour tester Angular en 2026. Karma reste fonctionnel pour quelques projets legacy mais sera retiré du CLI dans une version majeure proche. La migration prend une demi-journée sur un projet moyen — installation des paquets, deux fichiers de config, un setup de zone, et un changement de builder dans angular.json. Les specs Jasmine existants tournent sans modification, et le gain immédiat en vitesse change le rapport quotidien à l'écriture des tests.

Combiné aux Signals d'Angular 17+, à provideHttpClientTesting(), et à l'API fixture.componentRef.setInput(), l'écriture d'un test devient plus simple, plus rapide, et plus déterministe. Les computed et signal remplacent les BehaviorSubject et combineLatest dans 80 % des cas — vous écrivez moins de fakeAsync, moins de mocks, et vos tests reflètent mieux le comportement réel de l'application. C'est l'un des investissements à plus fort ROI sur une codebase Angular vivante.

Ne sous-estimez pas l'effet sur la culture d'équipe : quand le watch mode tourne en moins d'une seconde, les développeurs lancent les tests entre chaque modification, repèrent les régressions instantanément, et écrivent plus de tests parce que c'est devenu un plaisir plutôt qu'une corvée. Au bout de quelques semaines, la suite verte n'est plus une exception mais la norme — et c'est exactement ce qui distingue les équipes qui livrent vite et bien des autres. Si vous hésitez encore, créez une branche, faites la migration en isolation, mesurez le temps de feedback, et soumettez la PR : les chiffres parleront d'eux-mêmes.

Récapitulatif des bonnes pratiques :
  • Migrer vers Vitest + @analogjs/vitest-angular dès aujourd'hui
  • Utiliser happy-dom par défaut pour la vitesse, jsdom si APIs DOM complètes nécessaires
  • Adopter provideHttpClient() + provideHttpClientTesting() à la place de HttpClientTestingModule
  • Tester les Signals directement, sans fakeAsync — ils sont synchrones
  • Utiliser fixture.componentRef.setInput() pour les signal inputs
  • Ajouter des data-testid sur les éléments testés pour éviter les sélecteurs CSS fragiles
  • Configurer des seuils de coverage par type (80 % services, 60-70 % composants)
  • Exclure modules, main.ts, environments et barrels de la coverage
  • Mocker au strict minimum — au-delà de 3 mocks par test, repenser la conception
  • Sharder la suite avec --shard en CI dès 2000+ tests

Partager