e2e-testing-patterns

Par wshobson · agents

Maîtrisez les tests end-to-end avec Playwright et Cypress pour créer des suites de tests fiables qui détectent les bugs, renforcent la confiance et permettent des déploiements rapides. À utiliser lors de l'implémentation de tests E2E, du débogage de tests instables ou de l'établissement de standards de test.

npx skills add https://github.com/wshobson/agents --skill e2e-testing-patterns

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

  1. Utiliser les Attributs de Données : data-testid ou data-cy pour les sélecteurs stables
  2. Éviter les Sélecteurs Fragiles : Ne pas se fier aux classes CSS ou à la structure du DOM
  3. Tester le Comportement Utilisateur : Cliquer, taper, voir - pas les détails d'implémentation
  4. Garder les Tests Indépendants : Chaque test doit fonctionner de manière isolée
  5. Nettoyer les Données de Test : Créer et supprimer les données de test pour chaque test
  6. Utiliser les Page Objects : Encapsuler la logique de page
  7. Assertions Significatives : Vérifier le comportement réellement visible par l'utilisateur
  8. 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

Skills similaires