Le Page Object Model de base (une classe par page, des sélecteurs en propriétés, des actions en méthodes) est un bon point de départ. Mais à mesure que les projets grossissent, ce modèle montre ses limites. Le code se duplique, le comportement partagé n'est pas factorisé, les composants de page partiels sont difficiles à tester, et la propriété des flux complexes devient floue.
Ces schémas résolvent les problèmes de passage à l'échelle les plus courants.
Classe de base
Chaque page object de votre suite partage des comportements communs : naviguer vers la page, attendre qu'elle se charge, vérifier le titre. Extrayez tout ça dans une BasePage :
// pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
protected page: Page;
protected readonly url: string;
constructor(page: Page, url: string) {
this.page = page;
this.url = url;
}
async navigate() {
await this.page.goto(this.url);
await this.waitForLoad();
}
// Redéfinir dans les sous-classes pour attendre les indicateurs de chargement spécifiques
protected async waitForLoad() {
await this.page.waitForLoadState('domcontentloaded');
}
async getTitle() {
return this.page.title();
}
async getURL() {
return this.page.url();
}
}Page concrète :
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';
export class LoginPage extends BasePage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
super(page, '/login');
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByRole('alert');
}
protected async waitForLoad() {
await this.emailInput.waitFor({ state: 'visible' });
}
async loginWith(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
return new DashboardPage(this.page);
}
async getErrorMessage() {
return this.errorMessage.textContent();
}
}Le type de retour DashboardPage rend le flux explicite : loginWith() retourne la page sur laquelle on atterrit.
Objets composants
Les grandes pages ont des sections qui se répètent : un tableau de données, un widget de notification, un panneau de filtres qui apparaît sur plusieurs pages. Extrayez-les dans des objets composants :
// components/DataTable.ts
import { Page, Locator } from '@playwright/test';
export class DataTable {
private readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
get rows() {
return this.container.getByRole('row');
}
async getRowCount() {
return this.rows.count();
}
async getRowByText(text: string) {
return this.rows.filter({ hasText: text });
}
async clickAction(rowText: string, actionName: string) {
const row = this.rows.filter({ hasText: rowText });
await row.getByRole('button', { name: actionName }).click();
}
async sortBy(columnName: string) {
await this.container
.getByRole('columnheader', { name: columnName })
.click();
}
}Utilisation dans les page objects :
// pages/OrdersPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';
import { DataTable } from '../components/DataTable';
export class OrdersPage extends BasePage {
readonly ordersTable: DataTable;
constructor(page: Page) {
super(page, '/orders');
this.ordersTable = new DataTable(
page.getByTestId('orders-table')
);
}
}Test :
test('le tableau des commandes affiche les bonnes données', async ({ page }) => {
const ordersPage = new OrdersPage(page);
await ordersPage.navigate();
expect(await ordersPage.ordersTable.getRowCount()).toBeGreaterThan(0);
await ordersPage.ordersTable.clickAction('Commande #123', 'Voir les détails');
});Le même composant DataTable peut être réutilisé dans ProductsPage, UsersPage, ou partout ailleurs où un tableau apparaît.
Factories de page objects
Quand vous avez beaucoup de page objects et que les tests doivent naviguer entre eux, une factory évite l'instanciation répétitive :
// pages/PageFactory.ts
import { Page } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { DashboardPage } from './DashboardPage';
import { OrdersPage } from './OrdersPage';
import { ProfilePage } from './ProfilePage';
export class PageFactory {
constructor(private page: Page) {}
get login() { return new LoginPage(this.page); }
get dashboard() { return new DashboardPage(this.page); }
get orders() { return new OrdersPage(this.page); }
get profile() { return new ProfilePage(this.page); }
}Avec une fixture :
// fixtures/pages.ts
import { test as base } from '@playwright/test';
import { PageFactory } from '../pages/PageFactory';
export const test = base.extend<{ pages: PageFactory }>({
pages: async ({ page }, use) => {
await use(new PageFactory(page));
},
});Test :
import { test } from '../fixtures/pages';
import { expect } from '@playwright/test';
test('l\'utilisateur peut voir ses commandes', async ({ pages }) => {
await pages.login.navigate();
const dashboard = await pages.login.loginWith('user@example.com', 'password');
await pages.orders.navigate();
expect(await pages.orders.ordersTable.getRowCount()).toBeGreaterThan(0);
});API fluente (chaînage de méthodes)
Pour la lisibilité des tests, les méthodes peuvent retourner this pour permettre le chaînage :
// pages/CheckoutPage.ts
export class CheckoutPage extends BasePage {
async fillShipping(address: ShippingAddress) {
await this.page.getByLabel('Rue').fill(address.street);
await this.page.getByLabel('Ville').fill(address.city);
await this.page.getByLabel('Code postal').fill(address.postalCode);
return this;
}
async selectPaymentMethod(method: 'card' | 'paypal') {
await this.page.getByRole('radio', { name: method }).click();
return this;
}
async submit() {
await this.page.getByRole('button', { name: 'Passer la commande' }).click();
return new ConfirmationPage(this.page);
}
}Test :
const confirmation = await checkoutPage
.fillShipping({ street: '123 rue Principale', city: 'Paris', postalCode: '75001' })
.then(p => p.selectPaymentMethod('card'))
.then(p => p.submit());
await expect(confirmation.heading).toHaveText('Commande confirmée !');Quand le POM de base suffit
Tous les projets n'ont pas besoin d'objets composants, de factories et de chaînages fluents. Le POM de base (une classe par page, locators et méthodes) est le bon niveau pour :
Les projets avec 20 à 50 tests, les équipes qui découvrent le POM et apprennent encore le schéma, et les applications avec une navigation simple et plate.
N'ajoutez de la complexité que quand le schéma de base cause de vrais problèmes. Du code copié-collé qui casse à plusieurs endroits, des sélecteurs difficiles à retrouver, des tests difficiles à lire parce que la relation entre pages n'est pas claire.
L'objectif reste toujours des tests lisibles et maintenables, pas la sophistication architecturale.
→ See also: Page Object Model dans Playwright: Du Chaos à la Maintenabilité | Interfaces et Types TypeScript pour le Page Object Model | Fixtures Personnalisés dans Playwright: Le Modèle qui Rend les Tests Lisibles