PlaywrightTestingNext.jsE2ETypeScript

Tests E2E avec Playwright et Next.js : testez votre site comme un vrai utilisateur

Mettez en place des tests end-to-end fiables avec Playwright sur Next.js : configuration, premiers tests, mocking d'API et intégration CI.

AMAlexis Mouchon5 min de lecture

Vos tests unitaires passent au vert, et pourtant votre formulaire de contact est cassé en production depuis trois jours. Ça vous parle ? C'est la limite des tests unitaires : ils vérifient que chaque pièce fonctionne isolément, pas que l'ensemble tient debout. Les tests end-to-end (E2E) comblent exactement ce trou — et Playwright est aujourd'hui l'outil le plus solide pour les écrire.

Dans cet article, je vous montre comment mettre en place Playwright sur un projet Next.js : de l'installation à l'intégration en CI, en passant par les patterns que j'utilise en production pour garder des tests rapides et non flaky.

Pourquoi Playwright plutôt que Cypress ?

J'ai longtemps utilisé Cypress, mais Playwright l'a remplacé sur tous mes projets. Trois raisons concrètes.

Le multi-navigateurs natif. Playwright pilote Chromium, Firefox et WebKit (le moteur de Safari) avec la même API. Tester Safari sans Mac dans votre CI, c'est possible — et vu la part de trafic iOS sur la plupart des sites, ce n'est pas un luxe.

La parallélisation par défaut. Chaque fichier de test tourne dans un worker isolé. Une suite de 50 tests qui prendrait 10 minutes en séquentiel passe en 2-3 minutes sans configuration particulière.

L'auto-waiting intelligent. Playwright attend automatiquement qu'un élément soit visible, stable et interactif avant d'agir dessus. Fini les cy.wait(2000) arbitraires qui rendent les suites lentes et fragiles.

Ajoutez à cela une API TypeScript excellente, un mode UI pour déboguer visuellement, et le trace viewer qui rejoue chaque test étape par étape — le choix est vite fait.

Installation et configuration sur Next.js

Installons Playwright à la racine du projet :

pnpm create playwright

Le CLI génère un dossier e2e/ (ou tests/, à votre choix) et un fichier playwright.config.ts. Voici la configuration que j'utilise sur mes projets Next.js :

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? "github" : "html",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "mobile", use: { ...devices["iPhone 14"] } },
  ],
  webServer: {
    command: "pnpm build && pnpm start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Deux points méritent votre attention.

Le bloc webServer est la pièce maîtresse pour Next.js : Playwright démarre lui-même votre application avant les tests et la coupe à la fin. En local, reuseExistingServer: true réutilise votre pnpm dev déjà lancé — pratique pour itérer vite. En CI, on teste le build de production (pnpm build && pnpm start), pas le mode dev : c'est ce qui tourne réellement chez vos utilisateurs, et ça détecte les erreurs de prérendu que le mode dev masque.

trace: "on-first-retry" enregistre une trace complète (DOM, réseau, console, captures) uniquement quand un test échoue puis est rejoué. C'est votre meilleur ami pour comprendre un échec en CI sans pouvoir le reproduire localement.

Écrire son premier test : le parcours critique

La règle d'or des tests E2E : ne testez pas tout, testez ce qui rapporte. Sur un site vitrine, c'est le formulaire de contact. Sur un e-commerce, le tunnel d'achat. Commencez par les 2-3 parcours dont la casse vous coûte de l'argent.

Voici un test du formulaire de contact d'un site vitrine :

// e2e/contact.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Formulaire de contact", () => {
  test("envoie un message avec des données valides", async ({ page }) => {
    await page.goto("/");
    await page.getByRole("link", { name: "Contact" }).click();

    await page.getByLabel("Nom").fill("Marie Dupont");
    await page.getByLabel("Email").fill("marie@exemple.fr");
    await page
      .getByLabel("Message")
      .fill("Bonjour, j'aimerais discuter d'un projet de site web.");

    await page.getByRole("button", { name: "Envoyer" }).click();

    await expect(
      page.getByText("Votre message a bien été envoyé")
    ).toBeVisible();
  });

  test("affiche une erreur si l'email est invalide", async ({ page }) => {
    await page.goto("/#contact");

    await page.getByLabel("Email").fill("pas-un-email");
    await page.getByRole("button", { name: "Envoyer" }).click();

    await expect(page.getByText("Adresse email invalide")).toBeVisible();
  });
});

Remarquez les sélecteurs : getByRole, getByLabel, getByText. Playwright pousse vers les sélecteurs accessibles — ceux qui correspondent à ce qu'un utilisateur (ou un lecteur d'écran) perçoit réellement. Double bénéfice : vos tests ne cassent pas quand vous refactorisez le CSS, et si un sélecteur getByRole ne trouve rien, c'est souvent le signe d'un vrai problème d'accessibilité. J'en parlais dans mon article sur l'accessibilité web avec Next.js : les deux sujets se renforcent mutuellement.

Mocker les API externes sans toucher au code

