Les schémas POM avancés répondent aux problèmes d'échelle qui apparaissent quand la structure de base ne tient plus. Les composants de navigation se dupliquent dans chaque page object, des modales peuvent surgir depuis plusieurs pages. Les flux multi-étapes s'étendent sur plusieurs classes, et les locators deviennent obsolètes quand la structure de la page change souvent.
Le problème du POM naïf
Le POM de base fonctionne pour les pages simples. Il s'effondre quand :
- La barre de navigation apparaît sur chaque page : la dupliquez-vous dans chaque page object ?
- Une modale peut apparaître depuis plusieurs pages : où la placez-vous ?
- Un formulaire multi-étapes complexe s'étend sur plusieurs pages : comment les chaînez-vous ?
- Les locators deviennent obsolètes parce que la structure de la page change souvent
Les schémas POM avancés résolvent ces problèmes.
Objets composants : les éléments UI réutilisables
Extrayez les éléments UI partagés dans leurs propres classes :
// components/NavigationBar.ts
import { Page, Locator } from '@playwright/test';
export class NavigationBar {
readonly page: Page;
readonly userMenu: Locator;
readonly notificationBell: Locator;
readonly searchBar: Locator;
readonly logo: Locator;
constructor(page: Page) {
this.page = page;
this.userMenu = page.getByTestId('user-menu');
this.notificationBell = page.getByTestId('notification-bell');
this.searchBar = page.getByTestId('nav-search');
this.logo = page.getByTestId('logo');
}
async logout() {
await this.userMenu.click();
await this.page.getByTestId('logout-option').click();
}
async goToProfile() {
await this.userMenu.click();
await this.page.getByTestId('profile-option').click();
}
async search(query: string) {
await this.searchBar.fill(query);
await this.searchBar.press('Enter');
}
}// components/ConfirmationModal.ts
import { Page, Locator } from '@playwright/test';
export class ConfirmationModal {
readonly page: Page;
readonly modal: Locator;
readonly title: Locator;
readonly message: Locator;
readonly confirmButton: Locator;
readonly cancelButton: Locator;
constructor(page: Page) {
this.page = page;
this.modal = page.getByTestId('confirmation-modal');
this.title = page.getByTestId('modal-title');
this.message = page.getByTestId('modal-message');
this.confirmButton = page.getByTestId('modal-confirm');
this.cancelButton = page.getByTestId('modal-cancel');
}
async confirm() {
await this.confirmButton.click();
await this.modal.waitFor({ state: 'hidden' });
}
async cancel() {
await this.cancelButton.click();
await this.modal.waitFor({ state: 'hidden' });
}
async waitForOpen() {
await this.modal.waitFor({ state: 'visible' });
}
}Page de base par composition
Au lieu de l'héritage partout, composez les pages à partir de composants :
// pages/BasePage.ts
import { Page } from '@playwright/test';
import { NavigationBar } from '../components/NavigationBar';
import { ConfirmationModal } from '../components/ConfirmationModal';
export abstract class BasePage {
readonly page: Page;
readonly nav: NavigationBar;
readonly confirmModal: ConfirmationModal;
constructor(page: Page) {
this.page = page;
this.nav = new NavigationBar(page);
this.confirmModal = new ConfirmationModal(page);
}
abstract navigate(): Promise<void>;
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
async getTitle() {
return this.page.title();
}
}// pages/UserManagementPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class UserManagementPage extends BasePage {
readonly userRows: Locator;
readonly addUserButton: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
super(page); // Configure nav et confirmModal
this.userRows = page.getByTestId('user-row');
this.addUserButton = page.getByTestId('add-user');
this.searchInput = page.getByTestId('user-search');
}
async navigate() {
await this.page.goto('/admin/users');
await this.waitForPageLoad();
}
async deleteUser(userId: number) {
await this.page.getByTestId(`delete-user-${userId}`).click();
await this.confirmModal.waitForOpen();
await this.confirmModal.confirm();
}
}// Dans les tests
test('l\'admin peut supprimer un utilisateur', async ({ page }) => {
const usersPage = new UserManagementPage(page);
await usersPage.navigate();
const initialCount = await usersPage.userRows.count();
await usersPage.deleteUser(123);
await expect(usersPage.userRows).toHaveCount(initialCount - 1);
// La nav est aussi disponible :
await usersPage.nav.logout();
});Lignes et tableaux dynamiques
Gérez les listes et tableaux avec du contenu dynamique :
// components/DataTable.ts
import { Page, Locator } from '@playwright/test';
export class DataTable {
readonly rows: Locator;
readonly headers: Locator;
readonly sortButtons: Locator;
readonly pagination: Locator;
constructor(page: Page, tableTestId: string) {
const table = page.getByTestId(tableTestId);
this.rows = table.getByTestId('table-row');
this.headers = table.getByTestId('table-header');
this.sortButtons = table.getByTestId('sort-button');
this.pagination = table.getByTestId('pagination');
}
getRow(index: number) {
return this.rows.nth(index);
}
async getRowByText(text: string) {
return this.rows.filter({ hasText: text });
}
async getCellValue(row: number, column: number) {
return this.rows.nth(row)
.getByTestId('table-cell')
.nth(column)
.textContent();
}
async sortBy(columnName: string) {
await this.sortButtons.filter({ hasText: columnName }).click();
}
async getCount() {
return this.rows.count();
}
}// Dans un page object
class OrdersPage extends BasePage {
readonly ordersTable: DataTable;
constructor(page: Page) {
super(page);
this.ordersTable = new DataTable(page, 'orders-table');
}
}
// Dans les tests
test('commandes triées par date', async ({ page }) => {
const ordersPage = new OrdersPage(page);
await ordersPage.navigate();
await ordersPage.ordersTable.sortBy('Date');
const firstDate = await ordersPage.ordersTable.getCellValue(0, 2);
const secondDate = await ordersPage.ordersTable.getCellValue(1, 2);
expect(new Date(firstDate!)).toBeGreaterThan(new Date(secondDate!));
});Flux multi-étapes : chaînes de pages
Pour les flux multi-étapes comme le checkout, retournez la page suivante depuis chaque étape :
// pages/checkout/CartPage.ts
import { Page } from '@playwright/test';
import { CheckoutPage } from './CheckoutPage';
export class CartPage extends BasePage {
constructor(page: Page) {
super(page);
}
async navigate() {
await this.page.goto('/cart');
}
async proceedToCheckout(): Promise<CheckoutPage> {
await this.page.getByTestId('proceed-checkout').click();
await this.page.waitForURL('/checkout/shipping');
return new CheckoutPage(this.page);
}
}// pages/checkout/CheckoutPage.ts
import { Page } from '@playwright/test';
import { PaymentPage } from './PaymentPage';
export class CheckoutPage extends BasePage {
readonly firstName: Locator;
readonly lastName: Locator;
readonly address: Locator;
readonly city: Locator;
constructor(page: Page) {
super(page);
this.firstName = page.getByTestId('first-name');
this.lastName = page.getByTestId('last-name');
this.address = page.getByTestId('address');
this.city = page.getByTestId('city');
}
async fillShipping(details: ShippingDetails): Promise<void> {
await this.firstName.fill(details.firstName);
await this.lastName.fill(details.lastName);
await this.address.fill(details.address);
await this.city.fill(details.city);
}
async continueToPayment(): Promise<PaymentPage> {
await this.page.getByTestId('continue-payment').click();
await this.page.waitForURL('/checkout/payment');
return new PaymentPage(this.page);
}
}// Test utilisant la chaîne
test('flux de checkout complet', async ({ page }) => {
const cart = new CartPage(page);
await cart.navigate();
const checkout = await cart.proceedToCheckout();
await checkout.fillShipping({
firstName: 'Jean', lastName: 'Dupont',
address: '123 rue Principale', city: 'Paris',
});
const payment = await checkout.continueToPayment();
await payment.fillCard({ number: '4242424242424242', expiry: '12/28', cvc: '123' });
const confirmation = await payment.submitOrder();
await expect(confirmation.orderNumber).toBeVisible();
});Le schéma Builder pour la configuration complexe
Quand vous créez des données de test complexes ou remplissez des formulaires complexes :
class UserFormBuilder {
private data: Partial<UserFormData> = {};
withName(firstName: string, lastName: string) {
this.data.firstName = firstName;
this.data.lastName = lastName;
return this; // Interface fluente — retourne this pour le chaînage
}
withEmail(email: string) {
this.data.email = email;
return this;
}
withRole(role: 'admin' | 'member') {
this.data.role = role;
return this;
}
asAdmin() {
this.data.role = 'admin';
this.data.permissions = ['manage-users', 'view-reports'];
return this;
}
build(): UserFormData {
return {
firstName: this.data.firstName ?? 'Test',
lastName: this.data.lastName ?? 'Utilisateur',
email: this.data.email ?? `test_${Date.now()}@example.com`,
role: this.data.role ?? 'member',
permissions: this.data.permissions ?? [],
};
}
}
// Dans les tests — très lisible
const adminUser = new UserFormBuilder()
.withName('Alice', 'Martin')
.withEmail('alice@test.com')
.asAdmin()
.build();
const regularUser = new UserFormBuilder()
.withEmail('bob@test.com')
.build(); // Utilise les valeurs par défaut pour tout le resteInitialisation paresseuse des locators
Calculez les locators une seule fois, au premier accès :
class ProductPage extends BasePage {
private _productCards?: Locator;
private _filterPanel?: Locator;
get productCards(): Locator {
if (!this._productCards) {
this._productCards = this.page.getByTestId('product-card');
}
return this._productCards;
}
get filterPanel(): Locator {
if (!this._filterPanel) {
this._filterPanel = this.page.getByTestId('filter-panel');
}
return this._filterPanel;
}
}Récapitulatif
| Schéma | Problème résolu |
|--------|----------------|
| Objets composants | UI partagée (nav, modales) sans duplication |
| Composition dans BasePage | Fonctionnalité commune sans chaînes d'héritage profondes |
| Classe DataTable | Listes et tableaux dynamiques avec opérations réutilisables |
| Chaîne de pages | Flux multi-étapes avec transitions typées |
| Schéma Builder | Données de test et remplissage de formulaires complexes |
| Locators paresseux | Performance : calculer uniquement quand nécessaire |
Le principe fondamental : les page objects doivent refléter la façon dont les utilisateurs pensent à l'application, pas comment le DOM est structuré. Quand vos page objects se lisent comme des scénarios utilisateur ("passer à la caisse", "renseigner les coordonnées de livraison", "valider la commande"), ils sont au bon niveau d'abstraction.
→ See also: Page Object Model dans Playwright: Du Chaos à la Maintenabilité | Patterns Avancés de Page Object dans Playwright | Fixtures Personnalisés dans Playwright: Le Modèle qui Rend les Tests Lisibles | Interfaces et Types TypeScript pour le Page Object Model