Comprendre le pattern Page Object Model

Comprendre le pattern Page Object Model

Introduction

Le pattern Page Object Model est un des modèles de conception d’automatisation de test les plus largement utilisés.
La plupart des ingénieurs QA/SDET (Software Development Engineer in Test) ont à un moment donné utilisé une variante du pattern page object model.
Cependant, il est souvent très mal compris et mal implémenté, ce qui peut entraîner un code d’automatisation de test ultra fragile et difficile à maintenir.

Dans cet article, j’aborde les concepts clés du pattern page object model afin de le rendre plus clair et plus facile à comprendre pour les ingénieurs automaticiens de la communauté francophone.

J’aimerais commencer par deux citations, l’une de Simon Stewart le créateur de Selenium Webdriver et la seconde est une réflexion de Martin Fowler

  • If you have WebDriver APIs in your test methods, You’re Doing It Wrong.

Simon Stewart

  • A page object wraps an HTML page, or fragment, with an application-specific API, allowing you to manipulate page elements without digging around in the HTML.
  • A page object should also provide an interface that’s easy to program to and hides the underlying widgetry in the window page.
  • The page object should encapsulate the mechanics required to find and manipulate the data in the page itself. A good rule of thumb is to imagine changing the concrete page -in which case the page object interface shouldn’t change.

Martin Fowler

Le problème

Lorsque vous écrivez des tests fonctionnels à l’aide de Selenium Webdriver (ou à l’aide d’un autre framework), la grosse partie du travail consiste à gérer des interactions avec l’interface utilisateur via l’API Webdriver.
La plupart du temps c’est le scenario classique suivant :

  • Initialisation du contexte
  • Récupération des éléments web
  • Interactions avec les éléments web (saisie d’un texte, clique sur un élément, récupération de texte, etc.)
  • Vérification des résultats à travers différentes assertions

Considérez l’exemple suivant (Un test de login très basique avec Selenium Webdriver en JavaScript):

await driver.get("http://the-internet.herokuapp.com/login");
await driver.findElement({ id: "username" }).sendKeys("tomsmith");
await driver.findElement({ id: "password" }).sendKeys("SuperSecretPassword!");
await driver.findElement({ css: "button" }).click();
assert(await driver.findElement({ css: ".flash.success" }).isDisplayed());

Comme vous l’avez certainement constaté, ceci est un simple test avec des actions limitées :

  • Chargement de l’URL du login
  • Saisie du login
  • Saisie du mot de passe
  • Clique sur le bouton login
  • Vérification de l’affichage d’un message de succès

Et même avec un test très simple comme celui-ci, la lisibilité est très réduite. Il y a plusieurs utilisations de l’API Webdriver qui obscurcit le but principal du test.
Avec une simple analyse on peut identifier quelques limites et problèmes pour cette approche:

  1. Il n’y a pas de séparation claire entre les méthodes de test et les localisateurs de l’application (locators). Ils sont tous dans une seule méthode.
  2. Si l’application change ses identifiants ou sa structure graphique, les tests doivent changer également.
  3. Imaginez un scénario de plusieurs tests qui nécessitent l’utilisation de cette fonctionnalité de login. Le même code de connexion sera du coup répété encore et encore dans chaque test.
  4. Tout changement dans l’interface utilisateur signifie que tous les tests devront être modifiés également.
  5. Le code ci-dessus n’est pas très lisible, n’est pas facilement maintenable, la réutilisable est très limitée et laisse la porte ouverte pour la duplication de code (Se sont exactement les problèmes traités par les concepts du pattern Page Object Model).

La solution

Sans chercher à «réinventer la roue», la solution est tout simplement l’utilisation du pattern page object model

Qu’est-ce que le pattern page object model ?

Ce pattern est un modèle de conception très populaire dans le contexte de l’automatisation des tests UI pour améliorer la maintenance des tests et réduire la duplication de code.
Il s’agit d’un modèle de langage neutre pour représenter une page complète ou une partie d’une page de manière orientée objet. Et nous les utilisons pour modéliser l’interface utilisateur de l’application.

