Un framework Playwright qui dépasse 200 tests nécessite des décisions délibérées sur la structure de dossiers, la gestion de l'environnement, la gestion des données de test, et l'organisation des fixtures. Ces décisions doivent être prises tôt.
La structure de dossiers qui tient à l'échelle
Les décisions d'architecture au niveau des dossiers se propagent partout. Une structure qui reflète le but du framework (séparation entre logique de test, interaction de page, configuration des données, et utilitaires) garde la complexité gérable à mesure que la suite grandit.
my-app-tests/
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-crud.spec.ts
items-search.spec.ts
pages/
BasePage.ts
LoginPage.ts
DashboardPage.ts
fixtures/
index.ts
auth.fixture.ts
pages.fixture.ts
data.fixture.ts
data/
factories/
userFactory.ts
itemFactory.ts
seeds/
seedItems.ts
helpers/
waitHelpers.ts
apiHelpers.ts
utils/
envConfig.ts
logger.ts
playwright.config.ts
tsconfig.json
.eslintrc.json
.env.exampleLes règles clés qui font fonctionner ça : tests/ ne contient que des fichiers spec et aucune logique partagée. pages/ ne contient que des classes de page object. fixtures/ est la couche de liaison qui connecte tout. data/ possède toute la création et l'alimentation de données de test. helpers/ contient des fonctions réutilisables qui n'appartiennent pas à une page spécifique. utils/ contient l'infrastructure du framework, notamment la config, le logging et tout ce qui est global.
Les tests importent depuis fixtures/index.ts et nulle part ailleurs. Cette contrainte unique garde le graphe de dépendances propre.
La classe de base de page
Chaque page object dans une suite en croissance a besoin du même ensemble de capacités. Cela comprend la navigation, l'attente que la page atteigne un état connu, et une façon cohérente de gérer les patterns UI courants. Sans classe de base, ces patterns sont copiés-collés dans les pages et divergent avec le temps.
// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { envConfig } from '../utils/envConfig';
export abstract class BasePage {
protected readonly page: Page;
abstract readonly path: string;
constructor(page: Page) {
this.page = page;
}
async navigate(params?: Record<string, string>): Promise<void> {
const url = new URL(this.path, envConfig.baseURL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
await this.page.goto(url.toString());
await this.waitForPageLoad();
}
protected async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
}
async waitForVisible(locator: Locator, timeout = 10_000): Promise<void> {
await locator.waitFor({ state: 'visible', timeout });
}
async waitForHidden(locator: Locator, timeout = 10_000): Promise<void> {
await locator.waitFor({ state: 'hidden', timeout });
}
async assertHeading(text: string): Promise<void> {
await expect(this.page.getByRole('heading', { name: text })).toBeVisible();
}
async assertURL(expectedPath: string): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(expectedPath));
}
async dismissModal(): Promise<void> {
const overlay = this.page.locator('[data-testid="modal-overlay"]');
if (await overlay.isVisible()) {
await this.page.keyboard.press('Escape');
await this.waitForHidden(overlay);
}
}
}Les page objects étendent la base et ne définissent que ce qui est spécifique à cette page :
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly path = '/dashboard';
readonly addItemButton: Locator;
readonly itemsTable: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
super(page);
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.itemsTable = page.getByRole('table', { name: 'Travel items' });
this.searchInput = page.getByRole('searchbox');
}
protected override async waitForPageLoad(): Promise<void> {
await super.waitForPageLoad();
await this.itemsTable.waitFor({ state: 'visible' });
}
async getRowCount(): Promise<number> {
const rows = this.itemsTable.getByRole('row');
return (await rows.count()) - 1;
}
async searchFor(term: string): Promise<void> {
await this.searchInput.fill(term);
await this.page.waitForResponse('**/api/items?search=**');
}
}La surcharge de waitForPageLoad dans DashboardPage est le schéma qui élimine les tests instables à l'échelle. Chaque page définit sa propre condition "prête", et la navigation attend cette condition avant de retourner. Les tests n'ont jamais besoin d'ajouter des attentes manuelles.
La couche de fixtures
Les fixtures sont le système d'injection de dépendances du framework. Un fichier exporte tout ce dont les tests ont besoin : l'objet test étendu, tous les page objects, les helpers de données, et expect. Les fichiers de test importent depuis exactement un seul endroit.
// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
import { envConfig } from '../utils/envConfig';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto(envConfig.baseURL);
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill(envConfig.testUser.email);
await page.getByLabel('Password').fill(envConfig.testUser.password);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
await use(page);
},
});// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});// fixtures/index.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as pagesTest } from './pages.fixture';
import { test as dataTest } from './data.fixture';
export const test = mergeTests(authTest, pagesTest, dataTest);
export { expect } from '@playwright/test';mergeTests est le bon outil ici : il compose les fixtures de plusieurs fichiers sans perdre la sécurité des types. Chaque test du projet utilise désormais cet import unique.
import { test, expect } from '../../fixtures';Cet import unique donne à chaque test accès à authenticatedPage, dashboardPage, loginPage, et toutes les fixtures de données. Ajouter une nouvelle fixture signifie modifier un fichier dans fixtures/ et elle est immédiatement disponible partout.
mergeTests et des ré-exports. Dès que vous mettez de la logique de fixture directement dans index.ts, il devient difficile de localiser où une fixture spécifique est définie. Un fichier de fixture par domaine (auth, pages, données) garde les choses navigables.Gestion de la config à travers les environnements
Les URLs codées en dur sont la façon la plus rapide de rendre une suite de tests impossible à maintenir. La config spécifique à chaque environnement nécessite une seule source de vérité que le reste du framework lit.
// utils/envConfig.ts
import * as dotenv from 'dotenv';
import * as path from 'path';
const envFile = process.env.TEST_ENV
? `.env.${process.env.TEST_ENV}`
: '.env';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Variable d'environnement requise manquante : ${name}. ` +
`Avez-vous copié .env.example vers ${envFile} ?`
);
}
return value;
}
export const envConfig = {
baseURL: requireEnv('BASE_URL'),
apiBaseURL: requireEnv('API_BASE_URL'),
testUser: {
email: requireEnv('TEST_USER_EMAIL'),
password: requireEnv('TEST_USER_PASSWORD'),
},
apiToken: requireEnv('API_TOKEN'),
environment: (process.env.TEST_ENV ?? 'local') as 'local' | 'staging' | 'prod',
} as const;Trois fichiers .env se trouvent à la racine du projet et sont versionnés dans le dépôt (les secrets vont dans les variables CI, pas ici) :
# .env.example
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3001/api
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=replace_me
API_TOKEN=replace_me
# .env.staging
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://staging.myapp.com/api
TEST_USER_EMAIL=staging-test@myapp.com
TEST_USER_PASSWORD=
API_TOKEN=playwright.config.ts lit depuis envConfig plutôt que directement depuis process.env :
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { envConfig } from './utils/envConfig';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: envConfig.environment !== 'local',
retries: envConfig.environment === 'local' ? 0 : 2,
workers: envConfig.environment === 'local' ? undefined : 4,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['./utils/slackReporter.ts'],
],
use: {
baseURL: envConfig.baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Lancer contre le staging devient une variable d'environnement : TEST_ENV=staging npx playwright test.
Stratégie de données de test
Les tests qui dépendent de données créées par un test précédent sont les tests les plus fragiles de toute suite. Chaque test doit posséder ses données de la création au nettoyage. Trois schémas gèrent des scénarios différents : des factories pour les données dans le test, des builders pour les objets complexes, et l'alimentation via API pour les préconditions coûteuses.
Les factories génèrent des objets valides avec des valeurs par défaut sensées et laissent les tests surcharger uniquement ce qui compte pour le scénario spécifique :
// data/factories/itemFactory.ts
import { faker } from '@faker-js/faker';
export interface ItemData {
name: string;
category: 'Documents' | 'Electronics' | 'Clothing' | 'Other';
quantity: number;
notes?: string;
}
export function buildItem(overrides: Partial<ItemData> = {}): ItemData {
return {
name: faker.commerce.productName(),
category: 'Documents',
quantity: faker.number.int({ min: 1, max: 10 }),
...overrides,
};
}
export function buildItems(count: number, overrides: Partial<ItemData> = {}): ItemData[] {
return Array.from({ length: count }, () => buildItem(overrides));
}Pour les objets complexes avec de nombreuses dépendances, le schéma builder donne aux tests une API fluente :
// data/factories/userFactory.ts
import { faker } from '@faker-js/faker';
export interface UserData {
email: string;
password: string;
firstName: string;
lastName: string;
role: 'admin' | 'member' | 'viewer';
}
export class UserBuilder {
private data: UserData = {
email: faker.internet.email(),
password: 'TestPass123!',
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
role: 'member',
};
withRole(role: UserData['role']): this {
this.data.role = role;
return this;
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
asAdmin(): this {
this.data.role = 'admin';
return this;
}
build(): UserData {
return { ...this.data };
}
}L'alimentation via API gère le cas où la création par UI est trop lente ou crée un état peu fiable. La fixture de données connecte tout et gère le nettoyage :
// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildItem, ItemData } from '../data/factories/itemFactory';
import { envConfig } from '../utils/envConfig';
type DataFixtures = {
apiRequest: APIRequestContext;
seededItem: ItemData & { id: string };
};
export const test = base.extend<DataFixtures>({
apiRequest: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: envConfig.apiBaseURL,
extraHTTPHeaders: {
Authorization: `Bearer ${envConfig.apiToken}`,
'Content-Type': 'application/json',
},
});
await use(context);
await context.dispose();
},
seededItem: async ({ apiRequest }, use) => {
const itemData = buildItem();
const response = await apiRequest.post('/items', { data: itemData });
const created = await response.json() as ItemData & { id: string };
await use(created);
// Le nettoyage s'exécute que le test réussisse ou échoue
await apiRequest.delete(`/items/${created.id}`).catch(() => {
console.warn(`Nettoyage échoué pour l'item ${created.id} — suppression manuelle peut être nécessaire`);
});
},
});Le .catch() dans le teardown est intentionnel. Si le nettoyage lève une exception, le résultat du test ne doit pas être affecté. Loggez l'avertissement et continuez.
Reporters : HTML et notifications Slack
Le reporter HTML intégré suffit pour le développement local. Les pipelines CI ont besoin de quelque chose qui livre les résultats là où l'équipe regarde réellement. Dans la plupart des cas, Slack.
Un reporter personnalisé implémente l'interface Reporter de Playwright :
// utils/slackReporter.ts
import type {
Reporter,
FullConfig,
Suite,
TestCase,
TestResult,
FullResult,
} from '@playwright/test/reporter';
import * as https from 'https';
export default class SlackReporter implements Reporter {
private passed = 0;
private failed = 0;
private skipped = 0;
private failedTests: string[] = [];
private startTime = Date.now();
onBegin(_config: FullConfig, _suite: Suite): void {
this.startTime = Date.now();
}
onTestEnd(test: TestCase, result: TestResult): void {
if (result.status === 'passed') this.passed++;
else if (result.status === 'skipped') this.skipped++;
else {
this.failed++;
this.failedTests.push(test.titlePath().join(' > '));
}
}
async onEnd(result: FullResult): Promise<void> {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) return;
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
const total = this.passed + this.failed + this.skipped;
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${status} *Tests Playwright — ${process.env.TEST_ENV ?? 'local'}*\n${this.passed}/${total} passés en ${duration}s`,
},
},
];
if (this.failedTests.length > 0) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Tests échoués :*\n${this.failedTests.map(t => `• ${t}`).join('\n')}`,
},
});
}
const payload = JSON.stringify({ blocks });
await this.postToSlack(webhookUrl, payload);
}
private postToSlack(webhookUrl: string, payload: string): Promise<void> {
return new Promise((resolve, reject) => {
const url = new URL(webhookUrl);
const req = https.request(
{ hostname: url.hostname, path: url.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json' } },
() => resolve()
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
}Enregistrez le reporter dans playwright.config.ts :
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
envConfig.environment !== 'local' ? ['./utils/slackReporter.ts'] : ['list'],
],Le reporter Slack ne s'active que sur les environnements non-locaux. Pas de bruit pendant le développement local.
TypeScript en mode strict et linting
Le code de test est du code de production. Il s'exécute en CI, il affecte les décisions de publication, et les bugs dans les tests sont plus difficiles à détecter que les bugs dans le code applicatif. Rien ne teste les tests. TypeScript en mode strict et ESLint interceptent des catégories entières de problèmes avant qu'ils n'atteignent un membre de l'équipe.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@fixtures": ["./fixtures/index.ts"],
"@pages/*": ["./pages/*"],
"@data/*": ["./data/*"],
"@helpers/*": ["./helpers/*"],
"@utils/*": ["./utils/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}La configuration paths permet aux fichiers de test d'utiliser des imports propres :
import { test, expect } from '@fixtures';
import { buildItem } from '@data/factories/itemFactory';Pour ESLint, les règles clés pour la qualité des tests sont celles qui préviennent les erreurs courantes spécifiques à Playwright :
// .eslintrc.json
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "playwright"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:playwright/recommended"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/no-conditional-in-test": "warn",
"playwright/prefer-web-first-assertions": "error",
"playwright/no-networkidle": "warn"
}
}no-floating-promises est la règle la plus importante dans une suite de tests Playwright. Oublier un await avant un appel Playwright est une source courante de faux positifs. L'assertion s'exécute avant que l'action soit terminée, le test passe, et l'UI se retrouve dans un état inattendu pour l'étape suivante. TypeScript seul ne le détecte pas ; la règle de linting le fait.
Évoluer à côté d'une suite existante : le schéma strangler fig
Le schéma strangler fig décrit le remplacement progressif d'un ancien système en faisant pousser un nouveau autour de lui. Le trafic est dérouté graduellement de l'ancien vers le nouveau, jusqu'à ce que l'ancien ne soit plus touché et puisse être supprimé. La même approche s'applique aux frameworks de test.
Démarrer une "réécriture de framework" comme un effort parallèle échoue toujours. Le nouveau framework vit dans une branche séparée, l'ancienne suite continue de changer, la branche ne fusionne jamais. L'approche strangler fig garde l'équipe en train d'écrire des tests dans l'ancienne structure pendant que la nouvelle l'absorbe progressivement.
Les étapes pratiques :
Étape 1 : Créez la nouvelle structure de dossiers à côté des tests existants. Ne déplacez rien pour l'instant.tests/ ← structure plate existante, intacte
framework/ ← nouvelle structure, commence vide
tests/
pages/
fixtures/
...
playwright.config.ts ← mis à jour pour exécuter les deuxMettez à jour playwright.config.ts pour inclure les deux répertoires de tests :
export default defineConfig({
projects: [
{
name: 'legacy',
testDir: './tests',
use: { baseURL: envConfig.baseURL },
},
{
name: 'framework',
testDir: './framework/tests',
use: { baseURL: envConfig.baseURL },
},
],
});// framework/tests/items/items-search.spec.ts
// Migré depuis tests/items-search.spec.ts
// Migration : extraction de LoginPage, connexion aux fixtures, suppression de l'URL codée en dur
import { test, expect } from '../../fixtures';
test('la recherche filtre les items par nom', async ({ authenticatedPage, dashboardPage }) => {
await dashboardPage.navigate();
await dashboardPage.searchFor('Passeport');
await expect(dashboardPage.itemsTable.getByRole('row')).toHaveCount(2); // en-tête + 1 résultat
});tests/ gagne de nouveaux fichiers :
// package.json
"scripts": {
"check:no-new-legacy-tests": "node scripts/checkLegacyTests.js",
"test": "playwright test",
"test:framework": "playwright test --project=framework",
"test:legacy": "playwright test --project=legacy",
"lint": "eslint . --ext .ts",
"typecheck": "tsc --noEmit"
}Le script de vérification lit le diff git et échoue si de nouveaux fichiers .spec.ts apparaissent dans tests/. Les équipes arrêtent d'ajouter à l'ancienne structure non pas à cause d'une règle, mais parce que la nouvelle est clairement meilleure. La vérification fournit un filet de sécurité pour ceux qui n'ont pas encore remarqué le schéma.
Après quelques mois, le répertoire legacy ne contient plus que des vieux tests que personne n'a touchés. Un sprint de migration dédié convertit le reste, et le répertoire legacy est supprimé. La migration s'est faite progressivement, l'équipe a livré des fonctionnalités tout le temps, et le framework est en production dès le premier jour.
FAQ
Combien de pages un page object devrait-il couvrir ?Une page par classe, une modale par classe. Si une page a deux sections complètement séparées (une barre latérale et un panneau principal avec des responsabilités différentes), divisez-les en deux classes et composez-les dans la fixture. Une classe qui couvre deux pages indique que la frontière a été tracée au mauvais endroit.
Les fixtures doivent-elles jamais contenir des assertions ?Non. Les fixtures configurent et suppriment l'état. Une assertion dans une fixture rend impossible de savoir si un échec de test vient de la logique du test ou de la configuration. Si vous devez vérifier que la configuration s'est terminée avec succès, utilisez waitFor de Playwright avec une condition plutôt qu'une assertion. Les assertions appartiennent exclusivement aux fichiers de test.
Créez des fixtures d'auth séparées, une par rôle : adminPage, memberPage, viewerPage. Chaque fixture se connecte en tant qu'utilisateur différent et passe la page authentifiée au test. Si le nombre de rôles augmente, envisagez un schéma factory où authenticatedAs('admin') retourne la bonne fixture selon un paramètre.
Commencez avec workers: '50%' dans playwright.config.ts (la moitié des cœurs CPU disponibles). Surveillez l'utilisation des ressources de votre runner CI sur plusieurs exécutions. Si les tests deviennent instables à cause de la contention des ressources, réduisez les workers. Si le runner a de la marge, augmentez-les. Le bon nombre dépend de la spec du runner et de l'intensité en ressources de chaque test, pas d'une formule universelle.
test.describe plutôt que des fichiers spec séparés ?
Des fichiers spec séparés pour des fonctionnalités séparées. test.describe pour des regroupements logiques au sein d'une fonctionnalité, comme le chemin nominal versus les cas limites, ou les opérations de lecture versus les opérations d'écriture. La règle générale : si deux groupes de tests nécessitent une configuration test.use() différente (surcharges de fixtures différentes), ils appartiennent à des blocs describe séparés ou à des fichiers séparés. S'ils utilisent la même configuration, le regroupement est un choix de style.