Architecture

Page Object Model avec Playwright : le guide pas à pas

Structure tes tests Playwright avec le Page Object Model : première page object, composants, fixtures et erreurs à éviter, en TypeScript.

Ta suite Playwright compte 20 tests. Le formulaire de connexion change de libellé, et 14 tests cassent en même temps. Tu passes une demi-journée à corriger le même sélecteur dans 14 fichiers. C'est exactement le problème que résout le Page Object Model (POM) : regrouper au même endroit tout ce qui concerne une page, pour qu'un changement d'interface ne se corrige qu'une seule fois.

Ce guide montre comment mettre en place le Page Object Model avec Playwright et TypeScript, pas à pas : une première page object, l'enchaînement entre pages, les composants réutilisables, l'intégration avec les fixtures Playwright, puis les erreurs qui transforment un POM utile en couche d'abstraction pénible à maintenir.

Le Page Object Model en une phrase

Une page object est une classe qui représente une page (ou un morceau de page) de l'application testée. Elle contient deux choses : les locators des éléments de la page, et des méthodes métier qui décrivent ce qu'un utilisateur y fait, comme login() ou ajouterAuPanier(). Les tests, eux, n'utilisent plus de sélecteurs : ils appellent ces méthodes et vérifient le résultat.

Résultat : les tests se lisent comme des scénarios fonctionnels, et la connaissance de l'interface (où est le bouton, comment il s'appelle) vit dans un seul fichier.

Le problème : des tests qui répètent la page

Voici deux tests écrits sans POM :

ts
test('un utilisateur peut se connecter', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Adresse e-mail').fill('qa@example.com');
  await page.getByLabel('Mot de passe').fill('motdepasse');
  await page.getByRole('button', { name: 'Se connecter' }).click();
  await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
});

test('un mauvais mot de passe affiche une erreur', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Adresse e-mail').fill('qa@example.com');
  await page.getByLabel('Mot de passe').fill('mauvais');
  await page.getByRole('button', { name: 'Se connecter' }).click();
  await expect(page.getByRole('alert')).toContainText('Identifiants invalides');
});

Les deux tests fonctionnent. Mais les trois lignes de connexion sont copiées, et chaque nouveau test qui passe par la connexion les recopiera encore. Le jour où le bouton devient « Connexion », il faut modifier tous les fichiers concernés.

Étape 1 : écrire une première page object

Crée un dossier tests/pages et un fichier LoginPage.ts :

ts
// tests/pages/LoginPage.ts
import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Adresse e-mail');
    this.passwordInput = page.getByLabel('Mot de passe');
    this.submitButton = page.getByRole('button', { name: 'Se connecter' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Quelques choix méritent une explication :

  • Les locators sont créés dans le constructeur, mais Playwright ne cherche aucun élément à ce moment-là. Un locator est une description : la recherche a lieu au moment de l'action (fill, click) ou de l'assertion, avec l'attente automatique de Playwright. Tu peux donc déclarer tous les locators d'une page sans te soucier de l'ordre d'affichage.
  • Les sélecteurs utilisent getByLabel et getByRole plutôt que des classes CSS ou des chemins XPath. Ce sont les sélecteurs recommandés par Playwright : ils suivent ce que voit l'utilisateur et résistent aux changements de mise en page. Quand aucun libellé n'est fiable, getByTestId sur un attribut data-testid posé par les développeurs est la meilleure solution de repli.
  • Les propriétés sont en readonly : un test peut lire loginPage.errorMessage pour une vérification, mais ne peut pas le remplacer par erreur.
  • La méthode s'appelle login(), pas remplirEmailEtCliquer(). Elle décrit une intention métier, ce qui garde les tests lisibles même pour quelqu'un qui ne connaît pas le code.

Étape 2 : réécrire les tests avec la page object

ts
// tests/specs/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('un utilisateur peut se connecter', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('qa@example.com', 'motdepasse');
  await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
});

test('un mauvais mot de passe affiche une erreur', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('qa@example.com', 'mauvais');
  await expect(loginPage.errorMessage).toContainText('Identifiants invalides');
});

Si le libellé du bouton change, une seule ligne est à modifier, dans LoginPage.ts. Et chaque test raconte maintenant un scénario : aller sur la page de connexion, se connecter, vérifier le résultat.

Étape 3 : enchaîner les pages

Après une connexion réussie, l'utilisateur arrive sur le tableau de bord. Une pratique courante consiste à faire retourner à la méthode la page object suivante :

ts
// tests/pages/DashboardPage.ts
import { type Locator, type Page } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly projectList: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole('heading', { name: 'Tableau de bord' });
    this.projectList = page.getByRole('list', { name: 'Projets' });
  }

  async openProject(name: string) {
    await this.projectList.getByRole('link', { name }).click();
  }
}

Puis, dans LoginPage, une méthode dédiée au parcours réussi :

ts
async loginAs(email: string, password: string): Promise<DashboardPage> {
  await this.login(email, password);
  return new DashboardPage(this.page);
}

Le test devient alors :

ts
const dashboard = await loginPage.loginAs('qa@example.com', 'motdepasse');
await expect(dashboard.heading).toBeVisible();
await dashboard.openProject('Boutique en ligne');

Garde login() pour les cas d'erreur, qui restent sur la page de connexion, et loginAs() pour le parcours qui mène au tableau de bord. Une méthode qui retourne une page différente selon le résultat rendrait les tests ambigus.

Étape 4 : extraire les composants réutilisables

Certains éléments apparaissent sur plusieurs pages : l'en-tête, le menu utilisateur, une fenêtre de confirmation. Plutôt que de les dupliquer dans chaque page object, crée des composants :

ts
// tests/components/UserMenu.ts
import { type Locator, type Page } from '@playwright/test';

export class UserMenu {
  readonly openButton: Locator;
  readonly logoutItem: Locator;

  constructor(page: Page) {
    this.openButton = page.getByRole('button', { name: 'Mon compte' });
    this.logoutItem = page.getByRole('menuitem', { name: 'Se déconnecter' });
  }

  async logout() {
    await this.openButton.click();
    await this.logoutItem.click();
  }
}

Chaque page qui affiche ce menu l'expose comme une propriété :

ts
// dans DashboardPage
readonly userMenu: UserMenu;
// dans le constructeur
this.userMenu = new UserMenu(page);

Le test écrit simplement await dashboard.userMenu.logout(). Cette composition est souvent préférable à une longue chaîne d'héritage : une classe BasePage peut porter deux ou trois utilitaires vraiment communs, mais dès qu'elle accumule des méthodes utilisées par une seule page, elle devient un fourre-tout difficile à faire évoluer.

Étape 5 : injecter les pages avec les fixtures Playwright

Écrire new LoginPage(page) au début de chaque test reste répétitif. Les fixtures de Playwright permettent d'injecter les page objects directement dans les paramètres du test :

ts
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

type Pages = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

export { expect } from '@playwright/test';

Les tests importent alors test et expect depuis ce fichier, et non plus depuis @playwright/test :

ts
import { test, expect } from '../fixtures';

test('un mauvais mot de passe affiche une erreur', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login('qa@example.com', 'mauvais');
  await expect(loginPage.errorMessage).toContainText('Identifiants invalides');
});

Chaque test reçoit sa propre instance, créée à la demande : une fixture que le test n'utilise pas n'est jamais construite. C'est aussi ce qui garantit l'isolation entre les tests, puisqu'aucune page object n'est partagée.

Bonus : se connecter une seule fois pour toute la suite

Passer par l'écran de connexion dans chaque test ralentit la suite. Playwright permet de se connecter une seule fois dans un projet de « setup », d'enregistrer la session, puis de la réutiliser dans tous les tests. La page object sert alors à ce setup :

ts
// tests/auth.setup.ts
import { test as setup } from './fixtures';