Avec ce pattern, les objets de la page exposent des méthodes qui reflètent les actions ou les éléments graphiques qu’un utilisateur peut faire et voir sur une page web.
Il cache également les détails d’implémentation indiquant au navigateur comment manipuler les éléments de la page.

En bref, le pattern page object model encapsule les différents comportements d’une page.

Vos tests utilisent ensuite les méthodes exposées par cette classe (page object) chaque fois qu’ils ont besoin d’interagir avec l’interface utilisateur.

L’avantage est que si l’interface utilisateur de la page change, les tests eux-mêmes n’ont pas besoin de changer, seul le code dans la page object doit changer.
Par la suite, toutes les modifications pour prendre en charge cette nouvelle interface utilisateur se trouvent au même endroit (page object).

Une image vaut mille mots

Cette figure illustre les concepts du pattern page object model

Page Object Model

Pourquoi utiliser le pattern page object model ?

Les principales raisons sont les suivantes :

  • Maintenabilité
  • Réduction ou élimination de la duplication de code
  • Lisibilité des scripts
  • Réutilisabilité
  • La scalabilité
  • Amélioration de l’organisation du code source

Implémentation

Nous allons procéder étape par étape pour la mise en œuvre de cette technique de page object model, ci-dessous les étapes nécessaires:

  1. Configuration basique de Selenium Webdriver
  2. Analyse de l’application sous test (AUT*)
  3. Écriture de page objects
  4. Écriture de tests

Pour être plus précis ce n’est pas exactement la méthode que j’utilise tous les jours, car je commence par l’écriture des tests ce qui me permet de justifier chaque variable, chaque ligne de code et m’aider à faire du clean code. Mais c’est un sujet que je n’aborderais pas ici pour garder le focus sur le pattern page object model.

Configuration basique de Selenium Webdriver

Externaliser le code de la gestion du cycle de vie du driver (Webdrivier) dans une classe ou un script séparé (Separation of concerns) est une excellente idée que je recommande très fortement.
Ici dans mon cas j’utile mochajs, j’ai mis le code de la gestion du cycle de vie du driver dans les hooks mocha beforeEach et afterEach.
Le pattern Driver Factory est également utilisé pour la gestion de plusieurs types de navigateurs mais ce n’est pas l’objet de l’article.

const DriverFactory = require("./driver-factory");
const driverFactory = new DriverFactory();

beforeEach(async function () {
  const testName = this.currentTest.fullTitle();
  this.driver = await driverFactory.build(testName);
});

afterEach(async function () {
  await driverFactory.quit();
});

Analyse de l’application sous test (AUT*)

AUT*: Application Under Test

Dans cet article j’ai utilisé l’application the-internet et principalement la page login avec deux scenarios simples :

  • Cas passant
    Login avec les informations correctes (login et mot de passe)
    Login Secure Pass
  • Cas non-passant
    Echec de login avec des informations incorrectes (login et mot de passe incorrect)
    Login Secure Fail

Après une petite analyse j’ai pu identifie que le message qu’indique le succès ou l’échec du login ne fait pas partie de la page login, ni de la page cible une fois la connexion est réussie secure page, j’ai donc décidé de se limiter à la page login, gérer le message dans cette même page et de n’est pas créer le model page object pour la page secure, c’est largement suffisant pour cet exemple.

Écriture de Page objects

Page object Login (Classe LoginPage)

Ceci est le code de la classe page object login (LoginPage):

const BasePage = require("./BasePage");

const LOGIN_FORM = { id: "login" };
const USERNAME_INPUT = { id: "username" };
const PASSWORD_INPUT = { id: "password" };
const SUBMIT_BUTTON = { css: "button" };
const SUCCESS_MESSAGE = { css: ".flash.success" };
const FAILURE_MESSAGE = { css: ".flash.error" };

class LoginPage extends BasePage {
  constructor(driver) {
    super(driver);
  }

  async load() {
    await this.visit("/login");
    if (!(await this.isDisplayed(LOGIN_FORM, 1000)))
      throw new Error("Login form not loaded");
  }

  async authenticate(username, password) {
    await this.type(USERNAME_INPUT, username);
    await this.type(PASSWORD_INPUT, password);
    await this.click(SUBMIT_BUTTON);
  }

