MFormations
Modern Frontend Engineering

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èrePlaywrightCypress
NavigateursChromium, Firefox, WebKitChromium, Firefox, WebKit (v12+)
LangageJS/TS, Python, C#, JavaJS/TS uniquement
ParallélisationNative (multi-workers)Dashboard payant
RéseauInterception nativeInterception incluse
VitesseTrès rapideRapide
CI natifOuiNécessite Dashboard
Traces/VideoInclusInclus

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)

  1. Écrire un test qui échoue (Red)
  2. Écrire le code minimum pour passer (Green)
  3. Refactorer

Applicabilité en front

Type de codeTDD 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 :

  1. TDD pour la logique métier (hooks, services, utils)
  2. Test après coup pour les composants UI
  3. 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

  1. Préférer userEvent à fireEvent — simule les vrais comportements
  2. Tester comme un utilisateur — queries par rôle, label, texte
  3. Ne pas mocker ce que vous ne possédez pas — sauf les appels réseau
  4. Un fichier de test = un fichier de source — structure miroir
  5. Écrire des tests avant de corriger un bug — reproduction test d'abord
  6. CI doit échouer si les tests échouent — gate de qualité
  7. Limiter les snapshots — préférer les assertions ciblées
  8. Tests parallélisables — éviter les dépendances entre tests
  9. Nommer clairement les testsshould do X when Y
  10. Maintenir les tests — le code change, les tests aussi