L'isolation des tests signifie que chaque test provisionne ce dont il a besoin, ne fait aucune hypothèse sur les tests précédents, et ne laisse aucun état derrière lui. Les fixtures page et context de Playwright isolent automatiquement l'état du navigateur, mais l'état applicatif dans les bases de données partagées et les variables de module requiert une gestion explicite.
Ce que l'isolation des tests signifie vraiment
Un test isolé ne fait aucune hypothèse sur le monde avant son exécution et ne laisse aucune trace après. Chaque test provisionne ce dont il a besoin, fait son travail, et l'environnement après ce test est identique à l'environnement avant.
Cette définition semble évidente jusqu'à ce qu'on réalise ce que "état" recouvre dans un vrai projet. Ces trois types d'état peuvent fuir entre les tests : l'état du navigateur (cookies, localStorage, session), l'état applicatif (base de données, comptes, feature flags) et l'état du code de test (variables de module, fixtures partagées).
Les fixtures page et context de Playwright gèrent déjà l'isolation de l'état du navigateur. Chaque test reçoit automatiquement un BrowserContext frais : une session propre sans cookies, sans localStorage, rien venant d'un autre test. Ce n'est pas une option à activer, c'est le comportement par défaut. En utilisant la fixture page, on est déjà isolé au niveau du navigateur.
// Chaque test reçoit un contexte navigateur entièrement frais. C'est automatique.
test('l\'utilisateur anonyme voit le bouton de connexion', async ({ page }) => {
await page.goto('/dashboard');
// Pas de cookies, pas de session. Vraiment propre.
await expect(page.getByRole('link', { name: 'Se connecter' })).toBeVisible();
});
test('également anonyme, le test précédent n\'a laissé aucune trace', async ({ page }) => {
await page.goto('/dashboard');
// Même état propre, quelle que soit l'exécution précédente
await expect(page.getByRole('link', { name: 'Se connecter' })).toBeVisible();
});La partie difficile, c'est l'état applicatif. Playwright ne peut pas isoler votre base de données à votre place. C'est votre travail.
Échecs d'isolation classiques : des patterns familiers
L'échec d'isolation le plus courant ressemble à ça. Un fichier de test contient un test de setup qui crée des données, quelques tests qui utilisent ces données, et un test de teardown qui les supprime. Quelqu'un a écrit ça pour éviter de répéter le code de création.
// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';
// L'état partagé, source du problème
let testUserId: number;
test('setup : créer un utilisateur de test', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Test User', email: 'testuser@example.com' }
});
testUserId = (await response.json()).id;
});
test('peut voir le profil utilisateur', async ({ page }) => {
await page.goto(`/users/${testUserId}`);
await expect(page.getByRole('heading', { name: 'Test User' })).toBeVisible();
});
test('peut modifier le nom d\'utilisateur', async ({ page }) => {
await page.goto(`/users/${testUserId}/edit`);
// ...
});
test('teardown : supprimer l\'utilisateur de test', async ({ request }) => {
await request.delete(`/api/users/${testUserId}`);
});Ça fonctionne parfaitement quand les tests s'exécutent séquentiellement dans l'ordre du fichier. Ça casse de quatre façons différentes dès que les conditions changent. Avec fullyParallel, les tests s'exécutent hors ordre. Un test d'une autre suite peut supprimer l'utilisateur. Si le setup échoue, testUserId reste undefined pour tous les tests suivants. Et avec --shard, le setup et le teardown peuvent se retrouver sur des machines différentes.
Le deuxième échec classique : la collision d'adresses e-mail. Un test crée un utilisateur avec email: 'alice@test.com'. Le test passe. À la prochaine exécution, l'utilisateur existe déjà parce que le teardown de l'exécution précédente a échoué (crash du navigateur, timeout CI, erreur qui a sauté l'afterAll). Résultat : une erreur 409 Conflict qui ressemble à un bug dans le formulaire d'inscription.
// MAUVAIS : l'e-mail codé en dur crée des conflits à chaque réexécution
test('enregistrer un nouvel utilisateur', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('E-mail').fill('alice@test.com');
await page.getByLabel('Mot de passe').fill('Password123!');
await page.getByRole('button', { name: 'Créer un compte' }).click();
await expect(page.getByText('Bienvenue, alice')).toBeVisible();
});
// BON : e-mail unique par exécution, aucune collision possible
test('enregistrer un nouvel utilisateur', async ({ page }) => {
const email = `alice-${Date.now()}@test.com`;
await page.goto('/register');
await page.getByLabel('E-mail').fill(email);
await page.getByLabel('Mot de passe').fill('Password123!');
await page.getByRole('button', { name: 'Créer un compte' }).click();
await expect(page.getByText('Bienvenue, alice')).toBeVisible();
});Date.now() est la stratégie d'unicité la plus simple. Pour des identifiants plus lisibles, on peut combiner avec un suffixe aléatoire : ` alice-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.com . Le format exact importe peu du moment qu'il ne peut pas entrer en collision.
Isolation des données : maîtriser l'environnement via l'API
Le bon modèle d'isolation : chaque test crée tout ce dont il a besoin via l'API au début et fait son travail. Il supprime les données à la fin via
afterEach ou une fixture de nettoyage. Aucun test ne dépend d'un autre test pour avoir créé quoi que ce soit.
import { test, expect } from '@playwright/test';
test('l\'admin peut désactiver un compte utilisateur', async ({ page, request }) => {
// Créer les données nécessaires, entièrement appartenant à ce test
const createResponse = await request.post('/api/users', {
data: {
name: 'Utilisateur temporaire',
email: `temp-${Date.now()}@example.com`,
role: 'member'
}
});
expect(createResponse.ok()).toBeTruthy();
const { id: userId } = await createResponse.json();
try {
// Le test en lui-même
await page.goto(`/admin/users/${userId}`);
await page.getByRole('button', { name: 'Désactiver le compte' }).click();
await page.getByRole('button', { name: 'Confirmer' }).click();
await expect(page.getByTestId('account-status')).toHaveText('Inactif');
} finally {
// Le nettoyage s'exécute même si le test échoue
await request.delete(`/api/users/${userId}`);
}
});
Le pattern
try/finally est essentiel. Sans finally, un échec de test saute le nettoyage et laisse des données orphelines en base. Sur des dizaines d'exécutions, ces enregistrements s'accumulent et provoquent des échecs imprévisibles ailleurs.
La façon la plus propre de gérer ça dans Playwright : une fixture personnalisée qui encapsule le cycle de vie automatiquement.
// fixtures/api-fixtures.ts
import { test as base, expect } from '@playwright/test';
type ApiFixtures = {
createUser: (overrides?: Partial<{ name: string; email: string; role: string }>) => Promise<{ id: number; email: string }>;
};
export const test = base.extend<ApiFixtures>({
createUser: async ({ request }, use) => {
const createdIds: number[] = [];
const factory = async (overrides = {}) => {
const response = await request.post('/api/users', {
data: {
name: 'Test User',
email: `test-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.com`,
role: 'member',
...overrides
}
});
const user = await response.json();
createdIds.push(user.id);
return user;
};
await use(factory);
// Nettoyage de tous les utilisateurs créés par ce test, s'exécute automatiquement après chaque test
for (const id of createdIds) {
await request.delete(`/api/users/${id}`);
}
}
});
N'importe quel test peut utiliser
createUser et le nettoyage est garanti :
import { test } from '../fixtures/api-fixtures';
import { expect } from '@playwright/test';
test('l\'éditeur peut mettre à jour le profil utilisateur', async ({ page, createUser }) => {
const user = await createUser({ name: 'Jane', role: 'editor' });
await page.goto(`/users/${user.id}`);
await page.getByRole('button', { name: 'Modifier le profil' }).click();
await page.getByLabel('Nom affiché').fill('Jane Modifiée');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Jane Modifiée' })).toBeVisible();
});
Le test est lisible, le nettoyage est invisible et automatique, et créer plusieurs utilisateurs dans un seul test revient à appeler
createUser deux fois.
Construisez vos factories de données comme des fixtures dès le premier jour. Les intégrer dans une suite existante est bien plus difficile. Un ensemble de fixtures createUser, createOrder et createProduct couvre 80% des besoins de données de test typiques pour une application e-commerce.
storageState et isolation de l'authentification : connexion unique, isolation garantie
La fixture
createUser gère l'isolation des données. L'authentification est un problème distinct. On ne veut pas que chaque test fasse un flux de connexion complet via le navigateur, c'est lent. Mais on ne veut pas non plus que les tests partagent une session navigateur en direct. Un test qui se déconnecte ou modifie les paramètres du compte casserait tous les tests concurrents.
Le bon pattern : se connecter une seule fois par worker (pas une fois par test, pas une fois globalement). Le
storageState authentifié est sauvegardé dans un fichier et rechargé au démarrage de chaque test. Chaque test obtient alors son propre contexte navigateur qui démarre dans un état authentifié, sans partager de session en direct.
// tests/auth.setup.ts s'exécute une fois par worker avant la suite de tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('s\'authentifier', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('E-mail').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Mot de passe').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre d'être passé la page de connexion
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// Sauvegarder l'état d'authentification
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import path from 'path';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: '**/auth.setup.ts',
},
{
name: 'tests authentifiés',
dependencies: ['setup'],
use: {
storageState: path.join(__dirname, '.auth/user.json'),
},
testMatch: '**/*.spec.ts',
},
],
});
Avec cette configuration, chaque test démarre dans un état authentifié sans passer par le flux de connexion. Comme
storageState charge les données depuis un fichier dans un nouveau BrowserContext, les sessions sont entièrement isolées. Ce que le test A fait à sa session n'a aucun effet sur la session du test B.
Si votre application gère plusieurs rôles (admin, éditeur, lecteur), créez un fichier storageState distinct pour chaque rôle pendant le setup. Les fixtures peuvent ensuite charger le bon état selon les besoins du test. C'est bien plus rapide que de se connecter avec des identifiants différents dans chaque test.
L'isolation rend le parallélisme sûr
L'isolation des tests conditionne directement l'exécution parallèle. On ne peut pas lancer les tests en parallèle s'ils partagent de l'état, et on ne peut pas profiter pleinement du parallélisme sans isolation correcte. Ce sont deux faces de la même réalité.
Quand Playwright exécute des tests en parallèle, différents workers tournent simultanément. Il n'y a aucune garantie d'ordre entre eux. Si le test A et le test B essaient tous deux de créer un utilisateur avec
email: 'admin@test.com', l'un des deux échouera avec une violation de contrainte d'unicité. Lequel ? Ça dépend d'une course de timing. C'est la définition d'un test flaky.
// playwright.config.ts — ne fonctionne que si les tests sont vraiment isolés
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // Chaque test dans chaque fichier s'exécute en parallèle
workers: process.env.CI ? 4 : '50%',
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
});
fullyParallel: true est le changement de configuration le plus rentable qu'on puisse faire sur une suite mature. Une suite de 150 tests à 3 secondes chacun prend 7 minutes et demie en séquentiel. Avec 4 workers et une isolation correcte, ça tombe à environ 2 minutes. La contrainte n'est pas la capacité de Playwright. C'est si les tests sont suffisamment isolés pour tourner sans se perturber mutuellement.
N'ajoutez pas de retries pour masquer des problèmes d'isolation. Les retries sont un outil légitime pour gérer les vraies instabilités (timeouts réseau, services tiers défaillants). Mais si un test échoue parce qu'il a tourné en même temps qu'un autre test et qu'ils ont marché sur les données l'un de l'autre, le retry réussira probablement, et on ne saura jamais qu'il y a un problème d'isolation jusqu'à ce qu'il s'aggrave. Corrigez l'isolation d'abord, ajoutez les retries ensuite si nécessaire.
Les problèmes d'état partagé s'aggravent avec le nombre de workers. Avec un seul worker, les tests s'exécutent dans un ordre où le problème d'état ne se déclenche pas. Avec deux workers, des échecs occasionnels apparaissent. Avec huit workers, la suite est systématiquement cassée. Si augmenter le nombre de workers rend la suite plus instable, c'est un signal quasi-certain qu'il y a de l'état partagé quelque part.
Trouver les fuites d'isolation dans une suite existante
Si vous héritez d'une suite et suspectez des problèmes d'isolation, voici les étapes concrètes pour les localiser.
Étape 1 : exécuter avec --workers=1 et comparer. Si la suite complète passe avec un seul worker et échoue avec deux ou plus, il y a un problème d'isolation. Les tests qui échouent sont les victimes ; les tests qui les cassent sont plus difficiles à trouver.
# La suite passe-t-elle en séquentiel ?
npx playwright test --workers=1
# Passe-t-elle encore avec du parallélisme ?
npx playwright test --workers=4
Étape 2 : randomiser l'ordre. Certains bugs d'isolation n'apparaissent que quand le test A s'exécute avant le test B, mais ils tournent toujours dans le même ordre, donc on ne voit jamais l'échec. Playwright n'a pas de randomisation d'ordre intégrée, mais on peut diviser les tests manuellement et les exécuter dans des séquences différentes pour détecter les dépendances d'ordre.
Étape 3 : chercher ces patterns de code spécifiquement. Les variables de module que les tests écrivent sont la cause numéro 1.
// Cherchez ces patterns dans vos fichiers de test. Chacun est une fuite d'isolation potentielle.
// Variable de module assignée dans un test
let userId: number;
let authToken: string;
let createdRecord: any;
// test.beforeAll créant des données utilisées par plusieurs tests
test.beforeAll(async ({ request }) => {
// Si quelque chose ici crée de l'état mutable partagé, c'est une fuite
});
// Adresses e-mail, noms d'utilisateurs ou identifiants uniques fixes
data: { email: 'fixed@test.com' }
data: { username: 'testadmin' }
data: { id: 1 }
Étape 4 : vérifier les chemins de nettoyage. Cherchez les test.afterAll et vérifiez que chaque appel de nettoyage est également couvert par afterEach ou le teardown d'une fixture. afterAll s'exécute une fois par suite. Si un test échoue à mi-chemin, afterAll s'exécute quand même, mais le nettoyage peut opérer sur un état partiel.
Étape 5 : ajouter le titre du test aux enregistrements en base. En développement, nommez vos données de test d'après le test qui les crée :
const user = await createUser({
name: `Test user for: ${test.info().title}`,
email: `test-${Date.now()}@example.com`
});
Quand vous regardez la base de données de test et voyez dix lignes nommées "Test user for: l'admin peut désactiver un compte utilisateur", vous savez immédiatement que ce sont des échecs de nettoyage orphelins de ce test, et quel test investiguer.
Comment appliquer ça dès lundi matin
Si vous avez une suite existante avec des problèmes d'isolation, n'essayez pas de tout corriger d'un coup. Une approche priorisée qui apporte de la valeur rapidement.
Les 30 premières minutes : auditez les variables de module dans les fichiers de test. Tout let ou var déclaré au niveau du module qui est écrit dans un bloc test() est un problème. Déplacez ces déclarations à l'intérieur du test, utilisez beforeEach pour créer un état frais, et vérifiez que les tests passent encore.
L'heure suivante : remplacez tous les identifiants uniques codés en dur dans les données de test. Adresses e-mail, noms d'utilisateurs, numéros de téléphone, tout champ avec une contrainte d'unicité doit être dynamique. Utilisez Date.now() ou une stratégie similaire. Ça élimine la classe d'échecs "le test échoue à la deuxième exécution".
Cette semaine : construisez une fixture createUser (ou celle de votre entité la plus courante). Mettez la logique de création et de suppression en un seul endroit, rendez-la automatique, et migrez les cinq fichiers de test les plus problématiques vers son usage. Vous verrez immédiatement à quel point ces tests deviennent plus simples.
Ce sprint : activez fullyParallel: true avec deux workers et observez le nombre d'échecs. Chaque nouvel échec est une fuite d'isolation qui se cachait. Corrigez chacune au fur et à mesure. Une fois la suite propre avec deux workers, passez à quatre. Continuez jusqu'à atteindre la limite de la machine ou que votre suite termine en moins de deux minutes.
L'objectif n'est pas l'isolation parfaite comme principe abstrait. C'est une suite qu'on peut exécuter avec
--workers=8` et dont on peut faire confiance aux résultats. L'isolation est le mécanisme ; un feedback rapide et fiable est l'objectif. Une fois les tests sans état, le parallélisme n'est plus qu'une valeur de configuration.
→ See also: Fixtures Playwright Expliquées: Des Intégrées aux Personnalisées | Déboguer les Tests Instables: Un Guide Pratique | Exécution Parallèle dans Playwright: Workers, Fragments et Fragmentation pour la Vitesse | Tests Instables: Pourquoi ils Arrivent et Comment les Éliminer | Gestion des Données de Test dans Playwright: Stratégies et Patterns