Tests E2E Next.js : guide pratique Playwright pour l'automatisation

Le ticket porte l’étiquette rouge « urgent » : le paiement est encore cassé. En staging tout va bien, en prod non.
La semaine dernière, avant la release, j’ai cliqué manuellement sur plus de trente pages, rempli une dizaine de formulaires, testé trois navigateurs — et j’ai quand même raté le bouton visible seulement après scroll en bas sur mobile. Message du PM : « Les utilisateurs disent que le coupon ne marche pas. »
Impossible de continuer au manuel. Ça épuise l’équipe tôt ou tard.
J’ai comparé Cypress, Selenium, Puppeteer, Playwright… Playwright l’a emporté : multi-navigateur, config plus simple que Cypress pour mon cas. La première semaine, cinq bugs jamais vus au manuel — styles Firefox-only, race conditions sur des appels async.
J’ai galéré au début (config réécrite plusieurs fois, suite refaite deux fois). Aujourd’hui le flux est fluide : CI/CD à chaque push, beaucoup moins de bugs en prod.
Pourquoi Playwright (vs Cypress)
Cypress est connu pour sa simplicité, sa doc et sa communauté. Pourquoi pas lui ?
Trois blocages :
Multi-navigateur faible. Support Firefox/Safari longtemps limité ; la plupart des runs restent sur Chromium. J’ai déjà eu une page paiement parfaite sur Chrome et écran blanc sur Safari (propriété CSS non supportée). Playwright couvre nativement Chromium, Firefox et WebKit — une suite, les navigateurs majeurs.
Vitesse. Parallélisme bien plus fort. Cypress enchaîne souvent les cas : 50 tests ≈ 15 min ; Playwright avec 8 workers ≈ 5 min. En CI, chaque minute compte.
API. Après Cypress, l’async/await de Playwright paraît plus naturel, aligné avec le JavaScript moderne et les Server Components Next.js.
Cypress reste excellent si vous ne ciblez que Chrome et que l’équipe débute en tests — time travel et debug sont top.
Pour moi :
- tests multi-navigateur
- équipe Next.js/React à l’aise avec async/await
- feedback CI rapide
- API Routes et pages SSR à couvrir
Pas de vainqueur absolu : petit projet, peu d’expérience → Cypress pour démarrer vite ; projet qui mûrit → Playwright pour l’automatisation long terme.
Next.js + Playwright : configuration pas à pas
Installation en trois commandes :
npm init playwright@latest
# ou avec pnpm
pnpm create playwright
Réponses recommandées :
- TypeScript ? Yes (les types évitent des erreurs bêtes)
- Dossier de tests ? tests (défaut OK)
- GitHub Actions ? Yes (pour la CI plus tard)
Fichiers ajoutés :
your-nextjs-project/
├── tests/ # cas de test
│ └── example.spec.ts
├── playwright.config.ts # config Playwright
└── .github/
└── workflows/
└── playwright.yml # workflow CI
Pièges de configuration
Le playwright.config.ts généré convient aux sites statiques ; Next.js demande des ajustements. Voici ma config stable après six mois :
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : 4,
reporter: [
['html'],
['list'],
process.env.CI ? ['github'] : ['list'],
],
webServer: {
command: 'npm run dev',
port: 3000,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
Erreurs fréquentes
-
webServer.timeoutassez long. 30 s au début : cold start Next.js échouait souvent. 120 s : stable. -
reuseExistingServer: trueen local. Sinon chaque run redémarre Next.js — pénible. -
Pas trop de
workers. Tous les cœurs CPU = machine qui freeze. Moitié des cœurs : bon compromis. -
Mobile optionnel. Mobile Chrome attrape des bugs responsive, mais double le temps — à peser.
Vérification :
npx playwright test
Un passed vert : l’environnement est prêt.
Bonnes pratiques d’interaction (Page Object Model)
Au début, tout dans un fichier : login ~100 lignes, locator/fill/click partout. Changer un sélecteur = modifier une dizaine de fichiers.
Le Page Object Model (POM) encapsule la page dans une classe ; les tests appellent des méthodes.
Sans POM (à éviter)
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('connexion utilisateur', async ({ page }) => {
await page.goto('/login');
await page.locator('input[name="email"]').fill('[email protected]');
await page.locator('input[name="password"]').fill('password123');
await page.locator('button[type="submit"]').click();
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('message d\'erreur si échec', async ({ page }) => {
await page.goto('/login');
await page.locator('input[name="email"]').fill('[email protected]');
await page.locator('input[name="password"]').fill('wrongpass');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.error')).toBeVisible();
});
Si input[name="email"] devient input[id="email"], tous les tests bougent.
Avec POM (recommandé)
// tests/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly dashboardTitle: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('input[name="email"]');
this.passwordInput = page.locator('input[name="password"]');
this.submitButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator('.error');
this.dashboardTitle = page.locator('h1');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async goto() {
await this.page.goto('/login');
}
async expectLoginSuccess() {
await this.dashboardTitle.waitFor();
await expect(this.dashboardTitle).toContainText('Dashboard');
}
async expectLoginError() {
await expect(this.errorMessage).toBeVisible();
}
}
Tests simplifiés :
// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('connexion utilisateur', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await loginPage.expectLoginSuccess();
});
test('message d\'erreur si échec', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpass');
await loginPage.expectLoginError();
});
Un seul fichier à toucher pour les sélecteurs ; les tests se lisent comme du français technique.
Arborescence type
tests/
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
├── fixtures/
│ └── testData.ts
├── auth.spec.ts
├── checkout.spec.ts
└── dashboard.spec.ts
Conseils
-
Pas de sur-abstraction. Page testée une fois ? Pas besoin de POM.
-
Noms explicites.
fillLoginForm()plutôt quefillForm(). -
Attentes dans le Page Object.
waitFor()caché = tests plus propres. -
Données dans fixtures :
// tests/fixtures/testData.ts
export const testUsers = {
validUser: {
email: '[email protected]',
password: 'password123'
},
invalidUser: {
email: '[email protected]',
password: 'wrongpass'
}
};
import { testUsers } from './fixtures/testData';
await loginPage.login(testUsers.validUser.email, testUsers.validUser.password);
Tests E2E des API Routes
Les API Routes Next.js font partie du produit. Avant : Postman à la main. Maintenant : request Playwright, sans ouvrir le navigateur.
Test API basique
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
test.describe('API utilisateurs', () => {
test('GET /api/users - liste', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.status()).toBe(200);
const users = await response.json();
expect(Array.isArray(users)).toBeTruthy();
expect(users.length).toBeGreaterThan(0);
expect(users[0]).toHaveProperty('id');
expect(users[0]).toHaveProperty('email');
expect(users[0]).toHaveProperty('name');
});
test('POST /api/users - création', async ({ request }) => {
const newUser = {
email: '[email protected]',
name: 'Test User',
password: 'password123'
};
const response = await request.post('/api/users', {
data: newUser
});
expect(response.status()).toBe(201);
const createdUser = await response.json();
expect(createdUser.email).toBe(newUser.email);
expect(createdUser).not.toHaveProperty('password');
});
test('POST /api/users - email dupliqué', async ({ request }) => {
const duplicateUser = {
email: '[email protected]',
name: 'Duplicate User',
password: 'password123'
};
const response = await request.post('/api/users', {
data: duplicateUser
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error.message).toContain('Email déjà utilisé');
});
});
API authentifiées
// tests/api/auth.spec.ts
import { test, expect } from '@playwright/test';
let authToken: string;
test.describe('API protégées', () => {
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: '[email protected]',
password: 'password123'
}
});
const { token } = await response.json();
authToken = token;
});
test('GET /api/profile', async ({ request }) => {
const response = await request.get('/api/profile', {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
expect(response.status()).toBe(200);
const profile = await response.json();
expect(profile.email).toBe('[email protected]');
});
test('sans token → 401', async ({ request }) => {
const response = await request.get('/api/profile');
expect(response.status()).toBe(401);
});
});
Mix UI + API
// tests/posts.spec.ts
import { test, expect } from '@playwright/test';
test('publication d\'article de bout en bout', async ({ page, request }) => {
await page.goto('/login');
await page.fill('input[name="email"]', '[email protected]');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.goto('/posts/new');
await page.fill('input[name="title"]', 'Titre de test');
await page.fill('textarea[name="content"]', 'Contenu de test');
await page.click('button:has-text("Publier")');
await page.waitForURL(/\/posts\/\d+/);
const url = page.url();
const postId = url.split('/').pop();
const response = await request.get(`/api/posts/${postId}`);
expect(response.status()).toBe(200);
const post = await response.json();
expect(post.title).toBe('Titre de test');
expect(post.content).toBe('Contenu de test');
expect(post.status).toBe('published');
});
J’ai déjà attrapé un bug ainsi : UI « publié », base en draft — mauvaise mise à jour de statut.
Conseils terrain
-
Tester les bords : paramètres manquants, types invalides, permissions.
-
Nettoyer les données :
test.afterAll(async ({ request }) => {
await request.delete('/api/test/cleanup');
});
-
Mocker l’externe (paiement, SMS).
-
Temps de réponse :
const start = Date.now();
await request.get('/api/users');
const duration = Date.now() - start;
expect(duration).toBeLessThan(1000);
UI + API couvrent ~90 % ; le reste en unitaire.
Intégration CI/CD avec GitHub Actions
Suite écrite → branchez la CI. Chaque push qui casse un flux ailleurs : le vert CI vous sauve.
npm init playwright génère déjà un workflow ; je l’affine selon le projet.
CI de base
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Limites : navigateurs réinstallés à chaque fois, pas de base de test, rapport à télécharger.
Config production (la mienne)
name: E2E Tests
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v3
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run database migrations
run: npm run db:migrate
- name: Run Playwright tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Deploy report to GitHub Pages
if: always() && github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./playwright-report
Points clés
-
services : PostgreSQL pour les tests API (MySQL →
mysql:8). -
Cache navigateurs : premier run lent, puis rapide ; chromium seul en CI.
-
Migrations :
{
"scripts": {
"db:migrate": "prisma migrate deploy"
}
}
- Rapport sur GitHub Pages pour la branche main.
Secrets dans Settings → Secrets :
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_KEY }}
Debug d’échec CI
- Trace :
npx playwright show-trace trace.zip - Captures / vidéo automatiques
acten local :
brew install act
act -j test
Pièges : timeout job raisonnable (60 min), 2 workers en CI, retries: 2 pour le réseau pas pour masquer un test cassé.
Chaque PR doit être verte avant merge.
Couverture et rapports
npx playwright show-report
Rapport HTML : statut, durée, captures, trace (réseau, DOM, console) — idéal pour localiser la ligne fautive.
Couverture fonctionnelle
## Liste de couverture
### Authentification
- [x] Connexion OK
- [x] Échec mot de passe
- [x] Inscription
- [x] Mot de passe oublié
- [ ] OAuth Google
### Produits
- [x] Ajout
- [x] Édition
- [x] Suppression
- [ ] Import en masse
### Commandes
- [x] Panier
- [x] Checkout
- [x] Paiement (sandbox)
- [ ] Remboursement
Fichier tests/README.md, mis à jour à chaque feature.
Couverture de code (optionnel)
Possible avec Istanbul/v8 ; en E2E je regarde surtout les parcours — la logique fine reste en unitaire.
Reporter custom (Slack, etc.)
// my-reporter.ts
import { Reporter } from '@playwright/test/reporter';
class SlackReporter implements Reporter {
onEnd(result) {
const passed = result.suites.filter(s => s.ok).length;
const failed = result.suites.length - passed;
fetch('https://hooks.slack.com/services/YOUR_WEBHOOK', {
method: 'POST',
body: JSON.stringify({
text: `Tests terminés : ${passed} OK, ${failed} échecs`
})
});
}
}
export default SlackReporter;
export default defineConfig({
reporter: [
['html'],
['./my-reporter.ts']
]
});
Tendances : Playwright Trace Viewer ou CSV + Google Sheets — les deux marchent.
Habitudes
- Local : sortie terminal,
npx playwright test --debugsi besoin. - PR : rapport CI, surveiller les tests lents.
- Hebdo : compléter la checklist de couverture.
Conclusion
Il y a six mois, tests manuels jusqu’à 3 h du matin ; aujourd’hui c’est nettement plus léger.
Playwright + Next.js, en un mot : tranquillité. Config une fois, CI à chaque commit, releases plus sereines, moins d’appels urgents du PM.
Si vous testez encore à la main :
- Commencez par le cœur — login, paiement, pas tout d’un coup.
- Adoptez le POM — un peu lourd au début, rentable ensuite.
- Branchez la CI — des tests qui ne tournent pas seuls ne servent à rien.
- Pas de 100 % partout — priorisez le critique.
Les E2E ne sont pas qu’un outil : ils alignent l’équipe sur la qualité, réduisent le flou et rendent les releases prévisibles.
Je pars maintenant vers 17 h30. Le temps gagné va enfin à la salle — la carte annuelle ne dormait plus.
Lancez vos premiers tests : votre futur vous remerciera.
FAQ
Playwright ou Cypress : lequel choisir ?
• Playwright : multi-navigateur (Chromium/Firefox/WebKit), parallélisme rapide (8 workers ~3× Cypress), syntaxe async/await, projets moyens/grands
• Cypress : surtout Chrome, débogage time travel fort, communauté riche, plus accessible aux débutants
Chrome seul et peu d'expérience tests → Cypress ; multi-navigateur, CI rapide, équipe React/Next.js → Playwright.
Faut-il obligatoirement le Page Object Model ?
Petit projet (<10 cas) ou page testée une fois : écrire directement dans le test suffit. Si :
• plusieurs tests touchent la même page
• plusieurs personnes maintiennent les tests
• le projet vit longtemps
alors le POM évite la douleur : sans POM, changer un sélecteur = 10+ fichiers ; avec POM, un seul.
Les tests CI expirent toujours : que faire ?
• webServer.timeout trop court : passer à 120 s (cold start Next.js + compilation)
• trop de workers : 2-4 suffisent sur les runners CI
• test fragile : trace pour voir requête lente ou attente d'élément
• installation navigateurs lente : actions/cache pour les binaires Playwright
Astuce : chromium seul en CI, multi-navigateur en local.
Comment gérer les données de test ? Faut-il vider la base à la main ?
• Base dédiée aux tests, vidée régulièrement, sans toucher la dev
• Nettoyage après chaque test via test.afterAll(), risque d'oubli
• Conteneur Docker : base neuve par run, détruite après (propre mais lent)
J'utilise 1 + 2 : Docker en CI, base test locale + afterAll en dev.
Faut-il mocker les services tiers dans les tests API ?
• coût : paiement/SMS réels facturent à chaque run
• vitesse : latence tierce ralentit la suite
• stabilité : panne externe ne doit pas faire échouer vos tests
Playwright intercepte le réseau :
await page.route('**/api/payment', route => route.fulfill({ status: 200, body: '{"success": true}' }));
Ou retour simulé dans les API Routes Next.js selon NODE_ENV=test.
Quel taux de couverture est acceptable ?
Priorités :
• cœur (login, paiement, commande) : 100 %
• haute fréquence (catalogue, panier) : 80 %+
• basse fréquence (mot de passe, remboursement) : 50 %+
• périphérie (thème, langue) : optionnel
Ne visez pas 100 % partout. Chez moi : flux critiques à 100 %, ~60 % fonctionnel global, ~90 % des bugs bloqués.
Après Playwright, faut-il encore des tests unitaires ?
• E2E (Playwright) : parcours utilisateur, intégration front/back, lent mais large
• unitaires (Jest/Vitest) : logique, bords, erreurs, rapides mais locaux
Ratio visé : ~70 % unitaires, ~30 % E2E. Utilitaires, hooks, composants en unitaire ; parcours complets en E2E.
10 min de lecture · Publié le: 7 janv. 2026 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Tests unitaires Next.js : guide complet Jest + React Testing Library
Configurez de zéro l'environnement de test Next.js 15 avec Jest et React Testing Library : config, tests Client/Server Components, hooks, mocks et dépannage, avec exemples de code complets.
Partie 35 sur 51
Suivant
Next.js e-commerce : guide complet panier et paiement Stripe
Construisez un panier et un flux de paiement avec Zustand + Stripe : choix du state management, Checkout Session, Webhook et traitement des commandes, avec exemples de code prêts à l'emploi.
Partie 37 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire