Page Object pattern in JavaScript
Page Object pattern

Page Object pattern in JavaScript

The Page Object pattern is certainly the most utilized pattern when it comes to web application test automation. It is a powerful method for reducing code maintenance costs and code duplication which leads to increase of the re-usability and robustness. All this benefits become apparent when you're testing complex large scale applications. And we all know that large and complex applications only tend to grow larger and become more complex.

A Page Object wraps up a page, or a part/fragment of the page. A Page Object is an object that abstracts the details of interfacing with the HTML of a page. It should allow client to see and do anything that a human can. It provides and interface that's easy to program against and hides the complexities of underlying HTML widgets. There is a clean separation between test code and page specific code such as locators and layout. Page Objects creates a single repository for the services or operations offered by the page rather than having these services scattered throughout the tests.

Despite the term "page" in the pattern name, Page Objects shouldn't usually be build for each major application screen (page), but rather for a major parts/fragments of the page. This distinction is very subtle but tremendously important. I've seems implementations where developers tried to implement entire application screens into single page object, which necessary leads to very complex and hard to read spaghetti code. Major parts of the page can be represented for example by list of todo items, containing several todo items, header, main and footer. Some major components are on the page just for the structural purposes. It doesn't make sense to create separate Page Objects for them. Page Objects should only exists if there is some behavior to them that make sense from the user perspective.

For the further explanations and demonstrations I'll assume that we're using selenium-webdriver inside our Page Objects. This doesn't mean that you're required to use selenium-webdriver. Feel free to use any alternative technology that gets the job done (WebdriverIO, NightWatch, etc..). I've also implemented github repository that contains reference implementation of Page Objects for a simple Todo App. In following sections of the article I'll use code fragments from this repository. As I try to avoid JavaScript Classes at all cost, I implemented the page objects in stamp specification. To learn more about stamps please refer to it's documentation.

Idiomatic Page Objects

In the following section we'll enumerate some of the properties that proper Page Objects should implement. These properties are directly derived from books or articles that I have read on the subject during past couple of years and battle tested them in real world production applications.

Proper construction

Page Objects should be constructed either from WebDriver or WebElement. You construct a Page Object from WebDriver when there is only one instance of Page Object on the page. This allows to utilize another idiomatic property "laziness" which I'll describe later. When you have many items on the page which needs to be represented by Page Object (usually one item in a list), the Page Object representing one item must consume WebElement.

consuming WebDriver as there is only one AddButton on the page

const AddButton = stampit.init(function ({ driver }) {
  const findElement = () => driver.findElement(By.css('header button'));

  this.isEnabled = async function isEnabled() {
    const element = await findElement();

    return element.isEnabled();
  };

  this.click = async function click() {
    const element = await findElement();

    return element.click();
  };
});



consuming WebElement as there are multiple TodoListItems on the page

const TodoListItem = stampit
  .init(function ({ webElement }) {
    this.isComplete = async function isComplete() {
      const TodoList = require('./todo-list.page');
      const listElement = await webElement.findElement(By.xpath('..'));
      const todoList = TodoList({ webElement: listElement });

      return todoList.isComplete();
    };

    this.delete = async function _delete() {
      const button = await webElement.findElement(By.className('remove'));

      return button.click();
    };

    this.complete = async function complete() {
      const button = await webElement.findElement(By.className('complete'));

      if (!(await this.isComplete())) { return false; }

      return button.click();
    };

    this.uncomplete = async function uncomplete() {
      const button = await webElement.findElement(By.className('complete'));

      if (!(await this.isTodo())) { return false; }

      return button.click();
    };
  })
  .methods({
    async isTodo() {
      return !(await this.isComplete());
    },
  });

Total abstraction

Every Page Object should provide complete abstraction over the page or part/fragment of the page. Every operation on Page Object should exclusively return only JavaScript types or other Page Objects. It should never return WebDriver or WebElement (selenium-webdriver) instances directly. If you follow this rule, then the selenium-webdriver will become mere implementation detail, and you should be able to switch it for e.g. WebdriverIO with ease.

