Guide de test Vitest : de zéro à la confiance en production
Tests unitaires, tests d'intégration, tests de composants avec Testing Library, stratégies de mocking, configuration de la couverture et la philosophie de test qui produit de meilleurs logiciels.
La plupart des équipes écrivent des tests comme la plupart des gens font du sport : elles savent qu'elles devraient, elles se sentent coupables quand elles ne le font pas, et quand elles s'y mettent enfin, elles forcent trop le premier jour et abandonnent le vendredi. La base de code se retrouve avec une poignée de tests superficiels par snapshots qui cassent à chaque changement de classe CSS, quelques tests d'intégration auxquels personne ne fait confiance, et un badge de couverture dans le README qui ment.
J'ai connu les deux côtés. J'ai livré des projets avec zéro test et j'ai transpiré à chaque déploiement. J'ai aussi fait partie d'équipes qui cherchaient les 100 % de couverture et passaient plus de temps à maintenir les tests qu'à écrire des fonctionnalités. Aucune des deux approches ne fonctionne. Ce qui fonctionne, c'est écrire les bons tests, aux bons endroits, avec les bons outils.
Vitest a changé ma façon de penser les tests en JavaScript. Pas parce qu'il a inventé de nouveaux concepts — les fondamentaux n'ont pas changé depuis que Kent Beck en a écrit il y a des décennies. Mais parce qu'il a supprimé suffisamment de friction pour que l'écriture de tests cesse de ressembler à une corvée et commence à faire partie de la boucle de développement. Quand votre lanceur de tests est aussi rapide que votre serveur de développement et utilise la même configuration, les excuses s'évaporent.
Cet article est tout ce que je sais sur les tests avec Vitest, de la mise en place initiale à la philosophie qui donne tout son sens à la démarche.
Pourquoi Vitest plutôt que Jest#
Si vous avez utilisé Jest, vous connaissez déjà la majeure partie de l'API de Vitest. C'est voulu — Vitest est compatible avec Jest au niveau de l'API. describe, it, expect, beforeEach, vi.fn() — tout fonctionne. Alors pourquoi changer ?
Support natif des ESM#
Jest a été conçu pour CommonJS. Il peut gérer les ESM, mais cela nécessite de la configuration, des flags expérimentaux et parfois une bonne dose de prière. Si vous utilisez la syntaxe import/export (c'est-à-dire tout ce qui est moderne), vous avez probablement bataillé avec le pipeline de transformation de Jest.
Vitest tourne sur Vite. Vite comprend nativement les ESM. Il n'y a pas d'étape de transformation pour votre code source — ça fonctionne tout simplement. C'est plus important qu'il n'y paraît. La moitié des problèmes que j'ai débogués avec Jest au fil des années étaient liés à la résolution de modules : SyntaxError: Cannot use import statement outside a module, ou des mocks qui ne fonctionnaient pas parce que le module était déjà mis en cache dans un format différent.
Même configuration que votre serveur de développement#
Si votre projet utilise Vite (et si vous construisez une application React, Vue ou Svelte en 2026, c'est probablement le cas), Vitest lit automatiquement votre vite.config.ts. Vos alias de chemins, plugins et variables d'environnement fonctionnent dans les tests sans configuration supplémentaire. Avec Jest, vous maintenez une configuration parallèle qui doit rester synchronisée avec votre bundler. Chaque fois que vous ajoutez un alias de chemin dans vite.config.ts, vous devez penser à ajouter le moduleNameMapper correspondant dans jest.config.ts. C'est un détail, mais les détails s'accumulent.
Rapidité#
Vitest est rapide. Significativement rapide. Pas « ça vous fait gagner deux secondes » rapide — « ça change votre façon de travailler » rapide. Il utilise le graphe de modules de Vite pour comprendre quels tests sont affectés par une modification de fichier et ne lance que ceux-là. Son mode watch utilise la même infrastructure HMR qui rend le serveur de développement de Vite si instantané.
Sur un projet avec plus de 400 tests, le passage de Jest à Vitest a réduit notre boucle de retour en mode watch de ~4 secondes à moins de 500 ms. C'est la différence entre « je vais attendre que le test passe » et « je jette un œil au terminal pendant que mes doigts sont encore sur le clavier ».
Benchmarking intégré#
Vitest inclut bench() directement pour les tests de performance. Pas besoin de bibliothèque supplémentaire :
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});Lancez avec vitest bench. Ce n'est pas l'attraction principale, mais c'est agréable d'avoir les tests de performance dans la même chaîne d'outils sans installer benchmark.js et configurer un lanceur séparé.
Mise en place#
Installation#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomConfiguration#
Créez vitest.config.ts à la racine de votre projet :
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});Ou, si vous avez déjà un vite.config.ts, vous pouvez l'étendre :
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});La décision globals: true#
Quand globals est à true, vous n'avez pas besoin d'importer describe, it, expect, beforeEach, etc. — ils sont disponibles partout, exactement comme dans Jest. Quand c'est à false, vous les importez explicitement :
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});J'utilise globals: true parce que ça réduit le bruit visuel et correspond à ce que la plupart des développeurs attendent. Si vous êtes dans une équipe qui valorise les imports explicites, mettez-le à false — il n'y a pas de mauvaise réponse ici.
Si vous utilisez globals: true, ajoutez les types de Vitest à votre tsconfig.json pour que TypeScript les reconnaisse :
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Environnement : jsdom vs happy-dom vs node#
Vitest vous permet de choisir l'implémentation DOM par test ou globalement :
node— Pas de DOM. Pour la logique pure, les utilitaires, les routes API et tout ce qui ne touche pas au navigateur.jsdom— Le standard. Implémentation DOM complète. Plus lourd mais plus complet.happy-dom— Plus léger et plus rapide que jsdom mais moins complet. Certains cas particuliers (commeRange,SelectionouIntersectionObserver) peuvent ne pas fonctionner.
Par défaut, j'utilise jsdom globalement et je redéfinis par fichier quand j'ai besoin de node :
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});Fichiers de configuration#
Le fichier de setup s'exécute avant chaque fichier de test. C'est là que vous configurez les matchers de Testing Library et les mocks globaux :
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom ne l'implémente pas
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — nécessaire pour les composants responsifs
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});L'import @testing-library/jest-dom/vitest vous donne des matchers comme toBeInTheDocument(), toHaveClass(), toBeVisible() et bien d'autres. Ils rendent les assertions sur les éléments DOM lisibles et expressives.
Écrire de bons tests#
Le pattern AAA#
Chaque test suit la même structure : Arrange (Préparer), Act (Agir), Assert (Vérifier). Même si vous n'écrivez pas de commentaires explicites, la structure doit être visible :
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Quand je vois un test qui mélange la préparation, l'action et la vérification en une seule chaîne d'appels de méthodes, je sais qu'il sera difficile à comprendre quand il échouera. Gardez les trois phases visuellement distinctes même si vous n'ajoutez pas les commentaires.
Nommage des tests#
Il y a deux écoles : it('should calculate total with tax') et it('calculates total with tax'). Le préfixe « should » est verbeux sans apporter d'information. Quand le test échoue, vous verrez :
FAIL ✕ calculates total with tax
C'est déjà une phrase complète. Ajouter « should » ne fait qu'ajouter du bruit. Je préfère la forme directe : it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Pour les blocs describe, utilisez le nom de l'unité testée :
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Lisez-le à voix haute : « calculateTotal additionne les prix des articles. » « calculateTotal applique le taux de taxe. » Si la phrase fonctionne, le nommage fonctionne.
Une assertion par test vs regroupement pratique#
La règle puriste dit une assertion par test. La règle pratique dit : un concept par test. Ce sont deux choses différentes.
// C'est bien — un concept, plusieurs assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// Ce n'est pas bien — plusieurs concepts dans un seul test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});Le premier test a trois assertions mais elles vérifient toutes une seule chose : le format d'un nom d'affichage. Si une assertion échoue, vous savez exactement ce qui est cassé. Le second test, c'est trois tests séparés entassés ensemble. Si la deuxième assertion échoue, vous ne savez pas si c'est la création ou la mise à jour qui est cassée, et la troisième assertion ne s'exécute jamais.
Les descriptions de tests comme documentation#
De bonnes suites de tests servent de documentation vivante. Quelqu'un qui ne connaît pas le code devrait pouvoir lire les descriptions des tests et comprendre le comportement de la fonctionnalité :
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});Quand cette suite de tests s'exécute, la sortie se lit comme une spécification. C'est l'objectif.
Mocking#
Le mocking est l'outil le plus puissant et le plus dangereux de votre boîte à outils de test. Bien utilisé, il isole l'unité testée et rend les tests rapides et déterministes. Mal utilisé, il crée des tests qui passent quoi que fasse le code.
vi.fn() — Créer des fonctions mock#
Le mock le plus simple est une fonction qui enregistre ses appels :
const mockCallback = vi.fn();
// L'appeler
mockCallback("hello", 42);
mockCallback("world");
// Vérifier les appels
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Vous pouvez lui donner une valeur de retour :
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Ou la faire retourner des valeurs différentes sur des appels successifs :
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Observer les vraies méthodes#
Quand vous voulez observer une méthode sans remplacer son comportement :
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn conserve l'implémentation originale par défaut. Vous pouvez la redéfinir avec .mockImplementation() quand c'est nécessaire, mais restaurez l'original ensuite avec .mockRestore().
vi.mock() — Mocking au niveau du module#
C'est le gros morceau. vi.mock() remplace un module entier :
// Mocker le module entier
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// L'import utilise maintenant la version mockée
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest hisse automatiquement les appels vi.mock() en haut du fichier. Cela signifie que le mock est en place avant que les imports ne s'exécutent. Vous n'avez pas à vous soucier de l'ordre des imports.
Mocking automatique#
Si vous voulez simplement que chaque export soit remplacé par un vi.fn() :
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Sans la fonction factory, Vitest mock automatiquement tous les exports. Chaque fonction exportée devient un vi.fn() qui retourne undefined. C'est utile pour les modules que vous voulez faire taire (comme l'analytique ou les logs) sans spécifier chaque fonction.
Clear vs Reset vs Restore#
Ça piège tout le monde à un moment donné :
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — réinitialise l'historique des appels, conserve l'implémentation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // retourne toujours 42
// mockReset — réinitialise l'historique des appels ET l'implémentation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // ne retourne plus 42
// mockRestore — pour les spies, restaure l'implémentation originale
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() fonctionne normalement à nouveauEn pratique, utilisez vi.clearAllMocks() dans beforeEach pour réinitialiser l'historique des appels entre les tests. Utilisez vi.restoreAllMocks() si vous utilisez spyOn et voulez récupérer les originaux :
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Le danger du sur-mocking#
C'est le conseil le plus important que je puisse donner sur le mocking : chaque mock est un mensonge que vous racontez à votre test. Quand vous mockez une dépendance, vous dites « je fais confiance à ce composant pour fonctionner correctement, donc je vais le remplacer par une version simplifiée ». Si votre hypothèse est fausse, le test passe mais la fonctionnalité est cassée.
// Sur-mocké — ne teste rien d'utile
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Ce test vérifie que processInput appelle validate et format. Mais que se passe-t-il si processInput les appelle dans le mauvais ordre ? Que se passe-t-il s'il ignore leurs valeurs de retour ? Que se passe-t-il si la validation est censée empêcher l'étape de formatage de s'exécuter ? Le test ne le sait pas. Vous avez mocké tout le comportement intéressant.
La règle d'or : mockez aux frontières, pas au milieu. Mockez les requêtes réseau, l'accès au système de fichiers et les services tiers. Ne mockez pas vos propres fonctions utilitaires sauf s'il y a une raison impérieuse (comme si elles sont coûteuses à exécuter ou ont des effets de bord).
Tester les composants React#
Les bases avec Testing Library#
Testing Library impose une philosophie : testez les composants comme les utilisateurs interagissent avec eux. Pas de vérification de l'état interne, pas d'inspection des instances de composants, pas de rendu superficiel. Vous effectuez le rendu d'un composant et interagissez avec lui via le DOM, exactement comme un utilisateur le ferait.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Requêtes : getBy vs queryBy vs findBy#
C'est là que les débutants se perdent. Il y a trois variantes de requêtes et chacune a un cas d'utilisation spécifique :
getBy* — Retourne l'élément ou lève une erreur s'il n'est pas trouvé. Utilisez-le quand vous vous attendez à ce que l'élément existe :
// Lève une erreur si aucun bouton n'est trouvé — le test échoue avec un message utile
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Retourne l'élément ou null s'il n'est pas trouvé. Utilisez-le quand vous affirmez que quelque chose n'est PAS présent :
// Retourne null — ne lève pas d'erreur
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Retourne une Promise. Utilisez-le pour les éléments qui apparaissent de manière asynchrone :
// Attend jusqu'à 1000ms que l'élément apparaisse
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Requêtes orientées accessibilité#
Testing Library fournit ces requêtes dans un ordre de priorité délibéré :
getByRole— La meilleure requête. Utilise les rôles ARIA. Si votre composant n'est pas trouvable par rôle, il a peut-être un problème d'accessibilité.getByLabelText— Pour les éléments de formulaire. Si votre champ n'a pas de label, corrigez ça d'abord.getByPlaceholderText— Acceptable mais plus faible. Les placeholders disparaissent quand l'utilisateur tape.getByText— Pour les éléments non interactifs. Trouve par le contenu textuel visible.getByTestId— Dernier recours. Utilisez-le quand aucune requête sémantique ne fonctionne.
// Préférez ceci
screen.getByRole("textbox", { name: "Email address" });
// Plutôt que ceci
screen.getByPlaceholderText("Enter your email");
// Et certainement plutôt que ceci
screen.getByTestId("email-input");Le classement n'est pas arbitraire. Il correspond à la façon dont les technologies d'assistance naviguent sur la page. Si vous pouvez trouver un élément par son rôle et son nom accessible, les lecteurs d'écran le peuvent aussi. Si vous ne pouvez le trouver que par un ID de test, vous avez peut-être une lacune en accessibilité.
Événements utilisateur#
N'utilisez pas fireEvent. Utilisez @testing-library/user-event. La différence compte :
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type déclenche keydown, keypress, input, keyup pour CHAQUE caractère
// fireEvent.change se contente de définir la valeur — en sautant le flux réaliste d'événements
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent simule la chaîne complète d'événements qu'un vrai navigateur déclencherait. fireEvent.change est un événement synthétique unique. Si votre composant écoute onKeyDown ou utilise onInput au lieu de onChange, fireEvent.change ne déclenchera pas ces gestionnaires mais userEvent.type le fera.
Appelez toujours userEvent.setup() au début et utilisez l'instance user retournée. Cela garantit un ordonnancement correct des événements et un suivi d'état approprié.
Tester les interactions de composants#
Un test réaliste de composant ressemble à ceci :
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});Remarquez : pas d'inspection de l'état interne, pas de component.setState(), pas de vérification des détails d'implémentation. On fait le rendu, on interagit, on vérifie ce que l'utilisateur verrait. Si le composant refactorise sa gestion d'état interne de useState à useReducer, ces tests passent toujours. C'est tout l'intérêt.
Tester le code asynchrone#
waitFor#
Quand un composant se met à jour de manière asynchrone, waitFor interroge jusqu'à ce que l'assertion passe :
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Affiche d'abord le chargement
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Attendre que le contenu apparaisse
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// L'indicateur de chargement devrait avoir disparu
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor relance le callback toutes les 50 ms (par défaut) jusqu'à ce qu'il passe ou que le délai expire (1000 ms par défaut). Vous pouvez personnaliser les deux :
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Faux minuteurs#
Quand vous testez du code qui utilise setTimeout, setInterval ou Date, les faux minuteurs vous permettent de contrôler le temps :
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // réinitialise le minuteur
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Important : appelez toujours vi.useRealTimers() dans afterEach. Les faux minuteurs qui fuient entre les tests causent les échecs les plus déroutants que vous déboguerez jamais.
Tester avec faux minuteurs et rendu asynchrone#
Combiner les faux minuteurs avec les tests de composants React nécessite de la prudence. La planification interne de React utilise de vrais minuteurs, donc il faut souvent avancer les minuteurs ET vider les mises à jour React ensemble :
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Avancer les minuteurs dans act() pour vider les mises à jour React
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});Mocking d'API avec MSW#
Pour tester la récupération de données, Mock Service Worker (MSW) intercepte les requêtes réseau au niveau du réseau. Cela signifie que le code fetch/axios de votre composant s'exécute exactement comme en production — MSW remplace simplement la réponse réseau :
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Redéfinir le gestionnaire par défaut pour ce test uniquement
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW est meilleur que mocker fetch ou axios directement parce que :
- Le vrai code de récupération de données de votre composant s'exécute — vous testez la vraie intégration.
- Vous pouvez tester la gestion des erreurs en redéfinissant les gestionnaires par test.
- Les mêmes gestionnaires fonctionnent à la fois dans les tests et dans le mode développement du navigateur (Storybook, par exemple).
Tests d'intégration#
Tests unitaires vs tests d'intégration#
Un test unitaire isole une seule fonction ou un seul composant et mocke tout le reste. Un test d'intégration laisse plusieurs unités travailler ensemble et ne mocke que les frontières externes (réseau, système de fichiers, bases de données).
La vérité, c'est que la plupart des bugs que j'ai vus en production se produisent aux frontières entre les unités, pas à l'intérieur. Une fonction marche parfaitement en isolation mais échoue parce que l'appelant passe les données dans un format légèrement différent. Un composant s'affiche bien avec des données mockées mais casse quand la vraie réponse API a un niveau d'imbrication supplémentaire.
Les tests d'intégration attrapent ces bugs. Ils sont plus lents que les tests unitaires et plus difficiles à déboguer quand ils échouent, mais ils donnent plus de confiance par test.
Tester plusieurs composants ensemble#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Ne mocker que la couche API — tout le reste est réel
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});Dans ce test, ShoppingCart, CartProvider et leurs composants internes (lignes d'articles, sélecteurs de quantité, affichage des totaux) fonctionnent tous ensemble avec du vrai code. Le seul mock est l'appel API, parce que nous ne voulons pas faire de vraies requêtes réseau dans les tests.
Quand utiliser les tests d'intégration vs les tests unitaires#
Utilisez les tests unitaires quand :
- La logique est complexe et comporte de nombreux cas limites (un parseur de dates, une machine à états, un calcul).
- Vous avez besoin d'un retour rapide sur le comportement d'une fonction spécifique.
- L'unité est relativement isolée et ne dépend pas fortement d'autres unités.
Utilisez les tests d'intégration quand :
- Plusieurs composants doivent fonctionner ensemble correctement (un formulaire avec validation et soumission).
- Les données traversent plusieurs couches (contexte → composant → composant enfant).
- Vous testez un flux utilisateur, pas la valeur de retour d'une fonction.
En pratique, une suite de tests saine est riche en tests d'intégration pour les fonctionnalités et possède des tests unitaires pour les utilitaires complexes. Les composants eux-mêmes sont testés via les tests d'intégration — vous n'avez pas besoin d'un test unitaire séparé pour chaque petit composant si le test d'intégration l'exerce.
Couverture#
Lancer la couverture#
vitest run --coverageVous aurez besoin d'un fournisseur de couverture. Vitest en supporte deux :
# V8 — plus rapide, utilise la couverture intégrée de V8
npm install -D @vitest/coverage-v8
# Istanbul — plus mature, plus d'options de configuration
npm install -D @vitest/coverage-istanbulConfigurez-le dans votre config Vitest :
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
La couverture V8 utilise l'instrumentation intégrée du moteur V8. C'est plus rapide car il n'y a pas d'étape de transformation du code. Mais elle peut être moins précise pour certains cas particuliers, notamment autour des frontières de modules ES.
Istanbul instrumente votre code source avec des compteurs avant d'exécuter les tests. C'est plus lent mais plus éprouvé et donne une couverture de branches plus précise. Si vous appliquez des seuils de couverture en CI, la précision d'Istanbul peut compter.
J'utilise V8 pour le développement local (retour plus rapide) et Istanbul en CI (application plus précise). Vous pouvez configurer des fournisseurs différents par environnement si nécessaire.
Ce que la couverture signifie vraiment#
La couverture vous dit quelles lignes de code ont été exécutées pendant les tests. C'est tout. Elle ne vous dit pas si ces lignes ont été testées correctement. Considérez ceci :
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// Pas d'assertion !
});Ce test donne 100 % de couverture de la fonction divide. Il ne teste aussi absolument rien. Le test passerait si divide retournait null, levait une erreur ou lançait des missiles.
La couverture est un indicateur négatif utile : une faible couverture signifie qu'il y a définitivement des chemins non testés. Mais une couverture élevée ne signifie pas que votre code est bien testé. Cela signifie simplement que chaque ligne a été exécutée pendant un test.
Lignes vs branches#
La couverture des lignes est la métrique la plus courante mais la couverture des branches est plus précieuse :
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Un test avec getDiscount({ isPremium: true, yearsActive: 10 }) atteint chaque ligne (100 % de couverture de lignes) mais ne teste que deux des trois branches. Le chemin isPremium: false et le chemin yearsActive <= 5 ne sont pas testés.
La couverture des branches détecte cela. Elle suit chaque chemin possible à travers la logique conditionnelle. Si vous devez appliquer un seuil de couverture, utilisez la couverture des branches.
Ignorer le code généré#
Certain code ne devrait pas être compté dans la couverture. Les fichiers générés, les définitions de types, la configuration — cela gonfle vos métriques sans apporter de valeur :
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Vous pouvez aussi ignorer des lignes ou blocs spécifiques dans votre code source :
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Ou pour Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Utilisez-le avec parcimonie. Si vous vous retrouvez à ignorer de gros morceaux de code, soit ces morceaux ont besoin de tests, soit ils ne devraient pas être dans le rapport de couverture dès le départ.
Tester Next.js#
Mocker next/navigation#
Les composants Next.js qui utilisent useRouter, usePathname ou useSearchParams ont besoin de mocks :
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Pour les tests qui doivent vérifier que la navigation a été appelée :
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});Mocker next-intl#
Pour les composants internationalisés utilisant next-intl :
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));C'est l'approche la plus simple — les traductions retournent la clé elle-même, donc t("hero.title") retourne "hero.title". Dans les assertions, vous vérifiez la clé de traduction plutôt que la chaîne traduite réelle. Cela rend les tests indépendants de la langue.
Si vous avez besoin des traductions réelles dans un test spécifique :
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));Tester les Route Handlers#
Les Route Handlers de Next.js sont des fonctions régulières qui prennent une Request et retournent une Response. Ils sont simples à tester :
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Tester le Middleware#
Le middleware Next.js s'exécute à la périphérie (edge) et traite chaque requête. Testez-le comme une fonction :
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Tester les Server Actions#
Les Server Actions sont des fonctions asynchrones qui s'exécutent sur le serveur. Comme ce sont simplement des fonctions, vous pouvez les tester directement — mais vous pourriez avoir besoin de mocker les dépendances côté serveur :
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Champs requis manquants
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Patterns pratiques#
Fonction de rendu personnalisée#
La plupart des projets ont besoin des mêmes providers enveloppant chaque composant. Créez un rendu personnalisé :
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Réexporter tout depuis testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Maintenant chaque fichier de test importe depuis vos utilitaires personnalisés au lieu de @testing-library/react :
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header et tous ses enfants ont accès à ThemeProvider et CartProvider
});Tester les hooks personnalisés#
Vitest fonctionne avec le renderHook de @testing-library/react :
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // ne descend pas en dessous du minimum
});
});Tester les Error Boundaries#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Supprimer console.error pour les erreurs attendues
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Tests par snapshot (avec prudence)#
Les tests par snapshot ont mauvaise réputation parce que les gens les utilisent comme substitut à de vraies assertions. Un snapshot de la sortie HTML complète d'un composant est un fardeau de maintenance — il casse à chaque changement de classe CSS et personne n'examine attentivement le diff.
Mais les snapshots ciblés peuvent être utiles :
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Bien — petit snapshot ciblé de la sortie d'une fonction pure
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Mal — snapshot géant que personne ne relira
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Ne faites pas ça
});Les snapshots en ligne (toMatchInlineSnapshot) sont meilleurs que les snapshots en fichier parce que la valeur attendue est visible directement dans le test. Vous pouvez voir d'un coup d'œil ce que la fonction retourne sans ouvrir un fichier .snap séparé.
La philosophie de test#
Tester le comportement, pas l'implémentation#
Ce principe est tellement important qu'il mérite une section dédiée. Considérez deux tests pour la même fonctionnalité :
// Test d'implémentation — fragile, casse lors des refactorisations
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Test de comportement — résilient, teste ce que l'utilisateur voit
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});Le premier test casse si vous passez de useState à useReducer, même si le composant fonctionne exactement de la même façon. Le second test ne casse que si le comportement du composant change réellement. Il ne se soucie pas de la gestion interne du compteur — seulement que cliquer sur « + » fait augmenter le nombre.
Le test décisif est simple : pouvez-vous refactoriser l'implémentation sans changer le test ? Si oui, vous testez le comportement. Si non, vous testez l'implémentation.
Le Trophée de test#
Kent C. Dodds a proposé le « Testing Trophy » comme alternative à la pyramide de test traditionnelle :
╭─────────╮
│ E2E │ Peu — coûteux, lent, haute confiance
├─────────┤
│ │
│ Intégr. │ Le plus — bon ratio confiance/coût
│ │
├─────────┤
│ Unit. │ Quelques — rapides, ciblés, faible coût
├─────────┤
│ Statiq. │ Toujours — TypeScript, ESLint
╰─────────╯
La pyramide traditionnelle place les tests unitaires à la base (beaucoup) et les tests d'intégration au milieu (moins). Le trophée inverse cela : les tests d'intégration sont le point optimal. Voici pourquoi :
- L'analyse statique (TypeScript, ESLint) détecte les coquilles, les mauvais types et les erreurs logiques simples gratuitement. Vous n'avez même pas besoin de lancer quoi que ce soit.
- Les tests unitaires sont excellents pour la logique pure complexe mais ne vous disent pas si les pièces fonctionnent ensemble.
- Les tests d'intégration vérifient que les composants, hooks et contextes fonctionnent ensemble. Ils donnent le plus de confiance par test écrit.
- Les tests end-to-end vérifient le système entier mais sont lents, instables et coûteux à maintenir. Vous en avez besoin de quelques-uns pour les chemins critiques mais pas de centaines.
Je suis cette distribution en pratique : TypeScript détecte la plupart de mes erreurs de type, j'écris des tests unitaires pour les utilitaires et algorithmes complexes, des tests d'intégration pour les fonctionnalités et les flux utilisateur, et une poignée de tests E2E pour le chemin critique (inscription, achat, workflow principal).
Ce qui donne confiance vs ce qui fait perdre du temps#
Les tests existent pour vous donner confiance pour livrer. Pas la confiance que chaque ligne de code s'exécute — la confiance que l'application fonctionne pour les utilisateurs. Ce sont deux choses différentes.
Haute confiance, haute valeur :
- Test d'intégration d'un flux de paiement — couvre la validation du formulaire, les appels API, les mises à jour d'état et l'UI de succès/erreur.
- Test unitaire d'une fonction de calcul de prix avec cas limites — virgule flottante, arrondi, remises, valeurs nulles/négatives.
- Test que les routes protégées redirigent les utilisateurs non authentifiés.
Faible confiance, perte de temps :
- Test snapshot d'une page marketing statique — casse à chaque changement de texte, ne détecte rien de significatif.
- Test unitaire qu'un composant passe une prop à un enfant — teste React lui-même, pas votre code.
- Test que
useStateest appelé — teste le framework, pas le comportement. - 100 % de couverture d'un fichier de configuration — ce sont des données statiques, TypeScript valide déjà leur forme.
La question à se poser avant d'écrire un test : « Si ce test n'existait pas, quel bug pourrait passer en production ? » Si la réponse est « aucun que TypeScript n'attraperait » ou « aucun que quiconque remarquerait », le test ne vaut probablement pas la peine d'être écrit.
Le test comme retour sur la conception#
Du code difficile à tester est généralement du code mal conçu. Si vous devez mocker cinq choses pour tester une fonction, cette fonction a trop de dépendances. Si vous ne pouvez pas faire le rendu d'un composant sans mettre en place des providers de contexte élaborés, le composant est trop couplé à son environnement.
Les tests sont un utilisateur de votre code. Si vos tests ont du mal à utiliser votre API, les autres développeurs aussi. Quand vous vous retrouvez à lutter contre la mise en place du test, prenez-le comme un signal pour refactoriser le code testé, pas pour ajouter plus de mocks.
// Difficile à tester — la fonction fait trop de choses
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Plus facile à tester — séparation des préoccupations
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}La seconde version est une fonction pure. Vous pouvez tester chaque combinaison de résultats d'inventaire et de paiement sans mocker une base de données, un fournisseur de paiement ou un service d'email. La logique d'orchestration (récupération des données, envoi d'emails) vit dans une couche mince qui est testée au niveau de l'intégration.
C'est la vraie valeur du test : pas d'attraper les bugs après qu'ils sont écrits, mais d'empêcher les mauvaises conceptions avant qu'elles ne soient committées. La discipline d'écrire des tests vous pousse vers des fonctions plus petites, des interfaces plus claires et une architecture plus modulaire. Les tests sont l'effet secondaire. L'amélioration de la conception est l'événement principal.
Écrivez des tests auxquels vous faites confiance#
La pire chose qui puisse arriver à une suite de tests, ce n'est pas qu'elle ait des lacunes. C'est que les gens cessent de lui faire confiance. Une suite de tests avec quelques tests instables qui échouent aléatoirement en CI apprend à l'équipe à ignorer les builds en rouge. Une fois que c'est arrivé, la suite de tests est pire qu'inutile — elle procure activement une fausse sécurité.
Si un test échoue par intermittence, corrigez-le ou supprimez-le. Si un test est lent, accélérez-le ou déplacez-le dans une suite de tests lents séparée. Si un test casse à chaque changement sans rapport, réécrivez-le pour tester le comportement plutôt que l'implémentation.
L'objectif est une suite de tests où chaque échec signifie que quelque chose de réel est cassé. Quand les développeurs font confiance aux tests, ils les lancent avant chaque commit. Quand ils ne font pas confiance aux tests, ils les contournent avec --no-verify et déploient les doigts croisés.
Construisez une suite de tests sur laquelle vous miseriez votre week-end. Rien de moins ne vaut la peine d'être maintenu.