Patterns de Test E2E
Construisez des suites de tests end-to-end fiables, rapides et maintenables qui donnent la confiance nécessaire pour déployer du code rapidement et détecter les régressions avant vos utilisateurs.
Quand Utiliser Cette Compétence
- Implémenter l'automatisation des tests end-to-end
- Déboguer les tests instables ou peu fiables
- Tester les workflows utilisateur critiques
- Configurer les pipelines de test CI/CD
- Tester sur plusieurs navigateurs
- Valider les exigences d'accessibilité
- Tester les designs responsive
- Établir des standards de test E2E
Concepts Fondamentaux
1. Fondamentaux du Test E2E
Ce qu'il faut tester avec E2E :
- Parcours utilisateur critiques (connexion, paiement, inscription)
- Interactions complexes (drag-and-drop, formulaires multi-étapes)
- Compatibilité cross-browser
- Intégration d'API réelle
- Flux d'authentification
Ce qu'il ne FAUT PAS tester avec E2E :
- Logique au niveau des unités (utiliser les tests unitaires)
- Contrats d'API (utiliser les tests d'intégration)
- Cas limites (trop lent)
- Détails d'implémentation interne
2. Philosophie des Tests
La Pyramide des Tests :
/\
/E2E\ ← Peu, focalisés sur les chemins critiques
/─────\
/Integr\ ← Plus, testent les interactions de composants
/────────\
/Unit Tests\ ← Nombreux, rapides, isolés
/────────────\
Bonnes Pratiques :
- Tester le comportement utilisateur, pas l'implémentation
- Garder les tests indépendants
- Rendre les tests déterministes
- Optimiser pour la vitesse
- Utiliser data-testid, pas les sélecteurs CSS
Patterns Playwright
Configuration et Initialisation
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["junit", { outputFile: "results.xml" }]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 13"] } },
],
});
Pattern 1 : Page Object Model
// pages/LoginPage.ts
import { Page, Locator } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.loginButton = page.getByRole("button", { name: "Login" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage(): Promise<string> {
return (await this.errorMessage.textContent()) ?? "";
}
}
// Test utilisant Page Object
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
test("successful login", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "password123");
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
test("failed login shows error", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("invalid@example.com", "wrong");
const error = await loginPage.getErrorMessage();
expect(error).toContain("Invalid credentials");
});
Pattern 2 : Fixtures pour les Données de Test
// fixtures/test-data.ts
import { test as base } from "@playwright/test";
type TestData = {
testUser: {
email: string;
password: string;
name: string;
};
adminUser: {
email: string;
password: string;
};
};
export const test = base.extend<TestData>({
testUser: async ({}, use) => {
const user = {
email: `test-${Date.now()}@example.com`,
password: "Test123!@#",
name: "Test User",
};
// Setup: Créer l'utilisateur en base de données
await createTestUser(user);
await use(user);
// Teardown: Nettoyer l'utilisateur
await deleteTestUser(user.email);
},
adminUser: async ({}, use) => {
await use({
email: "admin@example.com",
password: process.env.ADMIN_PASSWORD!,
});
},
});
// Utilisation dans les tests
import { test } from "./fixtures/test-data";
test("user can update profile", async ({ page, testUser }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
await page.getByLabel("Password").fill(testUser.password);
await page.getByRole("button", { name: "Login" }).click();
await page.goto("/profile");
await page.getByLabel("Name").fill("Updated Name");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Profile updated")).toBeVisible();
});
Pattern 3 : Stratégies d'Attente
// ❌ Mauvais : Timeouts fixes
await page.waitForTimeout(3000); // Instable !
// ✅ Bon : Attendre des conditions spécifiques
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");
await page.waitForSelector('[data-testid="user-profile"]');
// ✅ Meilleur : Auto-attente avec assertions
await expect(page.getByText("Welcome")).toBeVisible();
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();
// Attendre une réponse API
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes("/api/users") && response.status() === 200,
);
await page.getByRole("button", { name: "Load Users" }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.users).toHaveLength(10);
// Attendre plusieurs conditions
await Promise.all([
page.waitForURL("/success"),
page.waitForLoadState("networkidle"),
expect(page.getByText("Payment successful")).toBeVisible(),
]);
Pattern 4 : Mocking et Interception Réseau
// Mocker les réponses API
test("displays error when API fails", async ({ page }) => {
await page.route("**/api/users", (route) => {
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal Server Error" }),
});
});
await page.goto("/users");
await expect(page.getByText("Failed to load users")).toBeVisible();
});
// Intercepter et modifier les requêtes
test("can modify API request", async ({ page }) => {
await page.route("**/api/users", async (route) => {
const request = route.request();
const postData = JSON.parse(request.postData() || "{}");
// Modifier la requête
postData.role = "admin";
await route.continue({
postData: JSON.stringify(postData),
});
});
// Le test continue...
});
// Mocker les services tiers
test("payment flow with mocked Stripe", async ({ page }) => {
await page.route("**/api/stripe/**", (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
id: "mock_payment_id",
status: "succeeded",
}),
});
});
// Tester le flux de paiement avec réponse mockée
});
Patterns Cypress
Configuration et Initialisation
// cypress.config.ts
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
setupNodeEvents(on, config) {
// Implémenter les listeners d'événements node
},
},
});
Pattern 1 : Commandes Personnalisées
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
createUser(userData: UserData): Chainable<User>;
dataCy(value: string): Chainable<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add("login", (email: string, password: string) => {
cy.visit("/login");
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should("include", "/dashboard");
});
Cypress.Commands.add("createUser", (userData: UserData) => {
return cy.request("POST", "/api/users", userData).its("body");
});
Cypress.Commands.add("dataCy", (value: string) => {
return cy.get(`[data-cy="${value}"]`);
});
// Utilisation
cy.login("user@example.com", "password");
cy.dataCy("submit-button").click();
Pattern 2 : Cypress Intercept
// Mocker les appels API
cy.intercept("GET", "/api/users", {
statusCode: 200,
body: [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
],
}).as("getUsers");
cy.visit("/users");
cy.wait("@getUsers");
cy.get('[data-testid="user-list"]').children().should("have.length", 2);
// Modifier les réponses
cy.intercept("GET", "/api/users", (req) => {
req.reply((res) => {
// Modifier la réponse
res.body.users = res.body.users.slice(0, 5);
res.send();
});
});
// Simuler un réseau lent
cy.intercept("GET", "/api/data", (req) => {
req.reply((res) => {
res.delay(3000); // 3 secondes de délai
res.send();
});
});
Patterns Avancés
Pattern 1 : Test de Régression Visuelle
// Avec Playwright
import { test, expect } from "@playwright/test";
test("homepage looks correct", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test("button in all states", async ({ page }) => {
await page.goto("/components");
const button = page.getByRole("button", { name: "Submit" });
// État par défaut
await expect(button).toHaveScreenshot("button-default.png");
// État hover
await button.hover();
await expect(button).toHaveScreenshot("button-hover.png");
// État disabled
await button.evaluate((el) => el.setAttribute("disabled", "true"));
await expect(button).toHaveScreenshot("button-disabled.png");
});
Pattern 2 : Test Parallèle avec Sharding
// playwright.config.ts
export default defineConfig({
projects: [
{
name: "shard-1",
use: { ...devices["Desktop Chrome"] },
grepInvert: /@slow/,
shard: { current: 1, total: 4 },
},
{
name: "shard-2",
use: { ...devices["Desktop Chrome"] },
shard: { current: 2, total: 4 },
},
// ... plus de shards
],
});
// Exécuter en CI
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
Pattern 3 : Test d'Accessibilité
// Installer : npm install @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("page should not have accessibility violations", async ({ page }) => {
await page.goto("/");
const accessibilityScanResults = await new AxeBuilder({ page })
.exclude("#third-party-widget")
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test("form is accessible", async ({ page }) => {
await page.goto("/signup");
const results = await new AxeBuilder({ page }).include("form").analyze();
expect(results.violations).toEqual([]);
});
Bonnes Pratiques
- Utiliser les Attributs de Données :
data-testidoudata-cypour les sélecteurs stables - Éviter les Sélecteurs Fragiles : Ne pas se fier aux classes CSS ou à la structure du DOM
- Tester le Comportement Utilisateur : Cliquer, taper, voir - pas les détails d'implémentation
- Garder les Tests Indépendants : Chaque test doit fonctionner de manière isolée
- Nettoyer les Données de Test : Créer et supprimer les données de test pour chaque test
- Utiliser les Page Objects : Encapsuler la logique de page
- Assertions Significatives : Vérifier le comportement réellement visible par l'utilisateur
- Optimiser pour la Vitesse : Mocker si possible, exécution parallèle
// ❌ Mauvais sélecteurs
cy.get(".btn.btn-primary.submit-button").click();
cy.get("div > form > div:nth-child(2) > input").type("text");
// ✅ Bon sélecteurs
cy.getByRole("button", { name: "Submit" }).click();
cy.getByLabel("Email address").type("user@example.com");
cy.get('[data-testid="email-input"]').type("user@example.com");
Pièges Courants
- Tests Instables : Utiliser les attentes appropriées, pas les timeouts fixes
- Tests Lents : Mocker les API externes, utiliser l'exécution parallèle
- Sur-test : Ne pas tester tous les cas limites avec E2E
- Tests Couplés : Les tests ne doivent pas dépendre les uns des autres
- Mauvais Sélecteurs : Éviter les classes CSS et nth-child
- Pas de Nettoyage : Nettoyer les données de test après chaque test
- Test d'Implémentation : Tester le comportement utilisateur, pas l'interne
Déboguer les Tests Défaillants
// Débogage Playwright
// 1. Exécuter en mode headed
npx playwright test --headed
// 2. Exécuter en mode debug
npx playwright test --debug
// 3. Utiliser le lecteur de trace
await page.screenshot({ path: 'screenshot.png' });
await page.video()?.saveAs('video.webm');
// 4. Ajouter test.step pour un meilleur rapport
test('checkout flow', async ({ page }) => {
await test.step('Add item to cart', async () => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).click();
});
await test.step('Proceed to checkout', async () => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
});
});
// 5. Inspecter l'état de la page
await page.pause(); // Pause l'exécution, ouvre l'inspecteur