Le design pattern Page Object Model (POM) est un des modèles de conception d’automatisation des tests les plus largement utilisés. Il est utilisé avec les principaux Frameworks d’automatisation des tests comme Selenium, WebdriverIO, Cypress, Nightwatch etc. La plupart des ingénieurs QA/SDET (Software Development Engineer in Test) ont à un moment donné utilisé une variante du design 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.
- 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.
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 :
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 :
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:
L’une des solutions courantes pour résoudre ce problème consiste à utiliser le pattern page object model, il y a d’autres patterns comme le Screenplay/Journey Pattern mais ce n’est pas le sujet de ce post, cela peut faire l’objet d’un nouvel article
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).
Cette figure illustre les concepts du pattern page object model
Les principales raisons sont les suivantes :
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:
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.
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();
});
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:
Cas non-passant:
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.
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;
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;
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.
Bien que la flexibilité soit présente, il y a quelques règles de base que vous devez respecter pour maintenir votre code :
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 !