  successMessagePresent() {
    return this.isDisplayed(SUCCESS_MESSAGE, 1000);
  }

  failureMessagePresent() {
    return this.isDisplayed(FAILURE_MESSAGE, 1000);
  }
}

module.exports = LoginPage;

Base page object (Classe BasePage)

const Until = require("selenium-webdriver").until;
const config = require("../configs/the-internet.config");

class BasePage {
  constructor(driver) {
    this.driver = driver;
  }

  async visit(url) {
    if (url.startsWith("http")) {
      await this.driver.get(url);
    } else {
      await this.driver.get(config.baseUrl + url);
    }
  }

  find(locator) {
    return this.driver.findElement(locator);
  }

  async click(locator) {
    await this.find(locator).click();
  }

  async type(locator, inputText) {
    await this.find(locator).sendKeys(inputText);
  }

  async isDisplayed(locator, timeout) {
    if (timeout) {
      await this.driver.wait(Until.elementLocated(locator), timeout);
      await this.driver.wait(
        Until.elementIsVisible(this.find(locator)),
        timeout
      );
      return true;
    } else {
      try {
        return await this.find(locator).isDisplayed();
      } catch (error) {
        return false;
      }
    }
  }
}

module.exports = BasePage;

Écriture de tests

Maintenant nous avons tous les éléments nécessaires pour écrire les cas de tests.
Ci-dessous le code nécessaire pour tester les deux scenarios de login, le cas passant et le cas non-passant

require("../support/mocha-hooks");
const assert = require("assert");
const LoginPage = require("../page-objects/login.page");

describe("Verify Login", function () {
  let login;

  beforeEach(async function () {
    login = new LoginPage(this.driver);
    await login.load();
  });

  it("should be able to login with valid credentials", async function () {
    await login.authenticate("tomsmith", "SuperSecretPassword!");
    assert(
      await login.successMessagePresent(),
      "Success message not displayed"
    );
  });

  it("should not be able to login with invalid credentials", async function () {
    await login.authenticate("invalid", "invalid");
    assert(
      await login.failureMessagePresent(),
      "Failure message not displayed"
    );
  });
});

Comme vous l’avez certainement constaté, on n’utilise plus l’API Webdriver, On utilise plutôt les méthodes exposées par la classe page object login.
Le code est maintenant beaucoup plus clair et nous avons plus de flexibilité pour réutiliser nos objets.

Bonnes pratiques

Bien que la flexibilité soit présente, il y a quelques règles de base que vous devez respecter pour maintenir votre code :

  • Le model page object n’a pas besoin d’être une page HTML entière, il peut aussi être un composant (C’est le cas notamment pour les applications modernes avec les framework React, Angular, etc.)
  • Le model page object expose uniquement les méthodes qu’un utilisateur final utiliserait pour interagir avec la page, les méthodes comme readTxtFile(), connectToDataBase(), executeSQL(), etc. ne devrait pas être exposées au script de test.
  • Ne créez pas tous les objets de page en même temps, ne faites que ce dont vous avez besoin à ce moment donné. Vous pouvez passer des jours (et parfois des semaines) à essayer de créer des pages objects pour l’ensemble de votre application et ce serait une perte de temps. Vos page objects augmenteront lorsque de nouvelles exigences arriveront, ce qui nécessitera de nouveaux scripts de test.
  • Les assertions n’appartiennent pas aux page objects, elles appartiennent aux scripts de test. Les methodes de la page objects ne décident pas si un test passe ou échoue.
  • Une exception à la règle ci-dessus est qu’il doit y avoir une seule vérification dans l’objet page et c’est pour vérifier que la page et tous les éléments importants de la page ont été chargés correctement. Cette vérification doit être effectuée lors de l’instanciation de l’objet de page.

Conclusion

En utilisant le pattern Page Object Model, vos tests deviennent plus concis et lisibles.
Vos localisateurs d’éléments web sont centralisés, ce qui facilite énormément la maintenance et la scalabilité de votre framework.
Les changements de l’interface utilisateur n’affectent que les page objects et non les scripts de test.

Si vous avez aimé cet article, n’hésitez pas à le partager !