Toute équipe logicielle écrit des tests. Toutes n'écrivent pas des tests qui préviennent les bogues, survivent aux refactorisations et donnent assez de confiance pour livrer un vendredi après-midi. La différence entre des tests utiles et des tests nuisibles ne tient ni au cadre de test ni au taux de couverture. Elle tient à la stratégie : savoir quoi tester, à quel niveau, et avec quel compromis.
Beaucoup d'équipes écrivent par défaut des tests unitaires pour tout et s'en tiennent là. Le rapport de couverture affiche 90 pour cent, l'intégration continue est verte, tout le monde est rassuré — jusqu'à ce qu'une modification dans une fonction casse trois fonctionnalités qu'aucun test unitaire n'a détectées. Le problème n'est pas le manque de discipline, mais le fait de tester au mauvais niveau. Un composant qui relie cinq services peut afficher 100 pour cent de couverture unitaire et échouer en production, parce que l'interaction entre ces services n'a jamais été vérifiée.
Ce guide parcourt tout l'éventail des stratégies modernes — des tests unitaires rapides et isolés aux tests de bout en bout lents mais fiables — et explique quand chacune apporte de la valeur et quand elle ne crée que de la charge. L'objectif n'est pas de vous convaincre d'écrire plus de tests, mais de vous aider à en écrire qui justifient leur existence : ils attrapent les bogues qui comptent, tournent assez vite pour être exécutés souvent, et survivent à l'inévitable réorganisation de votre base de code.
Bonnes pratiques des tests unitaires et ce qu'il faut tester
Les tests unitaires forment la base de la plupart des stratégies parce qu'ils sont rapides, déterministes et isolés. Un bon test unitaire s'exécute en millisecondes, couvre un seul comportement logique et ne dépend d'aucun système externe : ni base de données, ni système de fichiers, ni API réseau. Quand il échoue, vous savez exactement quelle logique est cassée et pourquoi.
L'erreur la plus fréquente consiste à tester des détails d'implémentation au lieu du comportement. Vérifier qu'une fonction appelle telle méthode interne ou renseigne tel champ privé rend le test cassant : une refactorisation qui préserve le comportement externe le fera tomber quand même. Le test devient un fardeau au lieu d'un filet de sécurité. Écrivez des tests qui portent sur la sortie observable ou les effets de bord d'une fonction, pas sur la façon dont elle y parvient.
Sont de bons candidats : les fonctions utilitaires pures, la logique métier sans entrées-sorties, la validation et l'analyse syntaxique, les transformations de données, les calculs algorithmiques et les automates à états. Sont de mauvais candidats : les fonctions qui font des appels réseau, les composants qui dessinent l'interface et tout code fortement couplé aux rouages du framework. Ceux-là se testent mieux au niveau intégration ou bout en bout.
import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";
describe("calculateDiscount", () => {
it("returns 0 for orders below the threshold", () => {
const order: Order = {
items: [{ price: 30, quantity: 1 }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
it("applies 10 percent for orders over $100", () => {
const order: Order = {
items: [
{ price: 80, quantity: 1 },
{ price: 40, quantity: 1 },
],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(12);
});
it("applies loyalty bonus for customers over 2 years", () => {
const order: Order = {
items: [{ price: 100, quantity: 1 }],
customerSince: new Date("2022-06-01"),
};
expect(calculateDiscount(order)).toBe(15);
});
it("does not apply discount when items are on sale", () => {
const order: Order = {
items: [{ price: 200, quantity: 1, onSale: true }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
});L'exemple ci-dessus vérifie quatre comportements distincts d'une même fonction, avec des entrées et des sorties attendues différentes. Chaque test est indépendant, déterministe et s'exécute en moins de 10 millisecondes. Si la logique tarifaire change, les tests disent exactement quels scénarios sont touchés. Voilà la proposition de valeur des tests unitaires : un retour rapide sur de la logique pure.
- Testez des comportements, pas l'implémentation. Vérifiez les sorties et les effets de bord, pas les appels internes ni l'état privé.
- Une assertion logique par cas. Un test doit échouer pour exactement une raison, afin qu'on voie tout de suite ce qui a cassé.
- Donnez des noms parlants qui se lisent comme des phrases. « renvoie 0 pour les commandes sous le seuil » vaut mieux que « test_remise_petit_montant ».
- Évitez l'état mutable partagé entre tests. Chaque test prépare ses données et nettoie après lui.
- Gardez des valeurs simples et parlantes. Utilisez des données réalistes, fidèles à vos objets métier.
Tests d'intégration avec de vraies dépendances
Les tests d'intégration se situent entre l'unitaire et le bout en bout, en vitesse comme en portée. Ils vérifient la collaboration de plusieurs unités : un dépôt qui interroge une vraie base, un service qui appelle un point d'accès réel, ou un composant rendu avec un véritable magasin d'état. La différence clé avec l'unitaire : ils utilisent des dépendances réelles ou conteneurisées plutôt que des doublures.
Les plus utiles vérifient que votre code interagit correctement avec les systèmes externes. Un test unitaire qui simule la couche base de données vous dit si la logique de requête est juste isolément, mais pas si la requête SQL réelle fonctionne contre le vrai schéma. Les tests d'intégration avec Testcontainers ou une base dédiée attrapent les écarts de schéma, les contraintes violées, les bogues aux frontières de transaction et les problèmes de sérialisation qui échappent totalement à l'unitaire.
Ils brillent sur les couches de dépôt, les gestionnaires de routes d'API, les consommateurs de files de messages, les migrations de base et tout code qui sérialise ou désérialise des données au passage d'une frontière. Le prix, c'est la vitesse — un test qui démarre un conteneur Postgres prend des secondes au lieu de millisecondes — mais les bogues qu'ils attrapent coûtent d'autant plus cher à découvrir en production.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";
describe("OrderRepository", () => {
let db: Awaited<ReturnType<typeof createDatabase>>;
let repo: OrderRepository;
beforeAll(async () => {
db = await createDatabase();
repo = new OrderRepository(db);
});
afterAll(async () => {
await teardownDatabase(db);
});
it("persists and retrieves an order", async () => {
const order = {
id: "ord_001",
customerId: "cus_001",
total: 1250,
status: "pending" as const,
createdAt: new Date("2025-06-01"),
};
await repo.save(order);
const retrieved = await repo.findById("ord_001");
expect(retrieved).toEqual(order);
});
it("returns null for a non-existent order", async () => {
const result = await repo.findById("ord_nonexistent");
expect(result).toBeNull();
});
it("updates order status", async () => {
await repo.save({
id: "ord_002",
customerId: "cus_001",
total: 500,
status: "pending",
createdAt: new Date(),
});
await repo.updateStatus("ord_002", "shipped");
const updated = await repo.findById("ord_002");
expect(updated?.status).toBe("shipped");
});
});La règle essentielle est d'isoler l'état de la base entre les exécutions. Chaque suite devrait créer son propre schéma ou sa propre base, appliquer les migrations, exécuter les tests puis tout démonter. Travailler contre une base partagée produit des tests instables, où les données de l'un fuient dans les vérifications de l'autre. Utilisez Testcontainers, Docker Compose ou le support de base de test de votre framework pour que chaque exécution parte d'un état connu.
Un test d'intégration avec une vraie base vaut cent tests unitaires qui la simulent. La doublure vous dit ce que vous attendez de la base. La vraie base vous dit ce qu'elle fait réellement. Après la première migration de schéma, les deux coïncident rarement.
Tests de bout en bout avec Playwright
Les tests de bout en bout simulent de vraies interactions à travers toute la pile : navigateur, frontal, API, base de données et services externes. Ce sont les plus lents et les plus coûteux, mais ils attrapent les bogues les plus réalistes : navigation cassée, validation de formulaire manquante, réponse d'API mal affichée, échec du parcours d'authentification, différences de rendu entre navigateurs.
Playwright s'est imposé grâce à sa prise en charge de plusieurs navigateurs, ses assertions à attente automatique, l'interception réseau et son confort d'utilisation. Contrairement aux outils antérieurs qui exigeaient attentes et reprises explicites, Playwright attend qu'un élément soit réellement actionnable avant d'interagir, ce qui supprime la cause la plus courante d'instabilité.
import { test, expect } from "@playwright/test";
test("user completes a purchase flow", async ({ page }) => {
await page.goto("/products");
await page.getByRole("link", { name: "Wireless Headphones" }).click();
await page.getByRole("button", { name: "Add to Cart" }).click();
await page.getByRole("button", { name: "View Cart" }).click();
await expect(page.getByText("Wireless Headphones")).toBeVisible();
await expect(page.getByText("$89.99")).toBeVisible();
await page.getByRole("button", { name: "Checkout" }).click();
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Card Number").fill("4242424242424242");
await page.getByLabel("Expiry").fill("12/28");
await page.getByLabel("CVC").fill("123");
await page.getByRole("button", { name: "Pay Now" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
await expect(page.getByText("ord_001")).toBeVisible();
});La plus grosse erreur est d'en écrire trop. Chaque test de bout en bout ajoute des minutes à votre chaîne d'intégration, et une suite de 200 peut facilement prendre une demi-heure. Leur économie impose la sélectivité. Concentrez-vous sur les parcours décisifs — inscription, connexion, achat, recherche, paiement — et laissez les niveaux inférieurs couvrir les cas limites et les états d'erreur.
- Priorisez les parcours qui touchent au chiffre d'affaires : paiement, montée en gamme d'abonnement, traitement des règlements.
- Gardez ces tests indépendants. Chacun crée ses propres données et nettoie après lui.
- Utilisez des appels d'API dans la préparation plutôt que l'interface, pour atteindre plus vite l'état de départ.
- Exécutez-les sur un environnement de préproduction ou d'aperçu, pas en production. Pilotez la visibilité des données de test par des indicateurs de fonctionnalité.
- Investissez dans l'observabilité : captures, traces et enregistrements vidéo des échecs font gagner des heures de débogage.
Le développement piloté par les tests en pratique
Le développement piloté par les tests ne parle pas de test, mais de conception. Le cycle rouge, vert, refactorisation vous force à réfléchir à l'interface de votre code avant de l'implémenter. Écrire le test d'abord vous oblige à répondre : que doit accepter cette fonction, et que doit-elle renvoyer ? Cette contrainte mène à de meilleures API, à un couplage plus lâche et à un code plus modulaire.
Les réticences viennent généralement de deux sources. Soit quelqu'un a essayé sur un problème qui ne s'y prêtait pas (code d'interface, travail exploratoire), soit la méthode a été appliquée avec dogmatisme, et l'on a passé plus de temps à se battre avec l'outil qu'à écrire du code utile. C'est un outil, pas une religion. Elle fonctionne au mieux sur la logique algorithmique, les transformations de données, la conception d'API et tout code dont le contrat entrée-sortie est clair d'emblée.
Un flux pratique ressemble à ceci : écrivez un seul test qui décrit le prochain comportement voulu. Exécutez-le et regardez-le échouer — cela confirme qu'il est valide et qu'il détecte l'absence de la fonctionnalité. Écrivez le minimum de code pour le faire passer, sans surenchère : l'implémentation la plus simple qui satisfait le test est la bonne. Puis améliorez la structure sans changer le comportement, en vous appuyant sur les tests qui passent pour repérer les régressions.
Le développement piloté par les tests ne vous fait pas écrire plus de tests. Il vous fait écrire un meilleur code. Le test est un effet secondaire de la conception, pas le but. Écrire des tests après l'implémentation, c'est tester. Les écrire avant, c'est concevoir.
La méthode est particulièrement efficace pour corriger des bogues. Quand un bogue est signalé, écrivez un test qui le reproduit (rouge), corrigez le code (vert), puis vérifiez que la correction ne casse rien (tous les tests passent encore). Ce test devient une protection permanente contre la récidive. Avec le temps, la suite reflète l'histoire réelle des bogues du projet, ce qui vaut bien plus qu'une suite fabriquée pour atteindre un objectif de couverture.
Tests par propriétés et fuzzing
Les tests classiques par l'exemple vérifient des entrées précises contre des sorties attendues. Les tests par propriétés procèdent autrement : ils définissent une propriété qui doit être vraie pour toutes les entrées, puis engendrent des centaines ou des milliers d'entrées aléatoires pour la vérifier. Cette technique attrape des cas limites qu'aucun humain n'aurait songé à écrire.
Par exemple, au lieu de cinq cas précis pour une fonction de tri, vous définissez une propriété : pour toute liste d'éléments comparables, le résultat doit contenir les mêmes éléments dans un ordre non décroissant. Le cadre engendre des listes aléatoires de tailles variées, avec doublons, listes vides et valeurs extrêmes, et vérifie la propriété sur chacune.
import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";
describe("sortByPrice", () => {
it("returns items in ascending price order for any input", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
for (let j = 1; j < sorted.length; j++) {
expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
}
}
});
it("preserves all original elements", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
expect(sorted).toHaveLength(items.length);
expect(sorted.map((i) => i.name).sort()).toEqual(
items.map((i) => i.name).sort()
);
}
});
});Fast-check est une bibliothèque répandue de tests par propriétés pour TypeScript, intégrée à Vitest et Jest. Elle fournit des générateurs pour les types courants, des combinateurs pour les structures complexes et une réduction automatique : quand un cas d'échec est trouvé, la bibliothèque tente de le ramener à la plus petite entrée qui échoue encore, ce qui simplifie beaucoup le débogage.
L'approche vaut surtout pour la sérialisation et la désérialisation, les algorithmes de tri et de filtrage, les règles de validation, les transitions d'automates et toute fonction dont l'espace d'entrées est vaste. Combinés à des techniques de fuzzing qui injectent des données malformées ou inattendues, ces tests révèlent des failles de sécurité et des plantages qu'on ne découvrirait sinon qu'en production.
Tests de régression visuelle
Les tests fonctionnels vérifient que l'application se comporte bien. Les tests de régression visuelle vérifient qu'elle a bonne allure. Des changements de CSS, des mises à jour de dépendances et des composants refondus peuvent introduire de légers décalages, des écarts de couleur, des changements de police ou des problèmes aux points de rupture qu'aucun test fonctionnel ne détecte. La régression visuelle capture des images de composants ou de pages, les compare à des références et signale toute différence pour relecture humaine.
Des outils comme Chromatic, Percy et la capture intégrée de Playwright mettent cela en œuvre à différents niveaux. Chromatic s'intègre à Storybook et compare pixel par pixel avec une logique qui ignore l'anticrénelage et les variations de sous-pixels. Percy offre des capacités proches avec un support plus large de frameworks. L'assertion toHaveScreenshot de Playwright permet d'écrire des vérifications visuelles directement dans vos tests de bout en bout, sans outil supplémentaire.
import { test, expect } from "@playwright/test";
test("product page renders correctly", async ({ page }) => {
await page.goto("/products/wireless-headphones");
await expect(page).toHaveScreenshot("product-page.png", {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
fullPage: true,
});
});
test("checkout form is visually stable across states", async ({ page }) => {
await page.goto("/checkout");
await expect(page).toHaveScreenshot("checkout-empty.png");
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveScreenshot("checkout-validation-error.png");
});La principale difficulté est la gestion des images de référence. Tout changement visuel voulu impose de les mettre à jour, et la validation peut devenir un goulot d'étranglement dans une équipe qui touche souvent à l'interface. La solution consiste à traiter cette mise à jour comme une étape du travail : approuver les différences visuelles dans le même cycle de relecture que le code, via une plateforme intégrée au flux des demandes de fusion.
- Commencez par les pages critiques : page d'accueil, paiement, connexion, fiche produit et toute page à mise en page complexe.
- Fixez des seuils adaptés pour ignorer l'anticrénelage et les différences de rendu de police entre systèmes.
- Utilisez des captures au niveau des composants (via Storybook) pour une couverture ciblée, plutôt que des captures pleine page sensibles au moindre changement.
- Intégrez la relecture visuelle au flux des demandes de fusion. Exigez une validation humaine pour les différences sur les pages modifiées.
- Exécutez les tests visuels dans un environnement uniforme (Docker) afin d'éliminer les écarts de rendu entre machines.
Tester les composants serveur de React et Next.js
Les composants serveur de React ont changé le paysage des tests. Ils s'exécutent uniquement sur le serveur et n'envoient aucun JavaScript au client. Ils peuvent accéder directement aux bases de données, aux systèmes de fichiers et aux services dorsaux sans exposer cette logique au navigateur. Cela permet de les tester autrement que les composants client.
Ce sont au fond des fonctions asynchrones qui renvoient du JSX. Vous pouvez les tester comme n'importe quelle fonction asynchrone : les appeler avec des propriétés, attendre le résultat et vérifier la sortie. Pas d'environnement navigateur, pas de doublure pour fetch ni pour les appels à la base si vous travaillez contre une vraie base de test. Cette simplicité est l'un des avantages sous-estimés des composants serveur : la préparation est nettement plus légère que pour des composants client, qui exigent un DOM.
import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";
describe("ProductList (Server Component)", () => {
it("renders products from the database", async () => {
// No mocking needed - the component calls the database directly
const { props } = await ProductList({ category: "electronics" });
expect(props.products.length).toBeGreaterThan(0);
expect(props.products.every((p) => p.category === "electronics")).toBe(
true
);
});
it("shows empty state when no products match", async () => {
const { props } = await ProductList({ category: "nonexistent" });
expect(props.products).toHaveLength(0);
});
});Les composants client qui utilisent useState, useEffect, des API du navigateur ou des gestionnaires d'événements ont besoin d'un environnement DOM. Vitest propose happy-dom ou jsdom pour simuler un navigateur. Testing Library fournit de quoi interroger la sortie rendue et simuler les interactions. La différence avec les composants serveur : il faut rendre le composant, attendre l'exécution des effets et vérifier l'état du DOM obtenu.
Les applications Next.js ajoutent le routage, la récupération de données et les actions serveur, qui demandent des précautions supplémentaires. Pour les pages qui utilisent getServerSideProps ou generateStaticParams, testez les fonctions de récupération indépendamment du composant. Testez les actions serveur comme de simples fonctions asynchrones : elles ne sont rien d'autre. Pour les mises en page et pages de l'App Router, testez la couche de données séparément de la présentation, afin de ne pas coupler vos tests aux rouages de rendu du framework.
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";
describe("CheckoutForm", () => {
it("shows validation errors for empty required fields", async () => {
const user = userEvent.setup();
render(<CheckoutForm />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(screen.getByText("Email is required")).toBeVisible();
expect(screen.getByText("Card number is required")).toBeVisible();
});
it("submits the form with valid data", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<CheckoutForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "test@example.com");
await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
await user.type(screen.getByLabelText("Expiry"), "12/28");
await user.type(screen.getByLabelText("CVC"), "123");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
cardNumber: "4242424242424242",
expiry: "12/28",
cvc: "123",
});
});
});La distinction la plus importante dans le nouveau modèle React est de séparer les tests de composants serveur et de composants client. Les premiers sont de pures fonctions asynchrones : testez-les sans DOM. Les seconds demandent de simuler l'interaction : testez-les avec Testing Library et userEvent. Mélanger les deux rend vos tests plus lents et plus compliqués que nécessaire.
Stratégies de simulation et quand s'en passer
La simulation est l'un des sujets les plus débattus. Les doublures remplacent les dépendances réelles par des substituts contrôlés et permettent de tester du code isolément, sans monter de base, appeler d'API externe ni attendre de minuteur. En contrepartie, vous vérifiez votre code contre une version simulée de la dépendance, pas contre la vraie. Si la doublure ne reflète pas fidèlement le comportement réel, le test passe alors que le code échoue.
La règle générale est de simuler à la frontière du système. Remplacez les services externes que vous ne maîtrisez pas (API tierces, prestataires de paiement, envoi de courriels) et l'infrastructure coûteuse ou non déterministe (bases de données, systèmes de fichiers, horloges). Ne simulez pas vos modules internes, vos fonctions utilitaires ni vos objets métier : ceux-là se testent avec leurs implémentations réelles, en intégration.
Vitest propose vi.mock, qui remonte les déclarations en tête de fichier et remplace les implémentations de modules avant l'exécution. Utile pour les variables d'environnement, les SDK tiers et les objets globaux. Le risque : la simulation au niveau du module est invisible et peut produire des tests qui passent isolément mais échouent ensemble à cause d'interférences.
import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";
const mockStripe = {
charges: {
create: vi.fn(),
},
};
vi.mock("stripe", () => ({
default: vi.fn(() => mockStripe),
}));
describe("PaymentService", () => {
it("creates a charge successfully", async () => {
mockStripe.charges.create.mockResolvedValue({
id: "ch_001",
status: "succeeded",
});
const service = new PaymentService();
const result = await service.charge(5000, "tok_visa");
expect(result.status).toBe("succeeded");
expect(mockStripe.charges.create).toHaveBeenCalledWith({
amount: 5000,
currency: "usd",
source: "tok_visa",
});
});
it("throws on failed payment", async () => {
mockStripe.charges.create.mockRejectedValue(
new Error("card_declined")
);
const service = new PaymentService();
await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
"card_declined"
);
});
});Une solution plus sûre consiste à utiliser des doublures qui implémentent la même interface que la dépendance réelle, mais en mémoire et de façon simplifiée. Plutôt que de simuler le module de base de données, écrivez une version en mémoire de votre dépôt qui stocke les données dans une Map. Cette doublure reste légère tout en étant fidèle, et emprunte les mêmes chemins de code que la vraie base, sans son coût de mise en place.
Chaque doublure creuse un écart entre vos tests et la réalité. C'est dans cet écart que se cachent les bogues. Avant d'y recourir, demandez-vous : puis-je tester cela avec la vraie dépendance dans un environnement maîtrisé ? Si oui, prenez le vrai. Si non — trop lent, trop coûteux, non déterministe —, simulez, mais étroitement, à la frontière du système.
- Simulez aux frontières : API tierces, SDK, passerelles de paiement, services externes que vous ne pouvez pas exécuter localement.
- Utilisez des doublures en mémoire pour vos propres abstractions (dépôts, caches, files) plutôt que de les simuler.
- Ne simulez pas ce qui ne vous appartient pas : simuler les rouages d'une bibliothèque couple vos tests à des détails d'implémentation.
- Préférez l'injection de dépendances à la simulation au niveau du module. Passer les dépendances explicitement rend la préparation transparente.
- Limitez la portée d'une doublure au test qui en a besoin. Réinitialisez toutes les doublures entre les tests.
Tester dans les chaînes CI/CD
Une suite de tests ne vaut que si elle s'exécute de façon fiable et donne un retour rapide. Des tests lents, instables ou qui ne tournent que sur les machines des développeurs sont un fardeau. Les intégrer à la chaîne CI/CD garantit qu'ils s'exécutent à chaque demande de fusion, à chaque intégration dans la branche principale et, pour les chemins critiques, avant chaque livraison.
La pyramide se traduit naturellement en étapes de chaîne. Les tests unitaires passent d'abord, à chaque envoi, parce qu'ils sont rapides et attrapent les erreurs de logique élémentaires. Viennent ensuite les tests d'intégration, en parallèle si possible, avec des dépendances conteneurisées. Les tests de bout en bout arrivent en dernier, uniquement sur les demandes de fusion et avant les livraisons, car ils sont lents et coûteux. Les tests visuels tournent à leurs côtés et comparent les captures à la référence de la branche principale.
La durée compte. Une suite de 45 minutes pousse à ne plus tester localement et à envoyer du code cassé. Quelques stratégies : n'exécuter que les tests touchés par le changement en cours, répartir les suites sur des exécuteurs parallèles, mettre en cache node_modules et les navigateurs de Playwright entre les exécutions, et planifier les tests de bout en bout lents plutôt que de les lancer à chaque commit.
# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx vitest run --project unit
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run --project integration
e2e:
needs: [unit, integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright testLes tests instables sont la première menace pour la confiance dans la chaîne. Un test qui échoue par intermittence, sans lien avec les changements de code, érode la confiance dans tout l'édifice. L'équipe se met à ignorer les échecs, à fusionner malgré le rouge, et la suite finit en bruit de fond. Investissez dans leur détection : marquez-les, mettez-les en quarantaine, corrigez-les ou supprimez-les en priorité. Un test auquel on ne peut pas se fier est pire que pas de test du tout, parce qu'il installe l'habitude d'ignorer les échecs.
Le rapport de résultats est une autre pratique sous-estimée. Une sortie qui annonce « 3 tests en échec » sans contexte oblige à fouiller les journaux pour retrouver le message d'erreur. Un bon rapport montre l'assertion en échec, les valeurs attendues et obtenues, le fichier et la ligne, ainsi que le contexte utile : captures, traces d'appel. Les rapports HTML de Vitest et Playwright, ou les annotations de GitHub Actions, rendent les résultats visibles directement dans le flux des demandes de fusion.
Le débat entre le trophée et la pyramide
La pyramide classique — tests unitaires à la base, intégration au milieu, bout en bout au sommet — guide la stratégie de test depuis vingt ans. Elle transmet une idée simple : beaucoup de tests rapides et isolés, moins de tests d'intégration plus lents, et très peu de tests de bout en bout. Le modèle convient bien aux services dorsaux, où l'unité de livraison est une fonction ou une classe aux frontières nettes.
Le trophée de test a été proposé comme modèle alternatif, plus fidèle au développement frontal moderne. Il remodèle la pyramide en losange, où les tests d'intégration forment la couche la plus large, avec moins de tests unitaires et moins de bout en bout. L'argument : pour une application frontale, la valeur vient surtout de vérifier comment les composants collaborent — précisément ce que font les tests d'intégration — et non de tester des fonctions isolées ou des parcours complets.
Le trophée traduit une réalité pratique : les tests les plus utiles d'une application React sont ceux qui rendent un composant, interagissent avec lui et vérifient le résultat. Ils sollicitent ensemble le composant, ses enfants, ses hooks, sa gestion d'état et ses appels d'API. Ils sont plus rapides que le bout en bout et plus réalistes que l'unitaire. Une suite de bons tests d'intégration donne plus de confiance par test que l'un ou l'autre dans un contexte frontal.
En réalité, les deux modèles simplifient une vérité plus complexe. La bonne stratégie dépend de votre architecture. Un microservice dorsal à forte logique métier profite de la pyramide : couverture unitaire profonde du domaine, moins de tests d'intégration pour la couche API, quelques tests de contrat pour les services externes. Une application frontale aux interactions complexes profite du trophée : large couverture d'intégration des composants et des pages, tests unitaires ciblés pour les utilitaires, et tests de bout en bout sur les chemins critiques.
La pyramide et le trophée ont tous deux tort si on les suit avec dogmatisme. La bonne stratégie est celle qui attrape vos bogues, tourne assez vite pour votre équipe et survit à votre architecture. Cela peut ressembler à une pyramide, à un trophée ou à une forme que personne n'a encore nommée. Cessez de débattre de la forme et regardez ce que vos tests attrapent vraiment.
L'idée importante derrière les deux modèles, c'est que chaque niveau a son profil de coût et de bénéfice. Un test unitaire coûte des millisecondes à écrire et à exécuter. Un test d'intégration coûte des secondes. Un test de bout en bout coûte des minutes. Répartissez votre budget de test en proportion de la valeur que chaque niveau apporte à votre application. Si c'est un tableau de bord riche en logique métier, investissez dans l'unitaire et l'intégration. Si c'est un site de contenu peu interactif, les tests de bout en bout sur les parcours clés et la régression visuelle pour la mise en page seront le meilleur placement.
Construire une culture du test
La meilleure stratégie échoue si l'équipe n'y croit pas. Une culture du test ne s'impose pas par des seuils de couverture ni par du TDD obligatoire. Elle naît quand écrire des tests devient le chemin naturel de moindre résistance. On écrit des tests quand ils sont faciles à écrire, rapides à exécuter et manifestement utiles. Quand ils sont cassants, lents et pleins de faux échecs, on trouve des moyens de les éviter.
Commencez par rendre l'expérience excellente. Investissez dans l'infrastructure : exécuteurs rapides, bases de test fiables, détection des tests instables. Écrivez des utilitaires qui rendent concises les vérifications courantes. Documentez les motifs dans un guide d'équipe, pour que chacun sache tester un nouveau point d'API, un composant React ou une migration sans repartir de zéro. Bâtir une bonne infrastructure une fois coûte bien moins cher que le combat quotidien contre de mauvais tests.
La relecture de code doit inclure la relecture des tests. Il faut se demander : ce test couvre-t-il vraiment le comportement qu'il prétend couvrir ? Pourrait-il passer même si l'implémentation était fausse ? Teste-t-il la bonne chose au bon niveau ? Est-il lisible et maintenable ? Traitez les tests comme du code de premier ordre, soumis aux mêmes exigences de relecture, de style et de qualité que le code de production.
Les indicateurs de couverture sont un bon outil de diagnostic et un très mauvais objectif. Fixez 90 pour cent et vous obtiendrez 90 pour cent — pas de meilleurs tests. On écrira des tests triviaux sur des accesseurs et des constructeurs vides pour atteindre la cible, sans renforcer le filet de sécurité. Servez-vous des rapports pour repérer les chemins non testés, pas pour imposer des seuils arbitraires. 70 pour cent avec des tests d'intégration bien placés valent mieux que 95 pour cent de tests unitaires superficiels.
- Rendez l'écriture facile : utilitaires, fabriques et aides qui réduisent le code répétitif dans chaque fichier de test.
- Célébrez les améliorations de tests comme des livraisons de fonctionnalités. Un test qui élimine une classe de bogues est une fonctionnalité.
- Faites tourner la responsabilité de l'infrastructure de test, pour que le savoir se partage au lieu de se concentrer sur une personne.
- Tenez régulièrement une revue des tests instables. Suivez leur nombre comme indicateur de santé de l'équipe et corrigez en priorité.
- Rédigez un manifeste de test d'équipe, qui définit quoi tester à chaque niveau, quoi ne pas tester, et les compromis retenus.
Le but ultime d'une culture du test n'est ni un chiffre de couverture ni une chaîne parfaite. C'est la confiance. Confiance qu'une refactorisation ne cassera pas la production. Confiance qu'une livraison le vendredi après-midi est sans danger. Confiance qu'un bogue signalé, une fois corrigé, le restera. Une stratégie qui donne cette confiance à l'équipe est une bonne stratégie, qu'elle ait la forme d'une pyramide, d'un trophée ou d'autre chose.
Pour tout rassembler
Bâtir une stratégie de test efficace ne consiste pas à choisir une approche contre une autre, mais à comprendre les compromis et à appliquer le bon outil à chaque situation. Les tests unitaires attrapent les erreurs de logique en millisecondes. Les tests d'intégration attrapent les bogues d'interaction avec de vraies dépendances. Les tests de bout en bout attrapent les défaillances visibles par l'utilisateur sur toute la pile. Les tests par propriétés attrapent des cas limites dont vous ignoriez l'existence. La régression visuelle attrape les changements d'apparence non voulus.
Les équipes qui livrent de façon fiable ne sont pas celles qui affichent la plus forte couverture. Ce sont celles qui ont réfléchi à ce qu'apporte chaque niveau, aligné leur investissement sur leur profil de risque et bâti une culture où l'on écrit des tests parce qu'ils apportent de la valeur, pas parce qu'une règle l'exige. Commencez par auditer votre suite actuelle. Pour chaque test, demandez : justifie-t-il son existence ? Si la réponse n'est pas un oui franc, supprimez-le ou remplacez-le. La meilleure suite n'est pas la plus grosse, c'est celle qui donne le plus de confiance par minute d'exécution.