setup('connexion', async ({ loginPage, page }) => {
  await loginPage.goto();
  await loginPage.login(process.env.TEST_USER_EMAIL!, process.env.TEST_USER_PASSWORD!);
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
ts
// playwright.config.ts (extrait)
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
],

Les tests du projet chromium démarrent alors directement connectés. Ajoute le dossier playwright/.auth à ton .gitignore : il contient une session valide.

Organiser les fichiers

Une structure simple suffit pour la plupart des projets :

text
tests/
├── components/
│   └── UserMenu.ts
├── pages/
│   ├── LoginPage.ts
│   └── DashboardPage.ts
├── specs/
│   ├── login.spec.ts
│   └── dashboard.spec.ts
├── auth.setup.ts
└── fixtures.ts

Une page object par page ou par grand écran, un composant par élément partagé, et les fichiers de test regroupés par fonctionnalité. Si une page object dépasse quelques centaines de lignes, c'est généralement le signe qu'elle contient plusieurs écrans ou composants à séparer.

Où mettre les assertions ?

C'est le débat le plus fréquent autour du POM. Deux approches existent :

  • Assertions dans les tests : la page object ne fait qu'agir et exposer des locators, et c'est le test qui vérifie. Chaque test montre clairement ce qu'il contrôle.
  • Méthodes de vérification dans la page object : par exemple expectErrorMessage(texte). Pratique quand la même vérification revient dans beaucoup de tests.

Une règle simple fonctionne bien : garde les vérifications métier (ce que le test cherche à prouver) dans le test, et réserve aux page objects les vérifications techniques répétitives, comme attendre qu'une page soit chargée. Si quelqu'un lit le test sans ouvrir les page objects, il doit comprendre ce qui est vérifié.

Les erreurs les plus fréquentes

ErreurConséquenceÀ faire plutôt
Sélecteurs CSS ou XPath liés à la mise en pageLes tests cassent au moindre changement de stylegetByRole, getByLabel, ou getByTestId en repli
Méthodes techniques (clickSubmit, fillField2)Des tests aussi difficiles à lire qu'avantDes méthodes métier : login, checkout, openProject
Une page object géante pour tout le siteFichier ingérable, conflits entre membres de l'équipeUne classe par écran, des composants pour le reste
waitForTimeout dans les méthodesSuite lente et pourtant instableLaisser l'attente automatique agir, attendre un état avec expect
Une instance partagée entre plusieurs testsTests dépendants les uns des autresUne instance par test, via les fixtures
Héritage sur plusieurs niveaux (BasePage → AuthPage → …)Difficile de savoir d'où vient une méthodeLa composition par composants

Quand ne pas utiliser le Page Object Model

Le POM a un coût : des fichiers en plus, et une couche à maintenir. Il n'est pas toujours rentable :

  • pour une suite de quelques tests sur un prototype qui va changer complètement ;
  • pour des scripts ponctuels, comme un export de données ou une vérification exploratoire ;
  • pour les tests d'API, où un petit client par ressource est plus adapté qu'une « page ». Voir automatiser vos tests API avec Playwright.

Dès qu'un même écran est utilisé dans plusieurs tests, en revanche, le POM se rentabilise vite.

FAQ sur le Page Object Model avec Playwright

Faut-il choisir entre page objects et fixtures ?

Non, les deux se complètent. La page object décrit une page et ses actions ; la fixture décide comment et quand elle est créée, puis la fournit au test. L'approche recommandée consiste à écrire des page objects, puis à les exposer via des fixtures, comme à l'étape 5.

Le Page Object Model est-il obligatoire avec Playwright ?

Non. Playwright n'impose aucune structure, et sa documentation présente le POM comme une bonne pratique parmi d'autres. Sur un projet d'entreprise avec plusieurs dizaines de tests, c'est toutefois la structure la plus répandue, et celle que l'on attend généralement d'un QA automaticien en entretien.

Faut-il écrire une page object pour chaque page de l'application ?

Non, seulement pour les pages que tes tests utilisent. Crée la page object au moment où tu écris le premier test qui en a besoin, puis enrichis-la au fil des tests. Créer toutes les pages à l'avance produit des méthodes jamais utilisées.

Peut-on utiliser le POM avec JavaScript plutôt que TypeScript ?

Oui, le principe est identique. TypeScript apporte cependant l'autocomplétion des méthodes et des locators dans l'éditeur, et signale une erreur dès qu'une méthode est renommée sans mettre à jour les tests. Pour démarrer, voir TypeScript pour Playwright : les bases essentielles.

Pour aller plus loin

Le Page Object Model est la première marche vers une suite de tests professionnelle. Si tu débutes, commence par débuter avec Playwright. Une fois ta suite structurée, l'étape suivante consiste à la faire tourner automatiquement à chaque modification du code : c'est l'objet du guide Playwright et GitHub Actions.

Structurer un vrai projet avec des page objects, des fixtures et une CI, avec quelqu'un qui relit ton code à chaque étape, c'est le cœur de la formation Playwright Mission Playwright.