Test E2E du dramaturge : objets de page et meilleures pratiques
Les tests unitaires vérifient que les fonctions individuelles se comportent correctement. Les tests d'intégration vérifient que les services fonctionnent ensemble. Mais ni l'un ni l'autre ne détecte ce qui se brise lorsqu'un utilisateur clique réellement sur votre application : flux de navigation interrompus, erreurs d'hydratation JavaScript, échecs de rendu dépendants du réseau et bugs de rendu entre navigateurs. Les tests de bout en bout avec Playwright comblent cette lacune et sont bien plus fiables que leurs prédécesseurs Cypress ou Selenium.
Playwright exécute en parallèle de vrais navigateurs Chromium, Firefox et WebKit. Il attend automatiquement que les éléments soient visibles et stables avant d'interagir. Il capture des captures d'écran, des vidéos et des traces réseau en cas de panne. Et son API TypeScript-first fait de l'écriture de tests maintenables un plaisir. Ce guide couvre tout, de la configuration aux objets de page en passant par l'intégration CI pour les applications Next.js.
Points clés à retenir
- Utilisez exclusivement les sélecteurs
data-testid— Les sélecteurs CSS et les sélecteurs de texte s'interrompent lors des modifications de l'interface utilisateur- Les sélecteurs abstraits du modèle d'objet de page (POM) dans des classes réutilisables - un emplacement de changement par élément de l'interface utilisateur
- Utilisez toujours
await page.goto()+await expect(page).toHaveURL()— ne supposez jamais que la navigation se termine de manière synchrone- Utilisez les indicateurs
test.describe.parallel()et--workerspour des exécutions CI plus rapides- Enregistrez les tests avec
playwright codegenmais refactorisez toujours les objets de page avant de valider- Capturer des captures d'écran en cas d'échec via
screenshot: 'only-on-failure'dans la configuration- Testez l'accessibilité ainsi que le comportement avec
@axe-core/playwright- Réponses API simulées pour les appels tiers instables ; utiliser une véritable API pour les chemins critiques et heureux
##Configuration
// apps/web/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'playwright-report/results.json' }],
...(process.env.CI ? [['github'] as ['github']] : []),
],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Tester la structure du répertoire
tests/
public/ # Tests for unauthenticated public pages
homepage.spec.ts
blog.spec.ts
services.spec.ts
sitemap.spec.ts
i18n.spec.ts
seo.spec.ts
admin/ # Tests for authenticated admin pages
contacts.spec.ts
orders.spec.ts
dashboard.spec.ts
fixtures/
auth.fixture.ts # Authentication fixtures
data.fixture.ts # Test data factories
pages/ # Page Object Model classes
HomePage.ts
BlogPage.ts
ContactsPage.ts
LoginPage.ts
helpers/
api.helper.ts # API call helpers for test setup
Modèle d'objet de page
Le modèle d'objet de page est le modèle le plus important pour les tests E2E maintenables. Lorsqu'un sélecteur change, vous mettez à jour un emplacement, pas tous les tests qui l'utilisent.
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
// Always use data-testid selectors
this.emailInput = page.getByTestId('login-email');
this.passwordInput = page.getByTestId('login-password');
this.submitButton = page.getByTestId('login-submit');
this.errorMessage = page.getByTestId('login-error');
this.forgotPasswordLink = page.getByTestId('forgot-password-link');
}
async goto() {
await this.page.goto('/auth/login');
await expect(this.page).toHaveURL('/auth/login');
await expect(this.submitButton).toBeVisible();
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async loginAndWaitForRedirect(email: string, password: string, expectedUrl: string) {
await this.login(email, password);
await expect(this.page).toHaveURL(expectedUrl, { timeout: 10_000 });
}
async expectError(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
}
// tests/pages/ContactsPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class ContactsPage {
readonly page: Page;
readonly addButton: Locator;
readonly searchInput: Locator;
readonly table: Locator;
readonly dialog: Locator;
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly saveButton: Locator;
readonly successToast: Locator;
constructor(page: Page) {
this.page = page;
this.addButton = page.getByTestId('contacts-add-button');
this.searchInput = page.getByTestId('contacts-search');
this.table = page.getByTestId('contacts-table');
this.dialog = page.getByTestId('contact-dialog');
this.nameInput = page.getByTestId('contact-name-input');
this.emailInput = page.getByTestId('contact-email-input');
this.saveButton = page.getByTestId('contact-save-button');
this.successToast = page.getByTestId('toast-success');
}
async goto() {
await this.page.goto('/dashboard/contacts');
await expect(this.table).toBeVisible();
}
async openAddDialog() {
await this.addButton.click();
await expect(this.dialog).toBeVisible();
}
async createContact(name: string, email: string) {
await this.openAddDialog();
await this.nameInput.fill(name);
await this.emailInput.fill(email);
await this.saveButton.click();
await expect(this.successToast).toBeVisible();
}
async searchFor(query: string) {
await this.searchInput.fill(query);
await this.page.waitForTimeout(500); // Debounce delay
}
async getRowCount() {
return this.table.getByRole('row').count();
}
async expectContactInTable(name: string) {
await expect(this.table).toContainText(name);
}
}
Appareils d'authentification
// tests/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
type AuthFixture = {
adminPage: Page;
userPage: Page;
};
export const test = base.extend<AuthFixture>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
// Login via API to bypass UI (faster, more reliable)
const response = await page.request.post('/auth/login', {
data: {
email: process.env.TEST_ADMIN_EMAIL || '[email protected]',
password: process.env.TEST_ADMIN_PASSWORD || 'testpassword123',
},
});
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
// Cookies are set automatically on the context
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.request.post('/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL || '[email protected]',
password: process.env.TEST_USER_PASSWORD || 'testpassword123',
},
});
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';
Tests d'écriture
// tests/admin/contacts.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { ContactsPage } from '../pages/ContactsPage';
test.describe('Contacts Management', () => {
test('admin can create, search, and delete a contact', async ({ adminPage }) => {
const contactsPage = new ContactsPage(adminPage);
const testName = `Test User ${Date.now()}`;
const testEmail = `test-${Date.now()}@example.com`;
await contactsPage.goto();
// Create
await contactsPage.createContact(testName, testEmail);
await contactsPage.expectContactInTable(testName);
// Search
await contactsPage.searchFor(testName);
const rowCount = await contactsPage.getRowCount();
expect(rowCount).toBe(2); // 1 header + 1 result
// Delete
await adminPage.getByTestId(`contact-delete-${testEmail}`).click();
await adminPage.getByTestId('confirm-delete').click();
await expect(contactsPage.successToast).toBeVisible();
await expect(contactsPage.table).not.toContainText(testName);
});
test('shows validation errors for invalid contact data', async ({ adminPage }) => {
const contactsPage = new ContactsPage(adminPage);
await contactsPage.goto();
await contactsPage.openAddDialog();
// Submit without filling required fields
await contactsPage.saveButton.click();
await expect(adminPage.getByTestId('error-name')).toContainText('required');
await expect(adminPage.getByTestId('error-email')).toContainText('required');
});
});
Tests de pages publiques
// tests/public/homepage.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test('renders hero section with correct heading', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/ECOSIRE/);
const hero = page.getByTestId('hero-heading');
await expect(hero).toBeVisible();
await expect(hero).not.toBeEmpty();
});
test('navigation links are all accessible', async ({ page }) => {
await page.goto('/');
const navLinks = page.getByRole('navigation').getByRole('link');
const count = await navLinks.count();
for (let i = 0; i < count; i++) {
const link = navLinks.nth(i);
const href = await link.getAttribute('href');
// All nav links should have valid hrefs
expect(href).toBeTruthy();
expect(href).not.toBe('#');
}
});
test('CTA button links to services page', async ({ page }) => {
await page.goto('/');
await page.getByTestId('hero-cta').click();
await expect(page).toHaveURL(/\/services/);
});
});
Tests i18n
// tests/public/i18n.spec.ts
import { test, expect } from '@playwright/test';
const testLocales = ['zh', 'es', 'ar', 'fr'];
test.describe('Internationalization', () => {
for (const locale of testLocales) {
test(`/${locale}/ renders with correct lang attribute`, async ({ page }) => {
await page.goto(`/${locale}/`);
const html = page.locator('html');
await expect(html).toHaveAttribute('lang', locale);
});
}
test('Arabic locale sets dir="rtl"', async ({ page }) => {
await page.goto('/ar/');
const html = page.locator('html');
await expect(html).toHaveAttribute('dir', 'rtl');
});
test('language switcher changes locale', async ({ page }) => {
await page.goto('/');
await page.getByTestId('language-switcher').click();
await page.getByTestId('locale-option-fr').click();
await expect(page).toHaveURL('/fr/');
await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
});
test('hreflang tags are present on all pages', async ({ page }) => {
await page.goto('/');
const hreflangs = await page.locator('link[rel="alternate"][hreflang]').all();
// Should have 11 locale hreflangs + x-default
expect(hreflangs.length).toBeGreaterThanOrEqual(12);
});
});
Tests d'accessibilité
// tests/public/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
const pages = ['/', '/blog', '/services', '/about', '/contact'];
for (const path of pages) {
test(`${path} has no WCAG 2.1 AA violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
if (results.violations.length > 0) {
const report = results.violations.map((v) =>
`${v.id}: ${v.description} — ${v.nodes.length} element(s)`
).join('\n');
expect.fail(`Accessibility violations on ${path}:\n${report}`);
}
});
}
test('skip link is the first focusable element', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toHaveAttribute('href', '#main-content');
});
});
Intégration CI
# .github/workflows/ci.yml (E2E section)
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, build]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: cd apps/web && npx playwright install --with-deps chromium firefox
- name: Run E2E tests
run: cd apps/web && npx playwright test --project=chromium --project=firefox
env:
PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}
TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 7
Questions fréquemment posées
Quand dois-je utiliser les rôles data-testid, ARIA et les sélecteurs de texte ?
Préférez data-testid comme sélecteur principal - il survit aux refontes de l'interface utilisateur, aux modifications de copie et aux refactorisations. Utilisez les sélecteurs de rôles ARIA (getByRole('button', { name: 'Submit' })) pour tester l'accessibilité simultanément. Évitez les sélecteurs de contenu de texte (getByText('Submit')) car ils se cassent lorsque la copie est modifiée. N'utilisez jamais de sélecteurs de classe CSS : ce sont des détails d'implémentation qui changent avec le travail de style.
Combien de projets (navigateurs) dois-je exécuter dans CI ?
Pour les demandes d'extraction, exécutez Chromium uniquement pour maintenir CI rapide. Pour les fusions de branches principales, exécutez les trois navigateurs de bureau (Chromium, Firefox, WebKit). Pour les versions candidates, ajoutez des fenêtres mobiles. WebKit détecte les bugs de mise en page spécifiques à Safari ; Firefox détecte les bizarreries que Chrome et Safari cachent tous deux. La hiérarchisation permet d'économiser des minutes CI tout en détectant les bogues entre navigateurs avant la publication.
Comment gérer les tests irréguliers causés par des problèmes de timing ?
L'attente automatique de Playwright devrait gérer la plupart des problèmes de timing : utilisez await expect(locator).toBeVisible() plutôt que page.waitForTimeout(). Si l'attente automatique ne suffit pas, utilisez page.waitForResponse() pour l'état dépendant du réseau, page.waitForLoadState('networkidle') pour les pages lourdes ou page.waitForFunction() pour les conditions personnalisées. Définissez retries: 2 dans CI pour distinguer les véritables pannes de la fragilité de l'infrastructure.
Les tests E2E doivent-ils utiliser la base de données réelle ou une base de données de test prédéfinie ?
Utilisez un environnement de test dédié avec une base de données de tests prédéfinie pour les tests E2E. N’exécutez jamais de tests E2E en production. Pour CI, lancez une pile Docker Compose avec une nouvelle base de données contenant des données de test. Créez et nettoyez les données de test via des appels d'API dans les hooks beforeAll/afterAll plutôt que de vous fier à des données pré-ensemencées qui peuvent dériver.
Comment tester les téléchargements de fichiers dans Playwright ?
Utilisez le modèle page.waitForDownload() : const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. Vous pouvez également vérifier download.suggestedFilename() et lire le contenu du fichier pour validation.
Prochaines étapes
Les tests Playwright E2E constituent le dernier filet de sécurité avant que les utilisateurs ne voient votre application. Investis correctement – avec des objets de page, des sélecteurs de données-testid, des appareils d'authentification et l'intégration CI – ils détectent ce que les tests unitaires manquent et vous donnent la confiance nécessaire pour déployer le vendredi.
ECOSIRE gère 29 fichiers de test Playwright E2E couvrant plus de 416 cas de test sur les pages publiques, les flux de travail d'administration et l'accessibilité pour les 11 paramètres régionaux. Découvrez nos services d'ingénierie front-end pour découvrir comment nous construisons des applications Next.js de qualité.
Rédigé par
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Articles connexes
AI-Powered Accounting Automation: What Works in 2026
Discover which AI accounting automation tools deliver real ROI in 2026, from bank reconciliation to predictive cash flow, with implementation strategies.
Payroll Processing: Setup, Compliance, and Automation
Complete payroll processing guide covering employee classification, federal and state withholding, payroll taxes, garnishments, automation platforms, and year-end W-2 compliance.
AI Agents for Business Automation: The 2026 Landscape
Explore how AI agents are transforming business automation in 2026, from multi-agent orchestration to practical deployment strategies for enterprise teams.