Reasons to not use page objects in E2E tests

They can result in code that is harder to reason about

Page objects seem to have gained a bit of traction in AngularJS E2E testing, especially since they seem to be officially recommended. In this post I offer a few reasons not to use them.

Your scientists were so preoccupied with whether or not they could, they didn’t stop to think if they should.

Ian Malcom, Jurassic Park

Reason 1: They obscure behaviour

Consider the official example below.

var AngularHomepage = function() {
  var nameInput = element(by.model('yourName'));
  var greeting = element(by.binding('yourName'));

  this.get = function() {
    browser.get('http://www.angularjs.org');
  };

  this.setName = function(name) {
    nameInput.sendKeys(name);
  };

  this.getGreeting = function() {
    return greeting.getText();
  };
};

Because of the behaviour of ElementFinder, the elements are actually only retrieved when you call setName or getGreeting. Of course you know this, since you know how Protractor behaves, and can see the code of the page object. But you could have this information much closer to the test rather than elsewhere. When you have more complex page behaviour, with elements appearing and disappearing due to non-trivial behaviour, in order to keep the tests maintainable, it is important to make sure that they are doing what you think they're doing.

I think the behaviour of ElementFinder that attempts to retrieve the element from the page when the element is first interacted with is good for simple cases, but slightly too magical for testing more complex UIs. Hiding this behind your own layer makes this situation even worse.

Reason 2: They obscure state

By nature of being objects, they promote keeping an internal state, exposed by an interface. You might not be able to get away from having some state in your tests: they last for some "real" period of time. However, you should be able to keep it all in the main body of the test, keeping only the state you need for that particular test.

Consider the example above. As mentioned, the elements are actually found on the page when you call setName or getGreeting, but you are offered no way to "refind" an element, say if the application removes them and new ones are added to the DOM. Of course, you can create a new instance of that page object, or add a method to do it,

this.refindNameInput = function() {
  nameInput = element(by.model('yourName'))
};

but doing this means that you are completely leaking how the page object handles state internally. Encapsulation has offered little but another layer of code between the test and what it's doing.

Reason 3: They obscure the interactions with the UI

The page object example in the documentation suggests wrapping the non-semantic UI actions such as typing in an input and getting text from an element...

nameInput.sendKeys(name);
...
greeting.getText();

... with functions with semantic names.

this.setName = function(name) {
  nameInput.sendKeys(name);
};

this.getGreeting = function() {
  return greeting.getText();
};

The main problems is that it's needlessly hiding how the object is interacting with the elements on the page and where the object gets its data. This means it's less clear what the test is testing.

As the application grows, and you have more elements and ways of interacting with them, combinations of key presses, mouse movements, and different and more complex ways of giving output, this can lead to your page objects exposing a lot of what you meant to hide in the first place.

Reason 4: You are spending time coding for a case of many E2E tests that should never happen

It is usually recommended to not have that many E2E tests. If you have a very small amount of E2E tests for each part of your application, then spending time for some future "what if we do this 100s of times" cases is effort expended that has the primary effect of making it harder to reason about each test that you do have.

Reason 5: They might not be the right abstraction for your cases

A "page", at least by name, may not be the right abstraction for finding or interacting with elements in an application. You might have a widget to test in a left menu panel, but it can also appear on the right, accessible from a different selector. Or it might appear at different times. It might also behave slightly differently depending on where it is or when it appears. Or you might even have different browser instances in play, say to test a login/registration flow, and need to control different ones, having them all in-play at once.

Just like optimization, premature refactoring, also known as speculative generalization, can be bad. Instead, I suggest doing continous/opportunistic refactoring. Until you have 3 tests or more, you might not really be able to see what is common to them, and you might get yourself stuck in multiple layers of asynchronous test code and related page objects that aren't particularly suited to your case.

Keep in mind: there might not even be a "right" abstraction for all your tests, and that's ok!

Of course, not planning up front has its criticisms.

I find that weeks of coding and testing can save me hours of planning.

Someone

However, I think using page objects just to have encapsulation and because others do it doesn't quite meet the definition of what I think is planning. At least good planning!

What to do instead of page objects?

Always keep in mind what you're trying to do in the tests.

  • Finding element(s)
  • Interacting with element(s)
  • Asserting something about element(s)

If you have a few tests of a repeated non-trivial group of actions, like a login, or maybe a drag and drop action between one part of a page and another, then factor them out to functions, not objects, that group them. I would really consider not factoring out much more than that.

For example, consider the following test.

describe('my homepage', function() {
  it('should greet the named user', function() {
    browser.get('http://mypage.com');
    input = element(by.xpath('//label[text()="Name"]/following::input[1]'))
    input.sendKeys('Julie');
    greeting = element(by.xpath('//label[text()="Greeting"]/following::p[1]'))
    expect(greeting.getText()).toEqual('Hello Julie!');
  });
});

You can see, right here in these few lines of code, what the test is doing: how the elements are being found and how Protractor is interacting with them. I think each test of your suite should be this clear. This does mean they will be some repeat calls to ElementFinder functions, or maybe some repeated locators, but so be it!

This doesn't sound very DRY... what if/when I need to change something?

Consider the reasons for changing/adding test code.

  • Changing something trivial in the UI, like text or where an element is on a page
  • Changing something non-trivial in the UI, like how a non-trivial group of widgets work together
  • Adding a new test
  • Fixing an existing test, for example if it is flaky/occasionally fails

If your main concern is the first case, and you're worrying that you might have to do a bit of a search-replace in your tests, then my suspicion is that you're focusing on the wrong thing. At most it's a few minutes of something a bit boring. I think it's much more important to make the tests easier to deal with if your doing something less-trivial, which are the other 3 cases. In those cases, clarity of what the tests are doing is paramount.