ProtractorJS: A Better Way To Implement Page Objects July 2015

ProtractorJS: A Better Way To Implement Page Objects

This was republished from Teachable’s tech blog.

Page objects, recommended in the Protractor docs, are a popular pattern for writing end-to-end tests for Angular but they quickly become lists of ugly, boilerplate selectors that break when you decide to change your DOM. They do an excellent job of separating view logic from test logic, but they are mangled with references to models, css, and bindings that are none of their business. Plus, you have better things to do with your life than write page objects… like go learn how to milk a goat or study Gaelic.

The original page object from our author bio spec:

module.exports = new ->
  page = this
  @authors = element.all(By.repeater("author in authors"))
  @authorName = (author) -> author.element(By.binding("author.name"))
  @authorShowDeleteModal = (author) -> author.element(By.css("[ng-click=\"showDeleteAuthorModal(author)\"]"))
  @authorByName = (name) ->
    element.all(By.xpath("//*[contains(text(), '" + name + "')]")).first().element(By.xpath('..'))
  @addAuthor = (params) ->
    form = pages.common.author_form
    form.form.isDisplayed().then (isDisplayed) ->
      if not isDisplayed then page.addAuthorButton.click()
      form.name.clear().sendKeys(params.name) if params.name?
      form.saveButton.click()
  return

One of the original author bio specs:

it "should show a list of author bios sorted alphabetically by name", ->
  page.authors.count().then (count) ->
    expect(count).toBeGreaterThan(1)
    expect(pages.common.page_header.text.getText()).toBe("Authors (" + count + ")")
  mapper = (author) -> page.authorName(author).getText()
  page.authors.map(mapper).then (names) ->
    expect(names).toEqual(names.slice(0).sort(caseInsensitiveSorter))

Not bad, but do you really want your test to break just because you decide to rename your “showDeleteModal” function?

They also move logic out of your tests so that you have to look up the relevant page object before you can understand a test. Separation of concerns is good but so is getting shit done.

Wouldn’t it be cool if you could select elements with the same syntax that you are used to with page objects, without having to write the page objects? And what if you could still do this while keeping your test logic separate from your view logic? With a few minor extensions to protractor, you totally can!

To accomplish this, we add a what attribute to our element to identify it semantically and a which attribute to distinguish it from other items repeated in a list:

<ul what="author list">
  <li class="author" ng-repeat="author in authors" what="author" which="">
    <div class="name" what="name" ng-bind="author.name"></div>
    <button class="btn btn-default" what="save button">Save</button>
  </li>
</ul>

Now we can select elements without using ids, classes, bindings, or models. Our new attributes are only used for testing, so we know not to mess with them when we’re refactoring our code or we’ll break our tests. This also gives us the added benefit of having extremely readable tests: I wasn’t really able to follow By.xpath(“//*[contains(text(), ‘” + name + “’)]”)) but I totally understand what “author list” and “save button” mean.

element(By.css(“[what='author list'] [what='author'][which='Chris'] [what='save button']”)).click()

But there’s still room for improvement. Let’s extend protractor to give ourselves a more readable syntax (I’ll tell you about our protractor-extras module to do this in one line later):

By = global.by
ElementFinder = protractor.ElementFinder
ElementArrayFinder = protractor.ElementArrayFinder

# All pages should extend this class
global.Page = class
  get: (what) ->
    return element(By.what.apply(this, arguments))
  which: (which) ->
    return element(By.which.apply(this, arguments))
  all: (what) ->
    return element.all(By.what(what))
  select: (selector) ->
    return element(By.css(selector))

By.addLocator “what”, (what, parent) ->
 return $(parent or document).find(“[what=\”” + what + “\”]”).first()

ElementFinder.prototype.get = (what) ->
if typeof what is "string"
  return @element(By.what(what))
else
  return @element(what)

originalElementAllMethod = ElementFinder.prototype.all
ElementFinder.prototype.all = (what) ->
  if typeof what is "string"
    return @all(By.what(what))
  else
    return originalElementAllMethod.apply(this, arguments)

ElementArrayFinder.prototype.which = (which) ->
  filter = (element) ->
    return element.getAttribute("which").then (value) ->
      return which is value
  return @filter(filter).toElementFinder_()

Now we can write that as:

page.get(“author list”).all(“author”).which(“Chris”).get("save button").click()

Which means that now we can change our ids, model names, classes, and bindings all we want without breaking anything. This is also readable enough that we can use it directly in our specs without the need for page objects.

Here’s what the final result looks like:

it "should show a list of author bios sorted alphabetically by name", ->
  page.all("author").count().then (count) ->
    expect(count).toBeGreaterThan(1)
    expect(page.get("page header").get("text").getText()).toBe("Authors (#{count})")
  mapper = (author) -> author.get("name").getText()
  page.all("author").map(mapper).then (names) ->
    expect(names).toEqual(names.slice(0).sort(caseInsensitiveSorter))

Once ES6 proxies are supported by our test browser, we will be able to use properties instead of methods to get elements, just like our original page object syntax:

page.authorList.authors.which(“Chris”)

But that’s a little ways out — Chrome revoked it’s experimental support for Proxies in version 38.

“But what about helper methods?!”, you ask. Despite wanting to kill page objects, I have to admit they can still sometimes be useful. They serve nicely as a place to put helper methods — for example, we create an author four times in our authors spec, so I kept our authors page object just to house the createAuthor method. With our new syntax, most tests won’t require page objects, but occasionally you may want to use one to organize reusable methods.

If you like this pattern and want to implement it, you can use our protractor-extras module to import these extra methods:

require("protractor-extras", protractor, global)

And that’s it. This extends the protractor and global objects.