Playwright : écrire des tests end-to-end qui ne flakent pas
Multi-navigateur, auto-wait natif, traces de debug — Playwright règle les problèmes qui rendent les tests E2E instables.
Un test E2E qui passe neuf fois sur dix et échoue à la dixième sans raison claire. Le coupable classique : un click() lancé avant que l'élément soit vraiment cliquable, ou une assertion qui vérifie le DOM une frame trop tôt. Avec Cypress ou Puppeteer, on ajoute des sleep(500) un peu partout pour compenser. Ça marche, jusqu'au jour où le CI est plus lent et où ça recasse.
Playwright a été conçu par l'équipe qui a créé Puppeteer chez Google, partie ensuite chez Microsoft. Leur réponse au flakiness : l'auto-wait intégré à chaque action, plutôt que des attentes ajoutées après coup.
Ce que Playwright fait différemment
Trois navigateurs avec une seule API : Chromium, Firefox et WebKit (le moteur de Safari). Un test écrit une fois tourne sur les trois sans changement de code.
L'auto-wait est le vrai argument de vente. Avant chaque action — clic, saisie, assertion — Playwright attend que l'élément soit attaché au DOM, visible, stable (pas en animation), et capable de recevoir l'événement. Pas de waitForSelector manuel avant chaque interaction comme avec Puppeteer.
Le test runner intégré (@playwright/test) gère aussi le parallélisme, les retries, et génère des rapports HTML avec captures d'écran et vidéos des échecs.
Installation
npm init playwright@latestLe CLI pose quelques questions : TypeScript ou JavaScript, dossier des tests, ajout d'un workflow CI. Il installe @playwright/test et télécharge les trois navigateurs (environ 400 Mo au total).
Pour installer seulement Chromium et réduire la taille :
npx playwright install chromiumPremier test
import { test, expect } from '@playwright/test'
test('la page d\'accueil affiche le titre', async ({ page }) => {
await page.goto('https://example.com')
await expect(page).toHaveTitle(/Example Domain/)
await expect(page.locator('h1')).toBeVisible()
})npx playwright testLe paramètre page est injecté automatiquement par le test runner — pas besoin de lancer et fermer un navigateur à la main comme avec Puppeteer. Chaque test reçoit un contexte de navigateur isolé.
Locators : la bonne façon de cibler un élément
Playwright pousse vers les locator, qui diffèrent des sélecteurs classiques sur un point important : ils sont paresseux. Un locator ne cherche pas l'élément immédiatement, il décrit comment le retrouver au moment de l'action.
const button = page.locator('button[type="submit"]')
await button.click() // recherche l'élément ici, avec auto-waitLes locators recommandés par ordre de préférence :
// Par rôle ARIA — le plus robuste, calqué sur l'accessibilité
page.getByRole('button', { name: 'Envoyer' })
// Par texte visible
page.getByText('Mot de passe oublié')
// Par label de formulaire
page.getByLabel('Adresse email')
// Par attribut de test dédié
page.getByTestId('submit-button')Éviter les sélecteurs CSS basés sur les classes utilitaires (getByRole reste stable même si les classes Tailwind changent). Cette philosophie recoupe directement les bonnes pratiques d'accessibilité web : un site correctement structuré avec des rôles ARIA est aussi plus facile à tester.
Remplir un formulaire et attendre la navigation
test('connexion utilisateur', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Mot de passe').fill('secret123')
await page.getByRole('button', { name: 'Se connecter' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible()
})Pas de waitForNavigation() explicite : l'assertion toHaveURL attend elle-même que l'URL corresponde, avec un timeout par défaut de 5 secondes.
Attendre une requête réseau
Pour les SPAs qui chargent des données après le rendu initial, waitForResponse synchronise le test sur un appel API précis plutôt que sur un délai arbitraire :
test('charge les données du dashboard', async ({ page }) => {
const responsePromise = page.waitForResponse(
(res) => res.url().includes('/api/dashboard') && res.status() === 200
)
await page.goto('/dashboard')
await responsePromise
await expect(page.locator('[data-testid="stat-card"]')).toHaveCount(4)
})Traces : déboguer un test qui échoue en CI
Le vrai problème des tests E2E n'est pas de les écrire, c'est de comprendre pourquoi ils échouent sur une machine qu'on ne voit pas. Playwright enregistre une trace complète — DOM, réseau, console, screenshots à chaque étape.
import { defineConfig } from '@playwright/test'
export default defineConfig({
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
retries: process.env.CI ? 2 : 0,
})trace: 'on-first-retry' capture la trace seulement quand un test échoue une première fois. Ouvrir le résultat :
npx playwright show-trace trace.zipUne interface s'ouvre avec une timeline complète de l'exécution, à rejouer étape par étape — nettement plus rapide que de rejouer le test en local en espérant reproduire le bug.
Playwright en CI
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7--with-deps installe les dépendances système des navigateurs (mêmes libs que celles nécessaires pour Chromium avec Puppeteer, mais gérées automatiquement). L'upload du rapport en if: always() garde les traces même quand les tests échouent — la partie la plus utile pour déboguer un run cassé. Cette structure de workflow s'intègre dans la même logique que les pipelines GitHub Actions classiques.
Parallélisation
Par défaut, Playwright lance les fichiers de test en parallèle avec plusieurs workers.
export default defineConfig({
workers: process.env.CI ? 2 : undefined,
fullyParallel: true,
})workers: undefined laisse Playwright utiliser la moitié des CPU disponibles en local. En CI, fixer un nombre explicite évite de saturer un runner avec des ressources limitées.
Playwright vs Puppeteer vs Cypress
| Critère | Playwright | Puppeteer | Cypress |
|---|---|---|---|
| Navigateurs | Chromium, Firefox, WebKit | Chromium uniquement | Chromium, Firefox |
| Auto-wait | Natif, sur chaque action | Partiel | Natif |
| Test runner intégré | Oui | Non | Oui |
| Multi-onglets/contextes | Oui | Oui | Non |
| Cas d'usage principal | Tests E2E | Scraping + tests | Tests E2E |
Puppeteer reste pertinent pour le scraping pur ou les projets Chrome-only déjà en place. Pour des tests E2E neufs, Playwright a l'avantage du multi-navigateur et d'un outillage de debug plus mature.
Playwright ne supprime pas tout le flakiness — un test mal conçu qui dépend de l'ordre d'exécution restera fragile. Mais il élimine la classe d'erreurs la plus fréquente : les attentes manuelles mal calibrées. Écrit avec TypeScript, le typage des locators et des assertions attrape en plus une bonne partie des erreurs de refactoring avant même de lancer les tests.