const AddButton = stampit.init(function ({ driver }) {
  const findElement = () => driver.findElement(By.css('header button'));

  this.isEnabled = async function isEnabled() {
    const element = await findElement();

    return element.isEnabled();
  };

  this.click = async function click() {
    const element = await findElement();

    return element.click();
  };
});

Laziness

It's important to separate the Page Object construction and it's operations. In most cases when constructing the new Page Object, we don't need to call anything on WebDriver or WebElement. By adhering to this separation, you make sure that creation of new Page Object instances is very cheap, and the expensive stuff happens later only if and when needed. Another problem you may avoid is that the page that the Page Object is abstracting may not be available at the time of the Page Object creation.

example of non-lazy approach

const AddButton = stampit.init(function ({ driver }) {
  const element = driver.findElement(By.css('header button'));

  this.isEnabled = async function isEnabled() {
    return element.isEnabled();
  };

  this.click = async function click() {
    return element.click();
  };
});

example of lazy approach

const AddButton = stampit.init(function ({ driver }) {
  const findElement = () => driver.findElement(By.css('header button'));

  this.isEnabled = async function isEnabled() {
    const element = await findElement();

    return element.isEnabled();
  };

  this.click = async function click() {
    const element = await findElement();

    return element.click();
  };
});

Cohesiveness

Don't crate Page Objects that implements multiple aspects of one page. If either of those aspects changes, you'd have to change this Page Object. Any code that is using this Page Object could be affected resulting in more maintain costs for your tests. If Page Objects models multiple aspects of the page, any changes impacting one aspect can result impacting second aspect and vice versa. Instead model only one aspect of the page functionality in a Page Object. A cohesive Page Object reduces it's complexity and increases it's re-usability.

Diagnostics

According to some authors, your Page Objects should contain diagnostic mechanism to determine if the Page Object was created on the correct page and throw errors if not. The argument is that it's hard to diagnose such errors when they happen. I personally don't use this diagnostic mechanism. I haven't seen such a big benefit from it nor did it help me to avoid this hard to diagnose errors (which I haven't seen at all). I'd utilize this mechanism if and when this problems starts to occur in your Page Objects/tests, but not before.

Never Assert

There are two schools of thoughts on this subject. The first school is saying that your Page Object operations should include assertions which should help with duplication of assertions in tests. The second school of thought says that we should always have assertions free Page Objects. Including assertions mixes responsibilities of providing access to the page data with assertion logic which leads to bloated Page Objects. I personally would always prefer assertion free Page Objects. In my opinion assertions have nothing to do with the Page Object pattern an it always paid of for me in the past to separate those two concerns.

There are several other idiomatic properties you can incorporate into your Page Objects. The last of them worth mentioning is fluent interface. According to some people it makes your tests more readable. The downside is that it makes your Page Objects more verbose and over-bloated. It's up to you decide if you want/need to utilize it.

We're nearing the end of this article. This article is a compilation of the know-how I have accumulated during last couple of years regarding Page Objects. Hope it helps you to create more idiomatic Page Objects in you application which in turn help you create more readable and maintainable e2e (end to end) tests. It is also important to find a fair balance between modeling Page Objects against the page behavior and underlying HTML structure. You should always prefer to push your self modeling to the page behavior and always prefer it.

Imagine that you've manged to cover your entire application with idiomatic Page Objects. These Page Objects should allow you to write a script that demonstrates the features of your application programmatically. If you managed to that than I'd says you did the job right an correctly.

Next time I'l show you how to avoid creating the hellish util module pattern that contains all the helper utils that doesn't fit inside your Page Objects. Another article will follow that will continue with the explanation how to utilize our idiomatic Page Objects to create e2e tests using cucumber.js.

Like always, I end my article with the following axiom: Define your code-base as pure functions and lift them only if needed. And compose, compose, compose…

e2e (End-To-End) testing done properly series:

To view or add a comment, sign in

Insights from the community

Others also viewed

Explore topics