Migrer de Selenium ou Cypress vers Playwright prend 4 à 6 semaines pour une suite de 500 tests. La stratégie consiste à faire tourner les deux frameworks en parallèle et à migrer fichier par fichier.
Quand migrer, et quand rester
La migration a un coût réel. Avant d'écrire une seule ligne de code Playwright, faites ce calcul honnêtement.
Pour les équipes Selenium, la migration a du sens quand :
- La gestion des drivers consomme du temps d'ingénierie. Si les incompatibilités de version de ChromeDriver cassent régulièrement votre CI, c'est une taxe récurrente que Playwright élimine entièrement.
- Votre bibliothèque utilitaire
WebDriverWait+ExpectedConditionsest tentaculaire et continue de produire des tests flaky. - Vous avez besoin d'émulation mobile, d'interception réseau ou de tests multi-contextes que Selenium ne peut pas gérer proprement.
- Votre équipe écrit du TypeScript et veut des types natifs partout.
La migration n'a pas de sens quand votre suite est en Java ou Python et que votre équipe ne passe pas à TypeScript. Playwright a des bindings Java et Python, mais les exemples communautaires sont moins riches. Elle n'a pas de sens non plus pour les navigateurs que Playwright ne supporte pas (Internet Explorer, ancien Edge) ou quand votre suite est stable et votre CI est rapide.
Pour les équipes Cypress, le calcul est différent. Cypress et Playwright résolvent le même problème à peu près au même niveau d'abstraction. Migrez quand :
- Vous avez besoin de couverture Safari/WebKit. Le moteur WebKit de Playwright est le seul moyen d'obtenir un vrai rendu navigateur sur Windows sans machine macOS.
- Vous vous heurtez au mur de la parallélisation payante et avez besoin de sharding gratuit sur plusieurs runners CI.
- Vous écrivez régulièrement des tests multi-onglets ou cross-domaines qui nécessitent des contournements Cypress.
- Vous voulez des tests API dans le même framework, dans le même run de test.
Restez sur Cypress quand votre suite fonctionne et que vous n'avez pas ces problèmes. Migrer 300 tests Cypress fonctionnels pour une fonctionnalité dont vous n'avez pas besoin, c'est du coût pur sans retour.
Une règle raisonnable : si le temps d'ingénierie annuel perdu à cause des limitations de votre framework actuel dépasse deux semaines de salaire, la migration s'autofinance en un an.
Le changement de modèle mental : protocole, attente et exécution
Comprendre pourquoi Playwright se comporte différemment accélère tout le reste de la migration.
Selenium communique avec les navigateurs via le protocole WebDriver : des requêtes HTTP depuis votre processus de test vers un processus de driver navigateur, qui transmet les commandes au navigateur. Chaque action est un aller-retour. C'est pourquoi Selenium est lent et pourquoi les attentes sont explicites. Le framework n'a aucune visibilité sur ce que fait le navigateur entre les commandes. Playwright utilise le Chrome DevTools Protocol (CDP) pour Chromium, et des protocoles analogues de bas niveau pour Firefox et WebKit. La connexion est un WebSocket persistant, pas du HTTP par commande. Le processus Playwright est étroitement couplé au navigateur et peut observer son état directement. C'est ce qui rend l'auto-waiting possible.Quand vous appelez await page.getByRole('button', { name: 'Submit' }).click(), Playwright ne clique pas immédiatement. Il interroge jusqu'à ce que l'élément existe dans le DOM, soit visible, ne soit pas recouvert par un autre élément et ne soit pas désactivé, puis il clique. La vérification d'actionnabilité est intégrée dans chaque interaction. Vous n'avez presque jamais besoin d'attentes explicites.
await parce que la file gère l'ordonnancement en interne. Cela donne à Cypress une apparence synchrone même si ce n'est pas le cas. Playwright abandonne cette abstraction et utilise le standard async/await, plus explicite, qui s'intègre aux outils JavaScript standard, et plus facile à raisonner quand ça part en vrille.
L'implication pratique : lors de la migration, supprimez vos utilitaires d'attente. Ne les portez pas. Si vous vous retrouvez à ajouter des attentes explicites dans Playwright pour corriger la flakiness, le problème est ailleurs. C'est le signe que votre locator ou votre structure de test a un problème plus profond.
Mapping des locators
La classe By de Selenium et l'API de locators de Playwright se recoupent dans les capacités mais diffèrent dans la philosophie. Les locators Playwright sont sémantiques par défaut. Ils vous encouragent à trouver les éléments comme le ferait un utilisateur, par rôle et texte visible, pas par détails d'implémentation CSS.
// Selenium : sélecteur CSS
driver.findElement(By.cssSelector("button[data-testid='submit-btn']")).click();
// Selenium : XPath
driver.findElement(By.xpath("//button[contains(text(), 'Submit')]")).click();
// Playwright équivalent : locator sémantique
await page.getByRole('button', { name: 'Submit' }).click();
// Playwright : test ID (quand vous maîtrisez le markup)
await page.getByTestId('submit-btn').click();
// Playwright : CSS en fallback (valide, mais à utiliser en dernier recours)
await page.locator("button[data-testid='submit-btn']").click();Table de correspondance pour les patterns courants :
// By.id("username")
await page.locator('#username');
await page.getByLabel('Username'); // mieux si le label existe
// By.name("email")
await page.locator('[name="email"]');
await page.getByLabel('Email');
// By.linkText("Forgot password?")
await page.getByRole('link', { name: 'Forgot password?' });
// By.partialLinkText("Forgot")
await page.getByRole('link', { name: /forgot/i });
// By.tagName("h1")
await page.locator('h1');
// By.className("error-message")
await page.locator('.error-message');
// ou de façon sémantique :
await page.getByRole('alert');Quand vous rencontrez du XPath dans des tests Selenium, résistez à l'envie de copier la chaîne XPath dans page.locator(). Le XPath fonctionne dans Playwright, mais il ancre vos tests à la structure d'implémentation. Profitez de la migration pour remplacer le XPath fragile par getByRole ou getByLabel. Les tests qui s'appuient sur des locators sémantiques survivent bien mieux aux refactorisations.
npx playwright codegen https://your-app.com sur votre application. Le générateur de code écrit des locators pour chaque élément avec lequel vous interagissez et choisit par défaut getByRole et getByLabel là où c'est possible. Utilisez-le pour découvrir quels locators sémantiques sont disponibles avant d'écrire manuellement des mappings.Migration des Page Objects
Les Page Objects se translatent directement de Selenium à Playwright. Le pattern est le même. Les différences : le constructeur reçoit Page au lieu de WebDriver, vous await tout, et les locators sont définis comme des objets Locator Playwright plutôt que des descripteurs By.
Voici une classe POM Selenium :
// Selenium (bindings TypeScript)
import { WebDriver, By, WebDriverWait, until } from 'selenium-webdriver';
export class LoginPage {
private driver: WebDriver;
private wait: WebDriverWait;
constructor(driver: WebDriver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, 10000);
}
async navigate() {
await this.driver.get('https://lab.becomeqa.com/login');
}
async login(email: string, password: string) {
const emailInput = await this.wait.until(
until.elementLocated(By.cssSelector('input[name="email"]'))
);
await emailInput.sendKeys(email);
const passwordInput = await this.driver.findElement(
By.cssSelector('input[type="password"]')
);
await passwordInput.sendKeys(password);
const submitBtn = await this.driver.findElement(
By.cssSelector('button[type="submit"]')
);
await submitBtn.click();
}
async getErrorMessage(): Promise<string> {
const errorEl = await this.wait.until(
until.elementLocated(By.cssSelector('.error-message'))
);
return errorEl.getText();
}
}L'équivalent Playwright :
// Playwright
import { Page, Locator } from '@playwright/test';
export class LoginPage {
private readonly page: Page;
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async navigate() {
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 getErrorMessage(): Promise<string> {
return this.errorMessage.textContent() ?? '';
}
}Remarquez ce qui a disparu : tous les appels WebDriverWait, les wrappers until.elementLocated, et la méthode .sendKeys (remplacée par .fill). La version Playwright est plus courte parce que l'auto-waiting qui était manuel dans Selenium est maintenant implicite.
Une amélioration structurelle à faire pendant la migration : définissez les locators comme propriétés de classe dans le constructeur plutôt qu'en ligne dans les méthodes. Les objets Locator de Playwright sont lazy. Ils ne requêtent pas le DOM tant que vous n'appelez pas d'action sur eux, donc les définir dans le constructeur n'a aucun coût en performance.
Migrer depuis Cypress
Les migrations Cypress vers Playwright sont plus courtes mais nécessitent de désapprendre le modèle de file de commandes.
Command chaining vs async/await :// Cypress : pas d'await, file de commandes
describe('Login', () => {
it('logs in 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')
})
})
// Playwright : async/await standard
import { test, expect } from '@playwright/test';
test('logs in successfully', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome');
});// Cypress
cy.intercept('GET', '/api/items', { fixture: 'items.json' }).as('getItems')
cy.visit('/items')
cy.wait('@getItems')
// Playwright
await page.route('**/api/items', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Item One' }]),
});
});
await page.goto('/items');
// Pas d'attente explicite nécessaire. L'auto-waiting s'en charge.// Cypress beforeEach
beforeEach(() => {
cy.login('admin@example.com', 'password')
})
// Playwright : utiliser les fixtures pour le setup partagé
import { test as base } from '@playwright/test';
const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
});
test('admin dashboard loads', async ({ authenticatedPage }) => {
await expect(authenticatedPage.getByRole('heading')).toHaveText('Dashboard');
});Les fixtures Playwright sont plus composables que le pattern beforeEach de Cypress. On peut les empiler, les faire dépendre les unes des autres, et les scoper à un seul test ou à un fichier entier. Pendant la migration, convertissez les blocs de login beforeEach en fixture storageState. Elle sérialise les cookies et le localStorage du navigateur après une seule connexion et les réutilise entre les tests sans répéter le flux de connexion UI.
Stratégie de migration : exécution parallèle et strangler fig
Ne migrez pas tout d'un coup. Cette approche produit une période de plusieurs semaines où rien ne fonctionne et où votre CI n'a plus de signal vert. Utilisez plutôt le pattern strangler fig : faites tourner Playwright et votre framework existant côte à côte, en migrant une zone fonctionnelle à la fois.
Étape 1 : installez Playwright aux côtés de votre framework existant.npm init playwright@latestChoisissez "TypeScript", mettez les tests dans playwright-tests/ (pas tests/ si c'est votre répertoire Selenium/Cypress), et passez le fichier GitHub Actions pour l'instant.
package.json et du pipeline CI.
Pour les grandes suites Selenium (1000+ tests), envisagez une approche mixte. Utilisez le codegen intégré de Playwright pour enregistrer de nouveaux tests sur les flux à haute valeur. Et écrivez un script de migration pour convertir mécaniquement les tests Selenium simples (clic, fill, assertion de texte) qui suivent des patterns prévisibles. La conversion mécanique ne produira pas du Playwright idiomatique, mais elle crée une base fonctionnelle que vous pouvez nettoyer progressivement.
Pièges courants de migration
Les attentes codées en dur. L'erreur la plus fréquente lors de la migration de tests Selenium est de copierThread.sleep() ou await driver.sleep(2000) dans Playwright. Ces attentes cachent de vrais problèmes : des éléments qui ne sont pas actionnables, des animations qui ne sont pas terminées, des requêtes réseau qui n'ont pas abouti. Dans Playwright, page.waitForTimeout(2000) existe mais ne devrait presque jamais apparaître dans le code de test. Remplacez chaque attente codée en dur par une assertion explicite que l'élément dont vous avez besoin est dans l'état attendu.
// Incorrect : copier l'habitude Selenium
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();
// Correct : attendre la condition spécifique
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();By.xpath("//div[3]/button") avec le XPath copié verbatim dans page.locator() transporte la fragilité avec lui. Tout changement de structure DOM le casse. Profitez de la migration pour forcer le remplacement des sélecteurs fragiles par des sélecteurs sémantiques.
Les hypothèses d'ordonnancement des tests. Les suites Selenium partagent souvent de l'état entre les tests : un test qui crée un utilisateur, et le test suivant qui se connecte avec cet utilisateur. Playwright exécute les tests en parallèle par défaut sur plusieurs workers, donc l'état partagé entre tests provoque des échecs aléatoires difficiles à reproduire. Chaque test doit créer ses propres données et nettoyer après lui-même, ou utiliser storageState de Playwright pour réutiliser l'authentification sans partager d'état mutable.
// Incorrect : dépend d'un test précédent ayant créé l'utilisateur
test('user can update profile', async ({ page }) => {
await page.goto('/profile'); // suppose un état de connexion d'un test précédent
// ...
});
// Correct : chaque test est autonome
test('user can update profile', async ({ page, context }) => {
await context.addCookies(/* cookies auth depuis storageState */);
await page.goto('/profile');
// ...
});driver.switchTo().frame() de Selenium a un équivalent direct dans Playwright, mais suffisamment différent pour prêter à confusion :
// Selenium
driver.switchTo().frame(driver.findElement(By.cssSelector('iframe#payment')));
driver.findElement(By.cssSelector('input[name="card"]')).sendKeys('4242...');
driver.switchTo().defaultContent();
// Playwright
const frame = page.frameLocator('iframe#payment');
await frame.locator('input[name="card"]').fill('4242...');
// Pas besoin de revenir en arrière. Le frameLocator de Playwright est automatiquement scopé.Migration CI : mettre à jour votre pipeline
Remplacer Selenium Grid ou Cypress Cloud par Playwright en CI est simple. Playwright installe les navigateurs pendant sa configuration et tourne sans processus de driver séparé.
Depuis Selenium Grid :# Avant : Selenium Grid avec Docker
services:
selenium-hub:
image: selenium/hub:4
chrome:
image: selenium/node-chrome:4
steps:
- name: Run Selenium tests
run: mvn test -Dwebdriver.hub.url=http://selenium-hub:4444
# Après : Playwright (pas de services externes nécessaires)
steps:
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test# Avant : Cypress avec parallélisation Cloud payante
- name: Cypress run
uses: cypress-io/github-action@v6
with:
record: true
parallel: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
# Après : Playwright avec sharding intégré gratuit
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Install Playwright
run: npm ci && npx playwright install --with-deps chromium
- name: Run shard
run: npx playwright test --shard=${{ matrix.shard }}/4
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/Pour fusionner les rapports de shards en un seul rapport HTML une fois tous les shards terminés :
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-blob-reportsPendant la phase d'exécution parallèle de votre migration, configurez la CI pour lancer les deux suites mais reporter les échecs séparément. Ainsi un test Selenium flaky ne bloque pas la progression de votre migration Playwright, et vous avez un signal clair sur quel framework cause quels échecs.
FAQ
Combien de temps prend réellement la migration ?Pour une suite Selenium de 500 tests avec un ingénieur dédié, comptez 4 à 6 semaines pour la migration et 2 semaines de stabilisation. Les suites Cypress de même taille prennent 2 à 4 semaines parce que les patterns de locators et le modèle mental JavaScript sont plus proches. Les grandes suites (2000+ tests) sans ownership clair peuvent s'étirer sur des mois. Prévoyez 20 à 30 % de plus que votre estimation initiale.
Dois-je réécrire chaque test ou puis-je automatiser une partie ?Vous pouvez automatiser les parties mécaniques : remplacer cy.get( par page.locator(, convertir cy.visit en await page.goto, envelopper tout dans async. Ça couvre peut-être 30 % du travail et crée quelque chose qui compile. Les 70 % restants, remplacer les sélecteurs fragiles par des sélecteurs sémantiques, supprimer les attentes codées en dur, corriger les problèmes d'ordonnancement de tests, nécessitent un jugement humain.
Gardez le pattern, remplacez les imports et le constructeur. L'investissement structurel dans le POM n'est pas perdu. La refactorisation est mécanique pour la plupart des méthodes.
Dois-je migrer vers les tests de composants Playwright en même temps ?Non. Migrez d'abord votre suite E2E. Les tests de composants Playwright sont un outil séparé avec une courbe d'apprentissage séparée. Vouloir migrer deux choses simultanément ralentit les deux.
Et si certains tests ne peuvent vraiment pas migrer ?Gardez-les dans l'ancien framework. Faites-les tourner sur un job CI séparé. Ne laissez pas le parfait bloquer le bon. Une suite migrée à 90 % sur Playwright est sensiblement mieux qu'une suite à 0 % parce que vous attendez de porter trois tests de cas limites.
Que se passe-t-il après la migration ?Une fois votre suite sur Playwright, vous êtes en position de l'améliorer. Ajoutez le mocking réseau, introduisez storageState pour éliminer les flux de connexion répétés, et activez l'exécution parallèle avec workers: 'auto'. Ajoutez aussi une couverture de tests API avec la fixture request de Playwright. La migration est un plancher, pas un plafond.