Le Page Object Model (POM) est un design pattern qui encapsule les locators et les interactions de chaque page dans une classe dédiée. Quand l'interface change, vous mettez à jour un seul fichier au lieu de trente tests.
Ce que le Page Object Model résout vraiment
Le POM est un design pattern, pas une fonctionnalité de Playwright. Le principe : créer une classe qui représente une page (ou une section de page). La classe contient les locators et les méthodes d'interaction avec cette page. Les tests utilisent la classe plutôt que les commandes Playwright brutes.
Sans POM, vos tests ressemblent à ceci :
test('l\'utilisateur peut se connecter', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});
test('la connexion échoue avec un mauvais mot de passe', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});Avec le POM :
test('l\'utilisateur peut se connecter', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();
});
test('la connexion échoue avec un mauvais mot de passe', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'wrongpassword');
await expect(page.getByText('Invalid credentials')).toBeVisible();
});Quand le bouton passe de "Submit" à "Sign In", vous corrigez LoginPage.ts. Les deux tests restent verts sans y toucher.
Construire votre premier Page Object
Créez un dossier pages/ à la racine du projet. Dedans, LoginPage.ts :
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly loginButton: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.loginButton = page.getByRole('button', { name: 'Login' });
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
}
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Deux choses à noter. Les locators sont définis dans le constructeur comme propriétés de classe : c'est l'approche recommandée par Playwright. Les locators sont paresseux, ils ne cherchent pas dans le DOM au moment où on les définit, seulement quand on les utilise. Les méthodes représentent des actions utilisateur (goto, login), pas des clics individuels. Les tests appellent des actions, pas des détails d'implémentation.
Ajouter d'autres pages
Ajoutez une DashboardPage.ts pour la page après connexion :
import { Page, Locator } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly heading: Locator;
readonly itemsTable: Locator;
readonly addItemButton: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { name: 'My Travel Items' });
this.itemsTable = page.getByRole('table');
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
}
async isLoaded() {
await this.heading.waitFor({ state: 'visible' });
}
async getRowCount() {
const rows = this.page.getByRole('row');
return await rows.count() - 1; // soustraction de la ligne d'en-tête
}
async clickAddItem() {
await this.addItemButton.click();
}
}Les tests se lisent comme un flux utilisateur :
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('le tableau de bord affiche les éléments après connexion', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await dashboardPage.isLoaded();
const rowCount = await dashboardPage.getRowCount();
expect(rowCount).toBeGreaterThan(0);
});Utiliser une BasePage pour le comportement partagé
Si vous avez 10 page objects qui ont tous besoin de la même logique de navigation ou d'une méthode waitForLoad commune, placez-la dans une classe de base :
// pages/BasePage.ts
import { Page } from '@playwright/test';
export class BasePage {
constructor(protected page: Page) {}
async waitForNetworkIdle() {
await this.page.waitForLoadState('networkidle');
}
async getPageTitle() {
return await this.page.title();
}
async scrollToBottom() {
await this.page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
}
}// pages/LoginPage.ts
import { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly loginButton: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: any) {
super(page);
this.loginButton = page.getByRole('button', { name: 'Login' });
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Submit' });
}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
}
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Ne surchargez pas l'héritage. Un niveau (BasePage vers page spécifique) suffit généralement. Les chaînes d'héritage profondes deviennent difficiles à suivre.
Transformer les page objects en fixtures
Créer new LoginPage(page) dans chaque test est répétitif. Les fixtures Playwright permettent d'injecter les page objects automatiquement :
// 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));
},
});
export { expect } from '@playwright/test';Importez maintenant test depuis votre fichier de fixture plutôt que depuis Playwright :
// tests/login.spec.ts
import { test, expect } from '../fixtures/pages.fixture';
test('l\'utilisateur peut se connecter', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(loginPage.page.getByText('My Travel Items')).toBeVisible();
});
test('le tableau de bord affiche les éléments après connexion', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await dashboardPage.isLoaded();
const rowCount = await dashboardPage.getRowCount();
expect(rowCount).toBeGreaterThan(0);
});Les page objects apparaissent dans la signature du test. Plus d'appels au constructeur, plus d'imports de classes dans les fichiers de test, juste les objets, prêts à l'emploi.
Ce qui appartient à un page object et ce qui n'y appartient pas
À mettre dans les page objects :- Les locators des éléments de la page
- Les méthodes représentant les actions utilisateur sur la page
- Les conditions d'attente spécifiques à la page (
isLoaded,waitForModal) - Les getters de données simples (
getRowCount,getHeadingText)
- Les assertions (
expect(...)) - Les données de test
- La logique de test (quoi faire, dans quel ordre)
- Les descriptions de ce que le test vérifie
La règle : les page objects décrivent COMMENT interagir avec la page. Les tests décrivent CE QU'IL FAUT vérifier. Mettre des assertions dans les page objects rend les tests difficiles à lire et les échecs difficiles à diagnostiquer.
// Mauvais : assertion dans le page object
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
await expect(this.page.getByText('My Travel Items')).toBeVisible(); // à éviter
}
// Correct : le page object effectue simplement l'action
async login(username: string, password: string) {
await this.loginButton.click();
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
// Le test fait l'assertion
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();Structure de projet avec le POM
Une structure de dossiers propre pour un projet basé sur le POM :
project/
pages/
BasePage.ts
LoginPage.ts
DashboardPage.ts
ItemsPage.ts
fixtures/
pages.fixture.ts
auth.fixture.ts
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-list.spec.ts
items-crud.spec.ts
api/
items-api.spec.ts
playwright.config.ts
package.jsonFAQ
Chaque page de l'application doit-elle avoir un page object ?Seulement les pages qui ont des tests automatisés. Si vous avez une page de paramètres sans tests, ne créez pas un SettingsPage.ts juste pour l'avoir.
Divisez par section. Une page avec un tableau, une modal et une barre latérale peut devenir ItemsTablePage, AddItemModal et un ItemsPage parent qui les compose. Ou extrayez la modal dans sa propre classe et importez-la dans la page.
Le pattern s'appelle parfois "API Objects" : une classe qui encapsule des appels d'API liés, de la même façon qu'un page object encapsule les interactions UI. Utile quand beaucoup de tests touchent les mêmes endpoints. Pas obligatoire, mais les mêmes bénéfices s'appliquent.
Quand passer de l'absence de POM au POM ?Quand vous vous retrouvez à copier-coller les mêmes 4 à 5 lignes pour la troisième fois. C'est le signal. Ne construisez pas des page objects par anticipation avant de savoir que vous en avez besoin.
→ See also: Fixtures Personnalisés dans Playwright: Le Modèle qui Rend les Tests Lisibles | Patterns Avancés de Page Object dans Playwright | Interfaces et Types TypeScript pour le Page Object Model | Classes JavaScript pour les Ingénieurs QA: Construire des Page Objects