Chapitre 10
Chapitre 10 — Testing
> Stratégies, outils et bonnes pratiques pour tester une application front-end moderne.
Testing — Cours complet
1. Pourquoi tester ?
Les tests sont un investissement qui rapporte :
- Détection précoce des bugs : un bug trouvé en dev coûte 10x moins qu'en production
- Confiance dans le refactoring : on peut réécrire sans peur
- Documentation vivante : les tests décrivent le comportement attendu
- Design forcing : un code testable est souvent mieux conçu
- Productivité d'équipe : moins de régressions, moins de debug
La pyramide des tests
╱╲
╱ E2E ╲ ← Tests de bout en bout (lents, fragiles)
╱────────╲
╱ Intégration ╲ ← Tests d'intégration (équilibrés)
╱────────────────╲
╱ Unitaires ╲ ← Tests unitaires (rapides, nombreux)
╱──────────────────────╲
En front-end, la pyramide devient :
╱╲
╱ E2E ╲ ← Playwright, Cypress (10-20%)
╱────────╲
╱ Intégration ╲ ← RTL + MSW (30-40%)
╱────────────────╲
╱ Unitaires ╲ ← Vitest (50-60%)
╱──────────────────────╲
2. Types de tests
Tests Unitaires
Testent une fonction ou un composant en isolation.
// sum.ts
export function sum(a: number, b: number): number {
return a + b;
}
// sum.test.ts
import { describe, it, expect } from 'vitest';
import { sum } from './sum';
describe('sum', () => {
it('adds two positive numbers', () => {
expect(sum(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(sum(-1, -2)).toBe(-3);
});
});
Tests d'Intégration
Testent l'interaction entre plusieurs modules.
test('affiche la liste des utilisateurs', async () => {
render(<UserList />);
expect(screen.getByText(/chargement/i)).toBeInTheDocument();
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(3);
});
Tests E2E
Testent un parcours utilisateur complet dans un navigateur.
test('connexion et navigation', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'user@test.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});
Tests de Snapshot
Comparent le rendu avec une référence enregistrée.
test('Button snapshot', () => {
const { container } = render(<Button>Click</Button>);
expect(container).toMatchSnapshot();
});
Tests Visuels
Comparent des screenshots de composants pour détecter des régressions CSS.
3. Vitest
Setup
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});
describe / it / expect
describe('AuthService', () => {
describe('login', () => {
it('returns a token when credentials are valid', async () => {
const token = await AuthService.login('user', 'pass');
expect(token).toBeDefined();
expect(typeof token).toBe('string');
});
it('throws when credentials are invalid', async () => {
await expect(AuthService.login('wrong', 'creds')).rejects.toThrow('Invalid credentials');
});
it.each([
{ email: '', password: 'pass' },
{ email: 'user', password: '' },
])('rejects empty $field', async ({ email, password }) => {
await expect(AuthService.login(email, password)).rejects.toThrow();
});
});
});
Mocks
import { vi } from 'vitest';
// Mock d'un module
vi.mock('axios');
import axios from 'axios';
const mockedAxios = vi.mocked(axios);
mockedAxios.get.mockResolvedValue({ data: { id: 1, name: 'Alice' } });
// Mock d'une fonction
const callback = vi.fn();
callback('hello');
expect(callback).toHaveBeenCalledWith('hello');
expect(callback).toHaveBeenCalledTimes(1);
Spies
const user = {
getName: (): string => 'Alice',
};
const spy = vi.spyOn(user, 'getName');
spy.mockImplementation(() => 'Bob');
expect(user.getName()).toBe('Bob');
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
4. React Testing Library
Philosophie
"Plus vos tests ressemblent à la façon dont votre logiciel est utilisé, plus ils peuvent vous donner confiance."
Ne testez PAS :
- L'implémentation (state interne, méthodes privées)
- Les détails de rendu (classes CSS, nœuds DOM spécifiques)
- Les appels à setState
Testez :
- Ce que l'utilisateur voit
- Ce que l'utilisateur fait
- Le comportement résultant
Queries
// Accessibles et sémantiques
getByRole('button', { name: /submit/i })
getByLabelText('Email')
getByPlaceholderText('Entrez votre email')
getByText('Connectez-vous')
getByDisplayValue('Valeur pré-remplie')
// Rôle ARIA
getByRole('alert')
getByRole('dialog')
getByRole('navigation')
getByRole('listitem')
// Variantes : getBy, queryBy, findBy
// getBy* → throw si pas trouvé
// queryBy* → null si pas trouvé (pour tester l'absence)
// findBy* → retourne une Promise (pour les éléments asynchrones)
fireEvent vs userEvent
import userEvent from '@testing-library/user-event';
// ❌ fireEvent (bas niveau, ne simule pas les vrais événements)
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'hello' } });
// ✅ userEvent (haut niveau, simule les vrais comportements)
await userEvent.click(button);
await userEvent.type(input, 'hello');
await userEvent.keyboard('{Enter}');
await userEvent.tab();
userEvent est toujours préféré car il déclenche tous les événements (focus, blur, keydown, keyup, click, etc.) comme un vrai utilisateur.
Exemple complet
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits with valid data', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/mot de passe/i);
const submitButton = screen.getByRole('button', { name: /connecter/i });
await userEvent.type(emailInput, 'user@test.com');
await userEvent.type(passwordInput, 'password123');
await userEvent.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@test.com',
password: 'password123',
});
});
it('shows validation errors for empty fields', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /connecter/i }));
expect(screen.getByText(/email requis/i)).toBeInTheDocument();
expect(screen.getByText(/mot de passe requis/i)).toBeInTheDocument();
});
});
5. Tests d'intégration avec MSW
Setup
npm install -D msw
Configuration
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}),
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@test.com' && password === 'pass') {
return HttpResponse.json({ token: 'fake-jwt' });
}
return new HttpResponse(null, { status: 401 });
}),
];
// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
import '@testing-library/jest-dom';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Test d'intégration
test('fetches and displays users', async () => {
render(<UserList />);
// Loading state
expect(screen.getByText(/chargement/i)).toBeInTheDocument();
// Data loaded
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(2);
expect(users[0]).toHaveTextContent('Alice');
});
6. Tests E2E
Playwright vs Cypress
| Critère | Playwright | Cypress |
|---|---|---|
| Navigateurs | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit (v12+) |
| Langage | JS/TS, Python, C#, Java | JS/TS uniquement |
| Parallélisation | Native (multi-workers) | Dashboard payant |
| Réseau | Interception native | Interception incluse |
| Vitesse | Très rapide | Rapide |
| CI natif | Oui | Nécessite Dashboard |
| Traces/Video | Inclus | Inclus |
Playwright
import { test, expect } from '@playwright/test';
test('complete login flow', async ({ page }) => {
await page.goto('https://example.com/login');
await page.getByLabel(/email/i).fill('user@example.com');
await page.getByLabel(/mot de passe/i).fill('password123');
await page.getByRole('button', { name: /connecter/i }).click();
await expect(page.getByTestId('dashboard')).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
});
Sélecteurs
// ❌ Fragile
await page.click('.btn-primary > span:nth-child(2)');
// ✅ Robuste (getByRole)
await page.getByRole('button', { name: /submit/i }).click();
// ✅ Robuste (getByTestId)
await page.getByTestId('submit-button').click();
// ✅ Robuste (getByLabel)
await page.getByLabel(/email/i).fill('test@test.com');
Fixtures
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './page-objects/LoginPage';
type MyFixtures = {
loginPage: LoginPage;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
7. Tests de Snapshot — Quand les utiliser
✅ Bons usages
// Éviter les régressions visuelles involontaires
test('Button retains structure', () => {
const { container } = render(<Button variant="primary">Submit</Button>);
expect(container).toMatchSnapshot();
});
⚠️ Limites
- Fragiles : un espace change, le snapshot casse
- Difficiles à reviewer : les diffs sont longs
- Faux positifs : un changement CSS peut casser le snapshot sans impact visuel réel
- Pas de test de comportement : un snapshot passant ne signifie pas que le composant fonctionne
❌ Quand les éviter
- Composants avec beaucoup de variantes
- Composants qui changent souvent
- Tests de logique métier
- Remplacer des tests de comportement par des snapshots
8. TDD en Front-End — Mythe ou Réalité ?
TDD classique (Red-Green-Refactor)
- Écrire un test qui échoue (Red)
- Écrire le code minimum pour passer (Green)
- Refactorer
Applicabilité en front
| Type de code | TDD applicable ? |
|---|---|
| Fonctions pures (utils, helpers) | ✅ Oui |
| Hooks personnalisés | ✅ Oui |
| Composants avec des règles métier | ⚠️ Parfois |
| Composants purement UI | ❌ Rarement |
| Animations complexes | ❌ Non |
Conclusion
Le TDD pur est difficile en front-end React. Une approche pragmatique :
- TDD pour la logique métier (hooks, services, utils)
- Test après coup pour les composants UI
- Tests d'intégration pour valider les parcours
9. Coverage
Configuration des seuils
// vitest.config.ts
coverage: {
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
exclude: [
'src/test/**',
'**/*.stories.*',
'**/*.d.ts',
'vite.config.ts',
],
}
Interprétation
- Branches : combien de branches conditionnelles (if/else, switch) sont couvertes
- Functions : combien de fonctions sont appelées dans les tests
- Lines : combien de lignes sont exécutées
- Statements : combien d'instructions sont exécutées
Limites du coverage
// 100% de coverage ne signifie pas 100% de qualité
if (user) {
return user.name;
}
return 'Guest';
// Le test peut passer sur les deux branches sans tester
// que user.name existe vraiment
10. CI Intégration
GitHub Actions
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
- run: npm ci
- run: npm run test:ci
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
11. Erreurs des juniors
❌ Tester l'implémentation
// ❌ Mauvais : teste l'état interne
expect(component.state().isLoading).toBe(true);
// ✅ Bon : teste le comportement visible
expect(screen.getByText(/chargement/i)).toBeInTheDocument();
❌ Snapshot Abuse
// ❌ Mauvais : snapshot de tout le composant
test('UserList', () => {
const { container } = render(<UserList />);
expect(container).toMatchSnapshot(); // 300 lignes de HTML à reviewer
});
// ✅ Bon : tests de comportement ciblés
test('UserList displays users and loading state', async () => {
render(<UserList />);
expect(screen.getByText(/chargement/i)).toBeInTheDocument();
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(2);
});
❌ Trop de mocks
// ❌ Mauvais : mock de tout
vi.mock('../../services/api');
vi.mock('../../hooks/useAuth');
vi.mock('../../utils/format');
// ✅ Bon : ne mocker que les appels réseau
// Le reste est testé avec le vrai code
❌ Négliger les tests d'accessibilité
// ❌ Mauvais : utilise des sélecteurs fragiles
expect(wrapper.find('.btn-save').exists()).toBe(true);
// ✅ Bon : utilise des sélecteurs accessibles
expect(screen.getByRole('button', { name: /sauvegarder/i })).toBeInTheDocument();
12. Bonnes pratiques
- Préférer
userEventàfireEvent— simule les vrais comportements - Tester comme un utilisateur — queries par rôle, label, texte
- Ne pas mocker ce que vous ne possédez pas — sauf les appels réseau
- Un fichier de test = un fichier de source — structure miroir
- Écrire des tests avant de corriger un bug — reproduction test d'abord
- CI doit échouer si les tests échouent — gate de qualité
- Limiter les snapshots — préférer les assertions ciblées
- Tests parallélisables — éviter les dépendances entre tests
- Nommer clairement les tests —
should do X when Y - Maintenir les tests — le code change, les tests aussi