AngularJS – End-to-end testing with Protractor

  • Fabio Santschi

We use AngularJS here at Liip, and as developers concerned with the quality of our work, we employ a multitude of tools and patterns to ensure that everything works as expected. In short, we test our software.

The goal of this post is to give you a short overview about some of our experiences with testing AngularJS applications.

End-to-end testing

Modern web applications have to integrate a variety of external services, database systems and APIs. On top of that, they have to accomodate an ever-shifting landscape of devices and browsers.

This is an area that is notoriously difficult or even outright impossible to test with traditional methods such as unit tests and simple mocks. A database can fail, an external service can return an invalid result and a new browser version might have introduced a simple bug that we didn't know about when we initially wrote our code.

This is where end-to-end testing comes into play. We want to test our application as a whole and make sure that it works as expected. We want to test the entire application, starting from the user interface down to the individual subsystems, such as the backend storage or external services.

It's important to note that end-to-end testing is no panacea, we employ it alongside other testing methods, but the high level and broad scope of end-to-end testing helps us tremendously when developing a complex web application.

Enter Protractor

Google has released an end-to-end testing framework for AngularJS applications called Protractor that integrates existing technologies such as Selenium, Node.js and Jasmine and makes writing tests a breeze.

With Protractor we can write automated tests that run inside an actual browser, against an existing website. Thus, we can easily test whether a page works as expected. The added bonus of using Protractor is that it understands AngularJS and is optimized for it.

If you already know Selenium and Jasmine, getting started with Protractor should be pretty straight forward.

Installation

Protractor requires Node.js, Selenium and a testing framework such as Jasmine to be installed on your computer. You can find more about the installation process here.

Writing a simple test

Protractor expects your tests to be written in so-called spec files. Spec is simply another word for test. These spec files are written using the syntax of your test framework, and the Protractor API. Out of the box, Protractor uses Jasmine as its default test framework, but it also has tentative support for Mocha and Cucumber.

Let's assume we want to test whether a login page displays an error message if we do not fill in the password field.

In protractor, we'd create a spec file (login_spec.js) for it that might look like this:

describe('login page', function() {
  it('should display an error if the password field is empty', function() {

    // Visit the login page
    browser.get('http://mysuperawesomepage.com/login');

    // Find the element that matches ng-model="userName" and type 'gandalf' into it.
    element(by.model('userName')).sendKeys('gandalf');

    // Find the submit button and click it
    element(by.id('btn-submit')).click();

    // Check whether our error message is displayed
    expect(element(by.css('.password-error')).isDisplayed()).toBe(true);
  });
});

So, what have we done here?

The describe call is from Jasmine, and we use it to describe the page we want to test, in this case, our login page. The it call is also from Jasmine, and we use it to describe the scope of our test. In this case the login page should display an error message if the password field is empty.

browser is a global variable exposed by Protractor, and we use it to visit our (get) login page.

element and by are also globals created for us by Protractor. We can use them to find and interact with elements on the page.

expect is again from Jasmine (and extended by Protractor), we test whether our expected error message is displayed.

It is important to note that Protractor is entirely asynchronous, so all API methods return promises. Under the hood, Protractor uses Selenium's control flow (a queue of pending promises) to allow us to write tests in a pseudo-synchronous way. Protractor is smart enough to make it all work.

Elements and Locators

As you can imagine, a large part of writing a test against a web site deals with finding and locating DOM elements on a page and executing actions such as clicking on them. As explained above, Protractor offers globally available constructs that help us here: element, and by.

A call to element requires a Locator and returns an ElementFinder โ€“ it is used to find the first (or only) element that is matched by the Locator. Similarly, there is also element.all if you want to find more than one element.

A Locator can be created by using the functions available with by. There are methods that return Locators like by.css, by.id or by.binding that pretty much do what you'd expect. It is even possible to create your own Locators and attaching them to by during the start up of Protractor.

element(by.css('input.username'));
element.all(by.css('a.btn'));

An ElementFinder exposes multiple actions, we've already seen sendKeys and click above. We can use these actions to interact with an element, or to find out more about an element, for instance whether it is currently being displayed or not, or what its text value is.

element(by.css('a.home-page')).getAttribute('target');

It is also possible to chain element calls, it is somewhat similar to jQuery in this regard.

element(by.css('div.article')).element(by.tagName('h1'));
element(by.css('ul#main-menu')).all(by.tagName('li'));

Last but not least, element.all exposes some additional functionality, as in getting the first child element, or getting a child element by index, and these calls can also be chained.

element.all(by.css('p')).first().element(by.css('span'));
element.all(by.css('div.article')).get(2).element(by.css('a'));

Organizing your code: Page objects

If we only rely on element calls to structure our tests, our life gets progressively worse as the application grows. We'd have to duplicate a lot of code for components that are used across different pages, or pages that are used across different tests. One change to an element's class name could force us to rewrite many of our tests.

We use a design pattern called page object to overcome this problem. It is described in more detail by Martin Fowler. The gist of it is very simple, we try to encapsulate and wrap most of our Protractor calls in them:

var LoginPage = function() {
  this.username = element(by.model('username'));
  this.password = element(by.model('password'));
  this.loginButton = element(by.id('btn-login'));
  this.passwordRequiredError = element(by.css('error-password-required'));
  this.visit = function() {
    browser.get('http://mysuperawesomepage.com/login');
  };

  this.setUsername = function(username) {
    this.username.clear();
    this.username.sendKeys(username);
  };

  this.setPassword = function(password)
    this.password.clear();
    this.password.sendKeys(password);
  };

  this.login = function() {
    this.loginButton.click();
  };
};
module.exports = LoginPage;

We can now use the LoginPage page object in our tests. You'll probably notice that the test is now much more readable, which is a nice side effect of using page objects:

var LoginPage = require('./login-page');

describe('login page', function() {
  it('should display an error message if the password field is empty', function() {
    var page = new LoginPage();
    page.visit();
    page.setUsername('gandalf');
    page.login();
    expect(page.passwordRequiredError.isDisplayed()).toBe(true);
  });
});

We extend this principle to shared page components, such as headers, footers and AngularJS directives and can then re-use these components:

var HomePage = function() {
  this.header = new Header();
  this.sideBar = new SideBar();
  this.footer = new Footer();
  ...
};
module.exports = HomePage;

Another neat trick: We often unify common methods in a base Page class that other page objects can inherit from:

var Page = function() {
  this.clearAndType = function(element, text) {
  element.clear();
  element.sendKeys(text);
};

//...
};
module.exports = Page;

Conclusion

Protractor allows us to test our AngularJS applications in a consistent and automated way. We're better able to make informed statements about the overall state and soundness of our AngularJS applications because of it.


Tell us what you think