Les hooks test.describe, test.beforeEach, test.afterEach, test.beforeAll et test.afterAll de Playwright permettent de grouper les tests liés et de partager la logique de setup sans duplication. Ils contrôlent aussi précisément quand l'état est créé et nettoyé.
Le test de base
Avant d'ajouter de la structure, un test unitaire ressemble à ça :
import { test, expect } from '@playwright/test';
test('l\'utilisateur peut se connecter', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL('/dashboard');
});Simple. Mais avec 10 tests qui démarrent tous par les mêmes étapes de connexion, c'est 10 blocs dupliqués. Quand le flux de connexion change, on met à jour 10 endroits au lieu d'un.
test.beforeEach : s'exécute avant chaque test
beforeEach exécute du code de setup avant chaque test dans sa portée. L'usage le plus courant : naviguer vers une page ou se connecter.
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
});
test('le tableau de bord affiche le nom de l\'utilisateur', async ({ page }) => {
// Déjà connecté et sur /dashboard
await expect(page.getByTestId('user-name')).toContainText('Test User');
});
test('l\'utilisateur peut accéder aux paramètres', async ({ page }) => {
// Aussi déjà connecté
await page.getByTestId('settings-link').click();
await expect(page).toHaveURL('/settings');
});Chaque test démarre avec l'utilisateur déjà connecté et sur le tableau de bord. Aucune duplication.
test.afterEach : nettoyage après chaque test
afterEach s'exécute après chaque test, qu'il ait passé ou échoué. À utiliser pour le nettoyage qui doit avoir lieu après chaque test.
test.afterEach(async ({ page }, testInfo) => {
// Prendre un screenshot en cas d'échec (Playwright peut aussi le faire via la config)
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({ path: `screenshots/${testInfo.title}.png` });
}
});Ou pour le nettoyage via API :
let createdUserId: number;
test.beforeEach(async ({ request }) => {
const response = await request.post('/api/users', {
data: { email: 'temp@test.com', password: 'Pass1' },
});
const body = await response.json();
createdUserId = body.id;
});
test.afterEach(async ({ request }) => {
// Supprimer l'utilisateur créé pendant le setup
await request.delete(`/api/users/${createdUserId}`);
});test.describe : grouper les tests liés
test.describe crée un groupe nommé de tests. Il regroupe les tests par fonctionnalité ou page, applique beforeEach/afterEach uniquement à un sous-ensemble, et imbrique des scénarios liés.
import { test, expect } from '@playwright/test';
test.describe('Page de connexion', () => {
test('affiche les champs e-mail et mot de passe', async ({ page }) => {
await page.goto('/login');
await expect(page.getByTestId('email')).toBeVisible();
await expect(page.getByTestId('password')).toBeVisible();
});
test('affiche une erreur avec un mauvais mot de passe', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'MauvaisPass');
await page.click('[data-testid="submit"]');
await expect(page.getByTestId('error-message')).toBeVisible();
});
});
test.describe('Tableau de bord', () => {
test.beforeEach(async ({ page }) => {
// Connexion avant chaque test du tableau de bord
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
});
test('affiche le nom de l\'utilisateur', async ({ page }) => {
await expect(page.getByTestId('user-name')).toBeVisible();
});
test('affiche les commandes récentes', async ({ page }) => {
await expect(page.getByTestId('orders-section')).toBeVisible();
});
});Le beforeEach de connexion s'applique uniquement au bloc describe Tableau de bord ; les tests de la Page de connexion ne sont pas affectés.
test.beforeAll et test.afterAll
beforeAll s'exécute une seule fois avant tous les tests dans sa portée (pas avant chaque test). afterAll s'exécute une seule fois après tous les tests.
Cas d'usage : setup coûteux qui ne doit se faire qu'une fois, comme créer un utilisateur de test via API, alimenter une base de données, ou démarrer un serveur.
import { test, expect, request as playwrightRequest } from '@playwright/test';
let authToken: string;
test.beforeAll(async () => {
// Créer le token d'auth une seule fois pour tous les tests de ce fichier
const ctx = await playwrightRequest.newContext();
const response = await ctx.post('/api/auth/login', {
data: { email: 'admin@test.com', password: 'AdminPass1' },
});
const body = await response.json();
authToken = body.token;
await ctx.dispose();
});
test('l\'admin peut voir tous les utilisateurs', async ({ request }) => {
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(200);
});
test('l\'admin peut supprimer un utilisateur', async ({ request }) => {
// Utilise aussi authToken — partagé entre tous les tests
});beforeAll et afterAll s'exécutent dans un contexte partagé. Les modifications de l'état partagé persistent entre les tests, ce qui peut provoquer des tests flaky si on ne fait pas attention. Préférez beforeEach pour la plupart des setups.
Blocs describe imbriqués
On peut imbriquer des blocs describe pour créer une hiérarchie :
test.describe('Flux de paiement', () => {
test.beforeEach(async ({ page }) => {
await loginAsUser(page);
await addItemToCart(page, 'product-123');
});
test.describe('avec une carte valide', () => {
test.beforeEach(async ({ page }) => {
await fillShippingAddress(page);
await fillValidCard(page, '4242 4242 4242 4242');
});
test('finalise l\'achat', async ({ page }) => {
await page.getByTestId('place-order').click();
await expect(page.getByTestId('order-confirmation')).toBeVisible();
});
test('envoie un e-mail de confirmation', async ({ page }) => {
// ...
});
});
test.describe('avec une carte invalide', () => {
test('affiche un message d\'erreur', async ({ page }) => {
await fillValidCard(page, '0000 0000 0000 0000');
await page.getByTestId('place-order').click();
await expect(page.getByTestId('payment-error')).toBeVisible();
});
});
});Ordre d'exécution des hooks pour un test imbriqué :
1. beforeEach externe (connexion + ajout au panier)
2. beforeEach interne (adresse de livraison + carte)
3. Le test lui-même
4. afterEach interne (s'il existe)
5. afterEach externe (s'il existe)
test.skip et test.only
Deux modificateurs utiles pendant le développement :
// Ignorer ce test (marqué comme ignoré, pas comme échoué)
test.skip('fonctionnalité pas encore implémentée', async ({ page }) => {
// ...
});
// Exécuter uniquement ce test (ignore tous les autres du fichier)
test.only('débogage de ce cas spécifique', async ({ page }) => {
// ...
});Ne commitez jamais test.only sur la branche principale : ça fait échouer toute la suite CI en n'exécutant qu'un seul test.
Skip conditionnel, utile pour les tests spécifiques à un environnement :
test('panneau admin', async ({ page }) => {
test.skip(process.env.ENV === 'production', 'Ignoré en production');
// ...
});Comment les tests sont nommés dans les rapports
Le nom affiché dans les rapports combine le label describe et le label test :
test.describe('Page de connexion', () => {
test('affiche une erreur avec un mauvais mot de passe', async ({ page }) => { ... });
});
// Rapporté comme : "Page de connexion > affiche une erreur avec un mauvais mot de passe"Des noms descriptifs valent leur pesant d'or quand un run CI affiche 3 échecs et qu'on doit les comprendre sans ouvrir le code.
Exemple de structure de fichier complet
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('Authentification utilisateur', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.navigate();
});
test.describe('Identifiants valides', () => {
test('redirige vers le tableau de bord', async ({ page }) => {
await loginPage.login('user@test.com', 'ValidPass1');
await expect(page).toHaveURL('/dashboard');
});
test('définit le cookie d\'auth', async ({ page }) => {
await loginPage.login('user@test.com', 'ValidPass1');
const cookies = await page.context().cookies();
expect(cookies.some(c => c.name === 'auth_token')).toBe(true);
});
});
test.describe('Identifiants invalides', () => {
test('mauvais mot de passe affiche une erreur', async () => {
await loginPage.login('user@test.com', 'MauvaisPass');
await expect(loginPage.errorMessage).toBeVisible();
});
test('e-mail vide affiche une erreur de validation', async () => {
await loginPage.login('', 'ValidPass1');
await expect(loginPage.emailError).toContainText('requis');
});
});
});Récapitulatif
| Hook | Quand s'exécute | Utiliser pour |
|------|----------------|---------------|
| test.beforeEach | Avant chaque test dans la portée | Navigation, connexion, remise à zéro de l'état |
| test.afterEach | Après chaque test dans la portée | Nettoyage, screenshots en cas d'échec |
| test.beforeAll | Une fois avant tous les tests dans la portée | Setup coûteux à usage unique |
| test.afterAll | Une fois après tous les tests dans la portée | Teardown à usage unique |
| test.describe | (groupement, pas un hook) | Organiser les tests, délimiter la portée des hooks |
Commencez par test.beforeEach pour la plupart des setups. Ajoutez test.describe pour grouper les tests liés. Utilisez test.beforeAll uniquement quand le setup est réellement coûteux et peut être partagé entre les tests sans risque.