Tests E2E avec Playwright en 2026 : Pourquoi nous avons abandonné Cypress
Tests E2E avec Playwright en 2026 : Pourquoi nous avons abandonné Cypress
Les tests end-to-end (E2E) sont essentiels pour garantir la qualité de vos applications web. Ils simulent le comportement réel des utilisateurs et détectent les bugs que les tests unitaires ne peuvent pas trouver.
Pendant longtemps, Cypress était la référence. Mais en 2026, Playwright de Microsoft s'est imposé comme le nouvel outil de référence pour les tests E2E.
Chez Hulli Studio, nous avons migré l'ensemble de nos projets de Cypress vers Playwright début 2025, et nous ne reviendrons pas en arrière. Dans cet article, nous expliquons pourquoi, comment migrer, et partageons nos patterns de tests avancés.
🎯 Pourquoi Playwright a gagné la bataille des tests E2E
Les limitations de Cypress que nous avons rencontrées
Cypress était excellent en 2020, mais plusieurs limitations sont devenues problématiques :
❌ Un seul navigateur à la fois
// Cypress : tester plusieurs navigateurs = relancer tous les tests
// Impossible de paralléliser cross-browser nativement
npx cypress run --browser chrome
npx cypress run --browser firefox // Lance TOUS les tests à nouveau
❌ Pas de vrai multi-onglets
// Cypress : impossible de tester vraiment des flows multi-onglets
cy.window().then((win) => {
win.open('/new-page') // Ouvert dans le même contexte
})
❌ Limité aux applications SPA
Cypress fonctionne à l'intérieur du navigateur, ce qui limite les capacités :
- Impossible de tester des domaines différents dans le même test
- CORS et sécurité deviennent des problèmes
- Pas d'accès aux DevTools Protocol
❌ Performances limitées
# 200 tests Cypress : ~18 minutes (sans parallelisation payante)
# 200 tests Playwright : ~4 minutes (parallelisation native gratuite)
✅ Ce que Playwright apporte
Playwright résout tous ces problèmes et va beaucoup plus loin :
✅ Multi-browsers natif et parallèle
// playwright.config.ts
export default defineConfig({
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'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
})
// Un seul run lance tous les navigateurs EN PARALLÈLE
npx playwright test // 5 browsers testés simultanément ✅
✅ Vrai support multi-onglets et multi-contextes
test('teste un flow OAuth avec popup', async ({ browser }) => {
// Contexte principal
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('https://myapp.com')
// Attendre la popup OAuth
const [popup] = await Promise.all([
context.waitForEvent('page'),
page.click('button:text("Sign in with Google")'),
])
// Interagir avec la popup
await popup.fill('input[type="email"]', 'user@example.com')
await popup.click('button:text("Continue")')
// Vérifier le retour sur la page principale
await page.waitForURL('**/dashboard')
await expect(page.locator('h1')).toContainText('Welcome')
})
✅ DevTools Protocol - Capacités avancées
// Intercepter les requêtes réseau
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: 'Mock User' }]),
})
})
// Simuler la géolocalisation
await context.setGeolocation({ latitude: 48.8566, longitude: 2.3522 })
// Bloquer les images pour tests plus rapides
await context.route('**/*.{png,jpg,jpeg}', (route) => route.abort())
// Simuler offline
await context.setOffline(true)
✅ Performance et parallélisation native
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 4 : 2, // Parallélisation
retries: process.env.CI ? 2 : 0, // Retry en CI
fullyParallel: true, // Tous les tests en parallèle
})
Résultat : Tests 4x plus rapides que Cypress sur nos projets.
✅ Auto-waiting intelligent
// Playwright attend AUTOMATIQUEMENT que l'élément soit :
// - Attaché au DOM
// - Visible
// - Stable (pas d'animations en cours)
// - Enabled
// - Pas couvert par un autre élément
await page.click('button') // Pas de cy.wait() manuel nécessaire ✅
// Cypress nécessitait souvent :
cy.get('button').should('be.visible')
cy.wait(500) // Attente arbitraire ❌
cy.get('button').click({ force: true }) // Force click = mauvaise pratique
✅ Debugging supérieur
# Mode UI interactif (comme Cypress, mais meilleur)
npx playwright test --ui
# Time-travel debugging avec traces
npx playwright test --trace on
# Codegen - génère les tests en enregistrant vos actions
npx playwright codegen https://myapp.com
# VS Code extension avec breakpoints
🚀 Démarrer avec Playwright
Installation
npm init playwright@latest
# Ou dans un projet existant
npm install -D @playwright/test
npx playwright install # Installe les navigateurs
Configuration de base
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
// Timeout par test
timeout: 30 * 1000,
// Retry en cas d'échec
retries: process.env.CI ? 2 : 0,
// Workers (parallélisation)
workers: process.env.CI ? 4 : 2,
// Reporter
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }],
['github'], // GitHub Actions integration
],
use: {
// Base URL
baseURL: 'http://localhost:3000',
// Traces on failure
trace: 'on-first-retry',
// Screenshots
screenshot: 'only-on-failure',
// Videos
video: 'retain-on-failure',
},
// Projects = configurations multi-browsers
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'] },
},
],
// Dev server
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Premier test
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Login Flow', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login')
// Remplir le formulaire
await page.fill('input[name="email"]', 'user@example.com')
await page.fill('input[name="password"]', 'Password123')
// Cliquer sur le bouton
await page.click('button[type="submit"]')
// Vérifier la redirection
await expect(page).toHaveURL('/dashboard')
// Vérifier le contenu
await expect(page.locator('h1')).toContainText('Welcome')
})
test('should show error with invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'wrong@example.com')
await page.fill('input[name="password"]', 'wrong')
await page.click('button[type="submit"]')
// Vérifier le message d'erreur
await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials')
})
})
Lancer les tests
# Lancer tous les tests
npx playwright test
# Lancer un fichier spécifique
npx playwright test login.spec.ts
# Lancer en mode UI (interactif)
npx playwright test --ui
# Lancer sur un browser spécifique
npx playwright test --project=chromium
# Mode debug
npx playwright test --debug
# Générer un rapport HTML
npx playwright show-report
📦 Patterns avancés Hulli Studio
1. Page Object Model (POM)
Structurer les tests avec le pattern Page Object pour la réutilisabilité.
// pages/login.page.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
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('[role="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.submitButton.click()
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message)
}
}
// Utilisation dans les tests
test('login avec Page Object', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'Password123')
await expect(page).toHaveURL('/dashboard')
})
2. Fixtures personnalisés - Authentification
Créer des fixtures pour réutiliser des états (user connecté, données mockées, etc.)
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test'
import { LoginPage } from '../pages/login.page'
type AuthFixtures = {
authenticatedPage: Page
}
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Se connecter avant chaque test
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'Password123')
// Attendre que l'auth soit complète
await page.waitForURL('/dashboard')
// Passer la page authentifiée au test
await use(page)
// Cleanup après le test (optionnel)
await page.context().clearCookies()
},
})
// Utilisation
import { test, expect } from './fixtures/auth.fixture'
test('accéder au dashboard (déjà connecté)', async ({ authenticatedPage }) => {
// L'utilisateur est déjà connecté ✅
await expect(authenticatedPage.locator('h1')).toContainText('Dashboard')
})
3. Fixtures avec données de test
// fixtures/test-data.fixture.ts
import { test as base } from '@playwright/test'
type TestDataFixtures = {
testUser: { email: string; password: string }
adminUser: { email: string; password: string }
}
export const test = base.extend<TestDataFixtures>({
testUser: async ({}, use) => {
const user = {
email: `test-${Date.now()}@example.com`,
password: 'TestPassword123',
}
// Créer l'utilisateur via API
await fetch('http://localhost:3000/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
})
await use(user)
// Cleanup : supprimer l'utilisateur
await fetch(`http://localhost:3000/api/users/${user.email}`, {
method: 'DELETE',
})
},
adminUser: [
async ({}, use) => {
await use({ email: 'admin@example.com', password: 'AdminPass123' })
},
{ scope: 'worker' },
], // Partagé entre tous les tests du worker
})
4. Mock d'APIs avec MSW (Mock Service Worker)
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('https://api.example.com/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
])
}),
http.post('https://api.example.com/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
}),
]
// tests/e2e/with-mocks.spec.ts
import { test, expect } from '@playwright/test'
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
const server = setupServer(...handlers)
test.beforeAll(() => server.listen())
test.afterEach(() => server.resetHandlers())
test.afterAll(() => server.close())
test('liste les utilisateurs mockés', async ({ page }) => {
await page.goto('/users')
// Les données viennent du mock MSW
await expect(page.locator('li')).toHaveCount(2)
await expect(page.locator('li').first()).toContainText('John Doe')
})
5. Tests visuels (Visual Regression Testing)
test('vérifie le design de la homepage', async ({ page }) => {
await page.goto('/')
// Screenshot et comparaison automatique
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
animations: 'disabled',
})
})
// Si le design change, Playwright détecte la différence visuellement
6. Tests accessibilité avec @axe-core
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test("vérifie l'accessibilité de la page login", async ({ page }) => {
await page.goto('/login')
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
expect(accessibilityScanResults.violations).toEqual([])
})
🔄 Migration depuis Cypress
Comparaison syntaxe Cypress → Playwright
| Action | Cypress | Playwright |
|---|---|---|
| Navigation | cy.visit('/page') |
await page.goto('/page') |
| Sélecteur | cy.get('button') |
page.locator('button') |
| Click | cy.get('button').click() |
await page.click('button') |
| Fill input | cy.get('input').type('text') |
await page.fill('input', 'text') |
| Assertion | cy.get('h1').should('contain', 'Title') |
await expect(page.locator('h1')).toContainText('Title') |
| Wait | cy.wait(1000) |
await page.waitForTimeout(1000) (déconseillé) |
| Intercept | cy.intercept('GET', '/api/*') |
await page.route('**/api/*', ...) |
Script de migration automatique
# Installer le migrator
npm install -D @playwright/test
# Convertir les tests Cypress
npx playwright codegen --target javascript cypress/e2e/
Migration manuelle étape par étape
Avant (Cypress):
describe('Login', () => {
it('should login successfully', () => {
cy.visit('/login')
cy.get('input[name="email"]').type('user@example.com')
cy.get('input[name="password"]').type('Password123')
cy.get('button[type="submit"]').click()
cy.url().should('include', '/dashboard')
cy.get('h1').should('contain', 'Welcome')
})
})
Après (Playwright):
import { test, expect } from '@playwright/test'
test.describe('Login', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'user@example.com')
await page.fill('input[name="password"]', 'Password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/.*dashboard/)
await expect(page.locator('h1')).toContainText('Welcome')
})
})
📊 Nos résultats après migration Cypress → Playwright
Temps d'exécution
| Métrique | Cypress | Playwright | Amélioration |
|---|---|---|---|
| 250 tests E2E | 22 min | 5 min | -77% |
| Tests cross-browser | 66 min (3x22) | 8 min | -88% |
| Temps setup CI | 3 min | 45 sec | -75% |
Stabilité
- Flaky tests : 12% → 2% (-83%)
- Timeouts : 8% → 0.5% (-94%)
- Maintenance : 6h/semaine → 1h/semaine
Developer Experience
- ✅ Debugging plus simple (VS Code integration)
- ✅ Traces visuelles incroyables
- ✅ Auto-completion TypeScript parfaite
- ✅ Mode UI interactif très pratique
💡 Bonnes pratiques Playwright 2026
✅ DO
- ✅ Utiliser des data-testid pour les sélecteurs stables
- ✅ Utiliser le Page Object Model pour la réutilisabilité
- ✅ Créer des fixtures pour les états communs (auth, données)
- ✅ Activer parallelization pour la performance
- ✅ Utiliser traces en CI pour debugger les échecs
- ✅ Tester sur plusieurs navigateurs (Chrome, Firefox, Safari)
- ✅ Implémenter des tests visuels pour les composants UI critiques
- ✅ Utiliser MSW pour mocker les APIs
❌ DON'T
- ❌ Ne pas utiliser
waitForTimeout()(anti-pattern) - ❌ Ne pas faire de
{ force: true }clicks (masque des bugs) - ❌ Ne pas tester des détails d'implémentation (classes CSS internes)
- ❌ Ne pas écrire de tests E2E pour tout (tests unitaires > E2E pour la logique)
- ❌ Ne pas ignorer les warnings d'accessibilité
- ❌ Ne pas commit les screenshots de baseline sans review
🎬 Conclusion
Playwright est devenu l'outil de référence pour les tests E2E en 2026. Sa performance, son auto-waiting intelligent, son support multi-browsers natif et ses capacités de debugging en font un choix évident.
Chez Hulli Studio, migrer vers Playwright a été l'une de nos meilleures décisions techniques :
- ⚡ Tests 4x plus rapides
- 🎯 Moins de flaky tests (2% vs 12%)
- 🛠️ Meilleure developer experience
- 💰 Réduction des coûts CI (moins de temps machine)
Si vous êtes encore sur Cypress, c'est le moment de migrer. La transition est plus simple qu'elle n'y paraît et les gains sont immédiats.
Vous souhaitez implémenter une stratégie de tests E2E robuste ou migrer depuis Cypress ? Chez Hulli Studio, nous aidons nos clients à mettre en place des pipelines de tests automatisés performants. Contactez-nous pour en discuter.
Mots-clés: Playwright, tests E2E, Playwright vs Cypress, tests automatisés, E2E testing, Playwright TypeScript, tests frontend, CI/CD, Page Object Model, visual testing, migration Cypress