Tests E2E avec Playwright en 2026 : Pourquoi nous avons abandonné Cypress

Brandon Sueur14 min

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