11.2 — Tests
🎯 Objectif : tester ce qui compte, sans transformer ta CI en marathon. La promesse — tu refactors en confiance, tu déploies sans angoisse.
À l'issue de cet axe, tu sauras :
- Comprendre la pyramide de tests et ses anti-patterns (cône de glace)
- Écrire des tests unitaires Vitest, Pest ou pytest
- Mocker proprement, ou éviter les mocks quand c'est mieux
- Tester une API avec Testcontainers ou DB en mémoire
- Écrire des tests E2E avec Playwright sur les parcours critiques
- Mesurer la couverture sans en faire une fin en soi
Confirmé
Pourquoi tester ?
Section intitulée « Pourquoi tester ? »3 bénéfices concrets :
- Refactor sans peur — tu changes 200 lignes, les tests confirment que tu n’as rien cassé.
- Doc vivante — un test bien nommé est un exemple d’usage à jour.
- Design feedback — du code dur à tester est souvent du code mal architecturé.
Les tests ne sont pas une assurance contre tous les bugs. Ils sont un filet qui rattrape les régressions évidentes.
La pyramide de tests
Section intitulée « La pyramide de tests »flowchart TD
E2E[E2E<br/>~5%<br/>Playwright/Cypress<br/>parcours critiques]
Int[Intégration<br/>~15-20%<br/>API + DB éphémère]
Unit[Unitaires<br/>~75-80%<br/>Vitest, Pest, pytest]
Unit --> Int --> E2E Pourquoi cette répartition
Section intitulée « Pourquoi cette répartition »| Unitaires | Intégration | E2E | |
|---|---|---|---|
| Vitesse | < 1 ms | 50-500 ms | 1-10 s |
| Coût d’écriture | Faible | Moyen | Élevé |
| Coût de maintenance | Faible | Moyen | Très élevé |
| Couvre quoi | Logique pure | Modules + DB | Parcours utilisateur |
| Quand échoue | Bug logique | Bug d’intégration | Bug bout-en-bout |
L’anti-pattern : le cône de glace
Section intitulée « L’anti-pattern : le cône de glace »flowchart TD
E2E[E2E<br/>50%<br/>SLOW]
Int[Intégration<br/>30%]
Unit[Unitaires<br/>20%]
Unit --> Int --> E2E Beaucoup d’équipes y arrivent par dérive : “on teste partout en E2E parce que c’est plus rassurant”. Résultat :
- CI 30 min au lieu de 3 min.
- Tests flaky (qui échouent au hasard à cause de timeouts).
- Personne ne lance les tests en local.
Tests unitaires — Vitest
Section intitulée « Tests unitaires — Vitest »npm install -D vitest @vitest/coverage-v8import { describe, it, expect } from 'vitest';import { add, divide } from '../src/calculator';
describe('add', () => { it('additionne deux entiers positifs', () => { expect(add(2, 3)).toBe(5); });
it('additionne avec zéro', () => { expect(add(5, 0)).toBe(5); });
it('additionne deux négatifs', () => { expect(add(-1, -2)).toBe(-3); });});
describe('divide', () => { it('lève une erreur si on divise par zéro', () => { expect(() => divide(10, 0)).toThrow('Cannot divide by zero'); });});npx vitest run # une foisnpx vitest # mode watchnpx vitest --coverage # avec couvertureAAA — Arrange, Act, Assert
Section intitulée « AAA — Arrange, Act, Assert »it('applique le discount sur le total', () => { // Arrange const items = [{ price: 100 }, { price: 50 }]; const code = 'SUMMER10';
// Act const total = computeTotal(items, code);
// Assert expect(total).toBe(135); // 150 * 0.9});Lisibilité maximale. Si une étape devient trop longue, c’est souvent qu’il faut extraire une helper ou simplifier l’API.
Tester un module qui dépend d’autres modules
Section intitulée « Tester un module qui dépend d’autres modules »export class OrderService { constructor(private db: OrderRepo, private mailer: Mailer) {}
async place(order: Order) { const saved = await this.db.save(order); await this.mailer.send('order-placed', saved); return saved; }}// Testimport { describe, it, expect, vi } from 'vitest';import { OrderService } from '../src/order.service';
it('appelle le mailer après avoir sauvegardé', async () => { const fakeDb = { save: vi.fn().mockResolvedValue({ id: 1 }) }; const fakeMailer = { send: vi.fn() };
const service = new OrderService(fakeDb, fakeMailer); await service.place({ amount: 100 });
expect(fakeDb.save).toHaveBeenCalledOnce(); expect(fakeMailer.send).toHaveBeenCalledWith('order-placed', { id: 1 });});vi.fn() crée un mock dont tu peux vérifier les appels.
Mocks — utiliser avec parcimonie
Section intitulée « Mocks — utiliser avec parcimonie »MSW — mocker les appels HTTP au niveau réseau
Section intitulée « MSW — mocker les appels HTTP au niveau réseau »Mock Service Worker est devenu le standard 2024+ pour tester un front qui consomme une API. Au lieu de mocker fetch à coups de vi.mock(), MSW intercepte les requêtes au niveau Service Worker (en navigateur) ou request handler (en Node) et renvoie tes réponses fixées.
Pourquoi c’est mieux que vi.mock('axios') :
| Approche | Problème | MSW |
|---|---|---|
vi.mock('axios') | Tu testes ton wrapper, pas le vrai chemin réseau | Tu testes le code identique à la prod |
vi.spyOn(global, 'fetch') | Boilerplate par test, fragile | Handlers déclarés une fois, réutilisables |
| Tests E2E avec vraie API | Lents, instables | MSW reste rapide (millisecondes) |
Setup minimal (Vitest + jsdom)
Section intitulée « Setup minimal (Vitest + jsdom) »import { http, HttpResponse } from 'msw';
export const handlers = [ http.get('/api/users/:id', ({ params }) => { return HttpResponse.json({ id: params.id, name: 'Alice', email: 'alice@example.com', }); }),
http.post('/api/users', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ id: '42', ...body }, { status: 201 }); }),
http.get('/api/error', () => HttpResponse.error()), // simule une erreur réseau];import { setupServer } from 'msw/node';import { handlers } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => server.resetHandlers());afterAll(() => server.close());export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'], },});Override par test pour les cas spéciaux
Section intitulée « Override par test pour les cas spéciaux »import { http, HttpResponse } from 'msw';import { server } from './setup';
it("affiche l'état d'erreur quand l'API retourne 500", async () => { server.use( http.get('/api/users/:id', () => { return new HttpResponse(null, { status: 500 }); }) );
render(<UserCard id="42" />); expect(await screen.findByText(/erreur/i)).toBeInTheDocument();});server.use() ajoute un handler qui prend le pas sur celui par défaut, le temps du test. resetHandlers() après chaque test remet à zéro.
Bonus : MSW marche aussi en dev navigateur
Section intitulée « Bonus : MSW marche aussi en dev navigateur »import { setupWorker } from 'msw/browser';import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
if (import.meta.env.DEV) await worker.start();Tu lances ton front sans backend → MSW intercepte les fetch et renvoie tes mocks. Idéal pour développer une feature avant que l’API correspondante existe.
Snapshot testing — figer un output complexe
Section intitulée « Snapshot testing — figer un output complexe »Quand tu testes un objet/string/composant qui a beaucoup de champs, écrire 30 expect(x.foo).toBe(...) est pénible. Snapshot testing capture l’output entier, le compare au prochain run.
import { expect, it } from 'vitest';import { renderToString } from 'react-dom/server';import { Button } from './Button';
it('Button rendu HTML stable', () => { const html = renderToString(<Button label="Click" />); expect(html).toMatchInlineSnapshot(); // Au 1er run, Vitest insère le snapshot dans le test : // expect(html).toMatchInlineSnapshot(`"<button class=\\"btn\\">Click</button>"`); // Aux runs suivants, il compare l'output actuel à ce snapshot.});Inline vs fichier séparé
Section intitulée « Inline vs fichier séparé »expect(value).toMatchSnapshot(); // → fichier `.snap` séparé (gros snapshots)expect(value).toMatchInlineSnapshot(); // → inscrit dans le test (petits snapshots)Quand c’est utile / quand c’est dangereux
Section intitulée « Quand c’est utile / quand c’est dangereux »| ✅ Bon usage | ❌ Mauvais usage |
|---|---|
| Output SQL compilé par un ORM (vérifier que tu produis la bonne query) | Composant React entier (snapshot vide de sens, casse à chaque refacto CSS) |
| Sortie d’un parser, AST, IR | Réponse API instable (timestamps, IDs) |
| Format de fichier généré (Markdown, YAML) | Output qui dépend de la locale système |
Anti-pattern : commit aveuglément un nouveau snapshot quand le test casse (« vert = OK »). Tu perds toute la valeur — relire et comprendre pourquoi le diff est apparu avant de mettre à jour.
Pour les tests visuels (composants UI), préfère Playwright toHaveScreenshot() (visual regression testing) qui prend une vraie capture pixel — plus fiable que comparer du HTML.
Tests d’intégration — DB éphémère
Section intitulée « Tests d’intégration — DB éphémère »Avec SQLite en mémoire (Node, Python, PHP)
Section intitulée « Avec SQLite en mémoire (Node, Python, PHP) »beforeAll(() => { process.env.DATABASE_URL = ':memory:'; // init schéma});
it('crée et lit un user', async () => { await db.user.create({ email: 'a@a.com' }); const user = await db.user.findByEmail('a@a.com'); expect(user).toBeDefined();});Avec Testcontainers — la vraie DB de prod
Section intitulée « Avec Testcontainers — la vraie DB de prod »Testcontainers lance un vrai PostgreSQL dans un Docker temporaire pendant les tests.
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => { container = await new PostgreSqlContainer().start(); process.env.DATABASE_URL = container.getConnectionUri(); await runMigrations();}, 60_000);
afterAll(async () => { await container.stop();});✅ Tu testes contre le vrai Postgres, avec ses fonctionnalités spécifiques (JSONB, full-text search, RLS). ❌ ~10 s de cold start au démarrage de la suite.
Verdict 2026 : Testcontainers pour les tests vraiment importants (queries SQL complexes), in-memory SQLite pour les tests rapides du quotidien.
TDD — Test-Driven Development
Section intitulée « TDD — Test-Driven Development »Le cycle Red → Green → Refactor :
- Red : écris un test qui échoue (la feature n’existe pas).
- Green : écris le minimum de code pour passer le test.
- Refactor : améliore le code en gardant les tests verts.
Quand TDD est utile
Section intitulée « Quand TDD est utile »- Logique métier complexe avec règles précises.
- Bug fixing — écris un test qui reproduit le bug AVANT de fixer.
- API publique — tu écris l’usage idéal, puis l’implémentation.
Quand TDD est pénible
Section intitulée « Quand TDD est pénible »- UI très visuelle — préfère Storybook + tests E2E ciblés.
- Code exploratoire — tu prototypes, tu écris les tests après si la feature reste.
- Code glue / config — tester du JSON-mapping est fastidieux et low-value.
Pragmatique 2026 : TDD strict sur le domaine, tests post-écriture sur le reste.
BDD — Behavior-Driven Development
Section intitulée « BDD — Behavior-Driven Development »Au lieu de tester du code, tu décris des comportements en pseudo-langage naturel :
Feature: Login
Scenario: Connexion réussie Given je suis sur la page de login When je saisis "alice@example.com" et "password123" And je clique sur "Se connecter" Then je suis redirigé vers /dashboard And je vois "Bonjour Alice"Outils : Cucumber (multi-langages), Behat (PHP), pytest-bdd (Python).
Verdict 2026 : utile dans des contextes métier complexes où le PO / QA écrit les scénarios. Souvent over-kill pour des MVPs et SaaS classiques où Vitest + Playwright suffisent.
Tests E2E — Playwright
Section intitulée « Tests E2E — Playwright »Playwright (par Microsoft) est le standard 2026 pour les tests bout-en-bout.
npm install -D @playwright/testnpx playwright install # télécharge les navigateursimport { test, expect } from '@playwright/test';
test('login → dashboard', async ({ page }) => { await page.goto('http://localhost:3000'); await page.click('text=Se connecter'); await page.fill('input[name="email"]', 'alice@example.com'); await page.fill('input[name="password"]', 'password123'); await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard'); await expect(page.locator('h1')).toContainText('Bonjour Alice');});npx playwright testnpx playwright test --ui # mode interactifBonnes pratiques E2E
Section intitulée « Bonnes pratiques E2E »- Garde-les peu nombreux : 5-10 tests pour les parcours critiques (login, paiement, signup).
- Test contre une DB de test : pas de pollution, données prévisibles via fixtures.
- Selectors stables : préfère
data-testid="..."à des sélecteurs CSS qui changent au refacto. - Pas de
await page.waitForTimeout(2000): utilise les auto-waits Playwright (expect(...)).
Cypress vs Playwright
Section intitulée « Cypress vs Playwright »Cypress reste très utilisé. Différences :
| Cypress | Playwright | |
|---|---|---|
| Multi-tab | ❌ | ✅ |
| Multi-navigateur | Limited (Chrome-based) | Chromium, Firefox, WebKit |
| Vitesse | Moyen | Très rapide |
| DX | Excellente UI | UI moderne aussi |
| Mobile emulation | Limitée | Native |
Verdict 2026 : Playwright pour les nouveaux projets. Cypress reste valide si l’équipe est habituée.
Tests selon le langage
Section intitulée « Tests selon le langage »Node.js / TypeScript
Section intitulée « Node.js / TypeScript »| Outil | Usage |
|---|---|
| Vitest | Standard 2026 — rapide, ESM-first, compatible Jest |
| Jest | Encore très répandu mais moins rapide |
| Node.js test runner | node --test natif depuis Node 20 |
| Supertest | Tests d’intégration HTTP |
| Testing Library | Tests composants React/Vue/Svelte |
| Playwright | E2E |
| Outil | Usage |
|---|---|
| pytest | Standard absolu |
| pytest-asyncio | Tests async |
| httpx async | Tests d’API FastAPI |
| Testcontainers Python | DB éphémères |
| Outil | Usage |
|---|---|
| Pest | DSL moderne, lisible |
| PHPUnit | Standard historique |
Laravel : RefreshDatabase | DB en mémoire SQLite par test |
Couverture — utile mais piège
Section intitulée « Couverture — utile mais piège »npx vitest run --coverage# génère un rapport HTML détailléCe que la couverture mesure
Section intitulée « Ce que la couverture mesure »- Lignes exécutées au moins une fois.
- Branches explorées (if/else, switch).
- Fonctions appelées.
Ce qu’elle ne mesure PAS
Section intitulée « Ce qu’elle ne mesure PAS »- Si les assertions sont pertinentes (un test sans
expectpeut passer la ligne sans rien vérifier). - Les cas limites non testés.
- La logique métier correcte.
Cible raisonnable
Section intitulée « Cible raisonnable »| Type de code | Couverture cible |
|---|---|
| Domaine métier | 80-95 % (souvent atteignable) |
| Couche infra (DB, HTTP) | 60-80 % |
| UI / composants | 50-70 % (préférer Storybook + E2E) |
| Glue / config | inutile de viser haut |
Globalement 70-80 % est sain. Au-delà, le ROI décroît vite.
Auto-évaluation
Section intitulée « Auto-évaluation »Pour aller plus loin
Section intitulée « Pour aller plus loin »- Vitest Docs — vitest.dev
- Playwright Docs — playwright.dev
- Pest PHP — pestphp.com
- Testing JavaScript — Kent C. Dodds (cours payant mais très bon)
- Working Effectively with Legacy Code — Michael Feathers
Suite : 11.3 — Outils statiques — ESLint, Prettier, TypeScript strict, PHPStan.