Playwright
Playwright est un framework de tests end-to-end (e2e) développé par Microsoft. Il permet d'automatiser Chromium, Firefox et WebKit avec une seule API, et intègre nativement l'auto-attente, les assertions web-first et l'isolation complète entre les tests.
Playwright vs Cypress : Playwright supporte nativement multi-navigateurs, multi-onglets, iframes et requêtes API. Il est plus rapide en CI et ne nécessite pas de serveur dédié.
Installation et configuration
npm init playwright@latest
# → installe Playwright, crée playwright.config.ts et un exemple de test
Installation manuelle :
npm install --save-dev @playwright/test
npx playwright install # installe les navigateurs (Chromium, Firefox, WebKit)
npx playwright install chromium # installer un seul navigateur
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000, // timeout par test (ms)
retries: process.env.CI ? 2 : 0, // retry automatique en CI
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'], // rapport HTML dans playwright-report/
['list'], // sortie console
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // enregistre une trace en cas d'échec
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
// Lancer le serveur de dev avant les tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Commandes utiles
npx playwright test # lancer tous les tests
npx playwright test mon-test.spec.ts # un fichier spécifique
npx playwright test --headed # avec le navigateur visible
npx playwright test --debug # mode debug (pause sur chaque étape)
npx playwright test --ui # interface graphique interactive
npx playwright show-report # ouvrir le rapport HTML
npx playwright codegen https://... # générer des tests en enregistrant des actions
Premier test e2e
// tests/exemple.spec.ts
import { test, expect } from '@playwright/test';
test('la page d\'accueil affiche le titre', async ({ page }) => {
await page.goto('/'); // navigue vers baseURL + /
await expect(page).toHaveTitle(/Mon Application/);
});
test('l\'utilisateur peut se connecter', async ({ page }) => {
await page.goto('/connexion');
await page.getByLabel('Email').fill('alice@exemple.com');
await page.getByLabel('Mot de passe').fill('secret');
await page.getByRole('button', { name: 'Se connecter' }).click();
await expect(page).toHaveURL('/tableau-de-bord');
await expect(page.getByText('Bienvenue, Alice')).toBeVisible();
});
Grouper les tests
import { test, expect } from '@playwright/test';
test.describe('Authentification', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/connexion');
});
test('connexion avec identifiants valides', async ({ page }) => { ... });
test('affiche une erreur si le mot de passe est incorrect', async ({ page }) => { ... });
test.skip('à implémenter', async ({ page }) => { ... });
test.only('seul ce test s\'exécute', async ({ page }) => { ... });
});
Locators et sélecteurs
Les locators sont la façon recommandée de cibler des éléments. Ils intègrent l'auto-attente et les retry automatiques.
Locators recommandés (par priorité)
// Par rôle ARIA (le plus robuste)
page.getByRole('button', { name: 'Envoyer' })
page.getByRole('heading', { name: 'Connexion' })
page.getByRole('link', { name: /accueil/i })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('checkbox', { name: 'Se souvenir de moi' })
// Par label (champs de formulaire)
page.getByLabel('Email')
page.getByLabel('Mot de passe')
// Par placeholder
page.getByPlaceholder('Rechercher...')
// Par texte
page.getByText('Bienvenue')
page.getByText('Bienvenue', { exact: true })
// Par alt text (images)
page.getByAltText('Logo de l\'entreprise')
// Par test id (en dernier recours)
page.getByTestId('submit-btn') // data-testid="submit-btn"
Sélecteurs CSS et XPath (si nécessaire)
page.locator('.btn-primary')
page.locator('#mon-id')
page.locator('input[type="email"]')
page.locator('xpath=//button[@type="submit"]')
Filtres et chaînage
// Filtrer par texte
page.getByRole('listitem').filter({ hasText: 'Alice' })
// Filtrer par contenu enfant
page.getByRole('listitem').filter({ has: page.getByRole('button') })
// Locator dans un locator
page.getByRole('article').getByRole('button', { name: 'Lire la suite' })
// Premier, dernier, nième
page.getByRole('listitem').first()
page.getByRole('listitem').last()
page.getByRole('listitem').nth(2) // index 0-based
Assertions
Playwright utilise des assertions web-first : elles attendent automatiquement que la condition soit remplie (retry jusqu'au timeout).
Page
await expect(page).toHaveURL('/tableau-de-bord');
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Mon Application');
Éléments
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toBeEmpty(); // champ vide
await expect(locator).toHaveText('Bonjour');
await expect(locator).toContainText('Bon');
await expect(locator).toHaveValue('alice@exemple.com');
await expect(locator).toHaveAttribute('href', '/accueil');
await expect(locator).toHaveClass('btn-primary');
await expect(locator).toHaveCount(3); // nombre d'éléments correspondants
Assertions génériques (sans auto-attente)
expect(valeur).toBe(42);
expect(valeur).toEqual({ a: 1 });
expect(valeur).toBeTruthy();
expect(tableau).toHaveLength(3);
Interactions
Navigation
await page.goto('https://exemple.com');
await page.goto('/chemin'); // relatif à baseURL
await page.goBack();
await page.goForward();
await page.reload();
Clics et formulaires
await locator.click();
await locator.dblclick();
await locator.click({ button: 'right' }); // clic droit
await locator.click({ modifiers: ['Shift'] }); // Shift+clic
await locator.fill('texte'); // remplace la valeur entière
await locator.type('texte'); // simule la frappe touche par touche
await locator.clear(); // vide le champ
await locator.press('Enter');
await locator.press('Control+A');
await locator.selectOption('option1'); // <select>
await locator.selectOption({ label: 'Option 1' });
await locator.check(); // checkbox / radio
await locator.uncheck();
Upload et drag-and-drop
await page.getByLabel('Fichier').setInputFiles('chemin/vers/fichier.pdf');
await page.getByLabel('Fichier').setInputFiles([]); // vider
await locator.dragTo(page.locator('#destination'));
Hover et scroll
await locator.hover();
await locator.scrollIntoViewIfNeeded();
await page.mouse.wheel(0, 300); // scroll de 300px vers le bas
Clavier et souris
await page.keyboard.press('Escape');
await page.keyboard.type('Bonjour');
await page.mouse.move(100, 200);
await page.mouse.click(100, 200);
Async et réseau
Attendre des événements
// Attendre une navigation
await page.waitForURL('/tableau-de-bord');
// Attendre qu'un élément soit visible
await page.waitForSelector('.chargement', { state: 'hidden' });
// Attendre une réponse réseau
const [response] = await Promise.all([
page.waitForResponse('**/api/utilisateurs'),
page.getByRole('button', { name: 'Charger' }).click(),
]);
expect(response.status()).toBe(200);
Intercepter et mocker le réseau
// Mocker une réponse API
await page.route('**/api/utilisateurs', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, nom: 'Alice' }]),
});
});
// Modifier une réponse existante
await page.route('**/api/produits', async route => {
const response = await route.fetch();
const json = await response.json();
json.push({ id: 99, nom: 'Produit test' });
await route.fulfill({ response, json });
});
// Bloquer des ressources (accélérer les tests)
await page.route('**/*.{png,jpg,gif,css}', route => route.abort());
// Intercepter et modifier les headers
await page.route('**/api/**', async route => {
await route.continue({
headers: {
...route.request().headers(),
'Authorization': 'Bearer token-de-test',
},
});
});
Screenshots, vidéos et traces
Screenshots
await page.screenshot({ path: 'screenshot.png' });
await page.screenshot({ path: 'screenshot.png', fullPage: true });
await locator.screenshot({ path: 'element.png' });
// Assertion visuelle (snapshot testing)
await expect(page).toHaveScreenshot('page-accueil.png');
await expect(locator).toHaveScreenshot('bouton.png');
Traces
La trace enregistre chaque action, screenshot et requête réseau pour le débogage.
// Dans playwright.config.ts
use: {
trace: 'on-first-retry', // enregistre en cas d'échec
// autres options : 'on', 'off', 'retain-on-failure'
}
npx playwright show-trace trace.zip # ouvrir une trace
Ouvrir le viewer de traces
Après un test échoué, la trace est disponible dans le rapport HTML (npx playwright show-report).
API testing
Playwright peut tester des APIs REST directement, sans navigateur.
import { test, expect } from '@playwright/test';
test('GET /api/utilisateurs retourne une liste', async ({ request }) => {
const response = await request.get('/api/utilisateurs');
expect(response.status()).toBe(200);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body).toHaveLength(3);
expect(body[0]).toMatchObject({ id: expect.any(Number), nom: expect.any(String) });
});
test('POST /api/utilisateurs crée un utilisateur', async ({ request }) => {
const response = await request.post('/api/utilisateurs', {
data: { nom: 'Alice', email: 'alice@exemple.com' },
headers: { 'Authorization': 'Bearer mon-token' },
});
expect(response.status()).toBe(201);
const created = await response.json();
expect(created.nom).toBe('Alice');
});
test('DELETE /api/utilisateurs/:id supprime un utilisateur', async ({ request }) => {
const response = await request.delete('/api/utilisateurs/1');
expect(response.status()).toBe(204);
});
Combiner API et UI dans un même test
test('l\'utilisateur créé via API apparaît dans l\'interface', async ({ page, request }) => {
// Créer via API (plus rapide que via l'UI)
await request.post('/api/utilisateurs', {
data: { nom: 'Bob', email: 'bob@exemple.com' },
});
// Vérifier dans l'UI
await page.goto('/utilisateurs');
await expect(page.getByText('Bob')).toBeVisible();
});
Page Object Model
Le POM encapsule les sélecteurs et les interactions d'une page dans une classe réutilisable.
// pages/ConnexionPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class ConnexionPage {
readonly page: Page;
readonly champEmail: Locator;
readonly champMotDePasse: Locator;
readonly boutonConnexion: Locator;
readonly messageErreur: Locator;
constructor(page: Page) {
this.page = page;
this.champEmail = page.getByLabel('Email');
this.champMotDePasse = page.getByLabel('Mot de passe');
this.boutonConnexion = page.getByRole('button', { name: 'Se connecter' });
this.messageErreur = page.getByRole('alert');
}
async goto() {
await this.page.goto('/connexion');
}
async seConnecter(email: string, motDePasse: string) {
await this.champEmail.fill(email);
await this.champMotDePasse.fill(motDePasse);
await this.boutonConnexion.click();
}
}
// tests/connexion.spec.ts
import { test, expect } from '@playwright/test';
import { ConnexionPage } from '../pages/ConnexionPage';
test.describe('Connexion', () => {
test('connexion réussie', async ({ page }) => {
const connexionPage = new ConnexionPage(page);
await connexionPage.goto();
await connexionPage.seConnecter('alice@exemple.com', 'secret');
await expect(page).toHaveURL('/tableau-de-bord');
});
test('erreur si mot de passe incorrect', async ({ page }) => {
const connexionPage = new ConnexionPage(page);
await connexionPage.goto();
await connexionPage.seConnecter('alice@exemple.com', 'mauvais');
await expect(connexionPage.messageErreur).toContainText('Identifiants invalides');
});
});
Configuration avancée et CI
Fixtures personnalisées
// fixtures.ts
import { test as base } from '@playwright/test';
import { ConnexionPage } from './pages/ConnexionPage';
type Fixtures = {
utilisateurConnecte: ConnexionPage;
};
export const test = base.extend<Fixtures>({
utilisateurConnecte: async ({ page }, use) => {
const connexionPage = new ConnexionPage(page);
await connexionPage.goto();
await connexionPage.seConnecter('alice@exemple.com', 'secret');
await use(connexionPage);
},
});
export { expect } from '@playwright/test';
// tests/tableau-de-bord.spec.ts
import { test, expect } from '../fixtures';
test('affiche le tableau de bord', async ({ utilisateurConnecte, page }) => {
await expect(page).toHaveURL('/tableau-de-bord');
});
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30