Un test E2E qui envoie un vrai email ou crée une vraie entrée en base à chaque exécution, c'est ingérable. Playwright permet d'intercepter les requêtes réseau au niveau du navigateur, sans modifier une ligne de votre application :

// e2e/contact-mock.spec.ts
import { test, expect } from "@playwright/test";

test("gère l'échec du serveur avec élégance", async ({ page }) => {
  // Intercepte l'appel à l'API et simule une erreur 500
  await page.route("**/api/contact", (route) =>
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal Server Error" }),
    })
  );

  await page.goto("/#contact");
  await page.getByLabel("Nom").fill("Marie Dupont");
  await page.getByLabel("Email").fill("marie@exemple.fr");
  await page.getByLabel("Message").fill("Test de résilience.");
  await page.getByRole("button", { name: "Envoyer" }).click();

  await expect(
    page.getByText("Une erreur est survenue, réessayez plus tard")
  ).toBeVisible();
});

page.route() est redoutablement utile pour tester les cas d'erreur — ceux qu'on ne pense jamais à vérifier à la main et qui font pourtant la différence entre un site professionnel et un site qui affiche une page blanche au premier pépin.

Pour les parcours qui touchent votre propre backend NestJS, je préfère une autre approche : une base de données de test dédiée, réinitialisée avant chaque run. Les mocks vérifient le comportement du front ; la vraie base vérifie l'intégration complète. Les deux se complètent.

Tester l'authentification sans se répéter

Si votre site a un espace connecté, chaque test protégé n'a pas à rejouer le login. Playwright permet de sauvegarder l'état d'authentification (cookies, localStorage) une fois, puis de le réutiliser partout :

// e2e/auth.setup.ts
import { test as setup } from "@playwright/test";

const authFile = "e2e/.auth/user.json";

setup("authentification", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel("Mot de passe").fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole("button", { name: "Se connecter" }).click();
  await page.waitForURL("/dashboard");

  await page.context().storageState({ path: authFile });
});

Puis dans la config, déclarez ce setup comme dépendance :

// playwright.config.ts (extrait)
projects: [
  { name: "setup", testMatch: /.*\.setup\.ts/ },
  {
    name: "chromium",
    use: {
      ...devices["Desktop Chrome"],
      storageState: "e2e/.auth/user.json",
    },
    dependencies: ["setup"],
  },
],

Le login est joué une seule fois par run, tous les autres tests démarrent déjà connectés. Sur une suite de 30 tests authentifiés, c'est plusieurs minutes gagnées à chaque exécution.

Intégrer Playwright dans votre CI GitHub Actions

Des tests E2E qui ne tournent qu'en local ne servent à rien : c'est en CI, avant chaque mise en production, qu'ils prennent leur valeur. Voici le job que j'ajoute à mes workflows :

# .github/workflows/e2e.yml
name: Tests E2E

on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium webkit
      - run: pnpm exec playwright test

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

L'étape upload-artifact est précieuse : en cas d'échec, le rapport HTML complet (avec traces et captures d'écran) est téléchargeable depuis l'interface GitHub. Vous rejouez le test qui a échoué image par image, requête par requête. Si vous voulez aller plus loin sur l'automatisation du déploiement, j'ai détaillé toute la chaîne dans mon article sur le CI/CD avec GitHub Actions.

Trois pièges à éviter pour des tests stables

Les assertions sur des délais. N'écrivez jamais await page.waitForTimeout(3000). Utilisez les assertions web-first de Playwright (toBeVisible(), toHaveURL(), toHaveText()) qui réessaient automatiquement jusqu'au timeout. Un test qui dort est un test lent ET fragile.

Les tests interdépendants. Chaque test doit pouvoir tourner seul, dans n'importe quel ordre. Si le test B dépend de données créées par le test A, la parallélisation les cassera aléatoirement. Créez les données nécessaires dans un beforeEach ou via l'API directement.

Tout tester en E2E. Les tests E2E sont lents et coûteux comparés aux tests unitaires. La bonne pyramide : beaucoup de tests unitaires (logique métier, composants isolés — avec Jest et React Testing Library), quelques tests d'intégration, et une poignée de tests E2E sur les parcours critiques. Une dizaine de tests E2E bien choisis protège mieux que deux cents tests redondants qui mettent 40 minutes à tourner.

Conclusion

Playwright a rendu les tests E2E enfin agréables à écrire et fiables à maintenir : auto-waiting, parallélisation, multi-navigateurs, trace viewer — l'outillage est au niveau de ce qu'on attend en 2026. Sur un projet Next.js, la recette est simple : le bloc webServer pour piloter le build de production, des sélecteurs accessibles pour des tests robustes, page.route() pour maîtriser les dépendances externes, et une intégration CI qui bloque les régressions avant qu'elles n'atteignent vos utilisateurs.

Commencez petit : un seul test sur votre parcours le plus critique. Vous verrez sa valeur la première fois qu'il attrapera une régression que vos tests unitaires avaient laissée passer.

Vous avez un projet web et vous voulez une base technique solide, testée et fiable ? N'hésitez pas à me contacter pour en discuter, ou écrivez-moi directement à contact@alexis-mouchon.fr.