Testing Angular Apps End-to-End with Protractor

When the team started up a new Angular project recently, we knew we wanted end-to-end (E2E) testing from day one. While unit tests are a popular and useful tool for enforcing the way that smaller pieces of a program behave, E2E tests are equally useful yet often neglected. They capture the full experience of using an application and demonstrate that the small pieces fit together coherently; learning to write these tests can dramatically reduce the need for costly manual testing.

Until recently, ngScenario was the recommended tool for E2E tests in Angular apps, but now a new library called Protractor is being promoted. It wraps over WebDriverJS from the Selenium project and runs Jasmine tests. Although it’s a relatively young project at this point, Protractor is already quite usable and has been enjoyable to work with in our new project. Let’s take a look at how easy it is to get started!

NOTE: The information below should be accurate for the latest version as I write this post, 0.14.0; please leave a comment if you have issues.

Setup

First, add Protractor to your project (or globally):

npm install protractor --save-dev

Then use the included webdriver-manager executable to install a standalone Selenium server:

./node_modules/.bin/webdriver-manager update

Next, create the configuration file that Protractor will use to prepare the test suite. There’s a well-documented reference file which I recommend reviewing briefly, but we’re using a very simple configuration to get started:

exports.config = {
  /**
   * Use `seleniumAddress` for faster startup; run `./node_modules/.bin/webdriver-manager start` to launch the Selenium server.
   * Use `seleniumPort` to let Protractor manage its own Selenium server instance (using the server JAR in its default location).
   */
  seleniumAddress: 'http://localhost:4444/wd/hub',
  // seleniumPort: 4444,

  /**
   * Path to your E2E test files, relative to the location of this configuration file.
   * We're pointing to the directory where our CoffeeScript output goes.
   */
  specs: [
    '../.tmp/test/e2e/**/*.js',
  ],

  /**
   * Properties passed to Selenium -- see https://code.google.com/p/selenium/wiki/DesiredCapabilities for more info.
   */
  capabilities: {
    'browserName': 'chrome'
  },

  /**
   * This should point to your running app instance, for relative path resolution in tests.
   */
  baseUrl: 'http://localhost:9000'
};

If you’re planning on running your E2E tests like full integration tests, targeting a real backend API, then you’re done with setup! Otherwise, include the angular-mocks.js script in your index page to help build a fake backend with the ngMockE2E module (discussed later).

Basic testing

If you haven’t written tests using Jasmine before, now would be a good time to read up on it.

To start out, we’ll make sure we can log into our app correctly, starting with stubbed tests:

describe('Authentication capabilities', function() {
  var fail = function() { expect(true).toBe(false); }

  it('should redirect to the login page if trying to load protected page while not authenticated', fail);
  it('should warn on missing/malformed credentials', fail);
  it('should accept a valid email address and password', fail);
  it('should return to the login page after logout', fail);
});

So far, this is just a plain (and thoroughly failing) Jasmine suite. As we fill in the stubbed specs, we’ll encounter some of the features that Protractor offers us:

describe('Authentication capabilities', function() {
  var loginURL;
  var email = element(by.name('login-email'));
  var password = element(by.name('login-password));
  var loginButton = element(by.xpath('//form[1]/input[@type="submit"]'));
  var error = element(by.model('loginError'));

  it('should redirect to the login page if trying to load protected page while not authenticated', function() {
    browser.get('/#/login');
    loginURL = browser.getCurrentUrl();

    browser.get('/#/');
    expect(browser.getCurrentUrl()).toEqual(loginURL);
  });

  it('should warn on missing/malformed credentials', function() {
    email.clear();
    password.clear();

    password.sendKeys('test');
    loginButton.click();
    expect(error.getText()).toMatch('missing email');

    email.sendKeys('test');
    loginButton.click();
    expect(error.getText()).toMatch('invalid email');

    email.sendKeys('@example.com');
    password.clear();
    loginButton.click();
    expect(error.getText()).toMatch('missing password');
  });

  it('should accept a valid email address and password', function() {
    email.clear();
    password.clear();

    email.sendKeys('test@example.com');
    password.sendKeys('test');
    loginButton.click();
    expect(browser.getCurrentUrl()).not.toEqual(loginURL);
  });

  it('should return to the login page after logout', function() {
    var logoutButton = $('a.logout');
    logoutButton.click();
    expect(browser.getCurrentUrl()).toEqual(loginURL);
  });
});

The bread and butter of these tests are the interactive elements of the page such as links, buttons, and input fields. These can be selected using the element global (or element.all for collections) in conjunction with a locator strategy. The by global not only exposes the basic locators from WebDriver like by.id and by.tagName but also provides shortcuts (like $('.foo') for element(by.css('.foo')) and, more importantly, new locators for Angular-specific concepts. Matching elements on model bindings and selections from repeaters often makes much more sense than using complex XPath or CSS expressions. The tests above demonstrate how to do some basic manipulation of elements such as clicking and entering text. Also worth mentioning are the browser global, used for navigation and high-level page information, and the protractor namespace.

To run the test suite, run ./node_modules/.bin/protractor path/to/configuration.js (or simply protractor path/to/configuration.js if you installed globally). Be sure to double-check that your environment matches the expectations of your configuration file (a running app instance, tests in the correct location, running Selenium server, etc.). For debugging, simply insert a call to browser.debugger() in your test and invoke Protractor as ./node_modules/.bin/protractor debug path/to/configuration.js. A plain debugger statement isn’t as effective in this case because of the way Protractor relies heavily on promises in its control flow; calling the debugger through the global browser reference also allows Protractor to attach some utility functions to the browser’s console.

Beyond the basics

The above test suite actually fails for our project because we don’t have an environment where test@example.com is a valid user. Instead of a dedicated test environment, we can mock our backend API to behave in a realistic way:

describe('Authentication capabilities', function() {

  var httpBackendMock = function() {
    angular.module('httpBackendMock', ['ngMockE2E', 'myApp'])
      .run(function($httpBackend) {
        var authenticated = false;
        var testAccount = {
          email: 'test@example.com'
        };

        $httpBackend.whenGET('/api/auth').respond(function(method, url, data, headers) {
          return authenticated ? [200, testAccount, {}] : [401, {}, {}];
        });

        $httpBackend.whenPOST('/api/auth').respond(function(method, url, data, headers) {
          authenticated = true;
          return [200, testAccount, {}];
        });

        $httpBackend.whenDELETE('/api/auth').respond(function(method, url, data, headers) {
          authenticated = false;
          return [204, {}, {}];
        });

        $httpBackend.whenGET(/.*/).passThrough();
      })
  };

  ptor = protractor.getInstance();
  ptor.addMockModule('httpBackendMock', httpBackendMock);

  // specs snipped
});

Our mocked backend intercepts the desired calls and returns reasonable dummy responses, with a fall-through case for anything we don’t specifically want to mock (such as static assets). Although it can take a little work to wrangle the dummy data, a mock backend is speedy and well-isolated, which makes it great for E2E tests without full integration. To clean things up a bit, we can factor the mocked backend into a module and simply require it into each test file.

Our earlier tests were also pretty verbose and harder to read because there was a lot of interaction with the low-level API. By creating page objects, we can cut down on repetition and make our specs easier to understand at a glance. I’m going to switch into CoffeeScript because it’s what we actually use and it reads even better:

# Page object
class LoginPage
  constructor: ->
    @email = element `by`.name 'login-email'
    @password = element `by`.name 'login-password'
    @loginButton = element `by`.xpath '//form[1]/input[@type="submit"]'
    @error = element `by`.model 'loginError'

  get: ->
    browser.get '/#/login'
    browser.getCurrentUrl()

  setEmail: (text) ->
    @email.clear()
    @email.sendKeys text
    @

  clearEmail: ->
    @email.clear()
    @

  setPassword: (text) ->
    @password.clear()
    @password.sendKeys text
    @

  clearPassword: ->
    @password.clear()
    @

  login: ->
    @loginButton.click()
    @

  getErrorText: ->
    @error.getText()

With the more descriptive methods of the page object, you’ll be able to read the test specs almost like normal English! Page objects become more useful as you add more workflows to your E2E tests, since you’ll be loading many of the same pages and touching many of the same elements. As with the mocked backend, you can factor page objects out into a separate module to keep your test files nice and lean. Now our test suite is looking pretty sharp!

Mocks = require './mocks'
Pages = require './pages'

describe 'Authentication capabilities', ->
  ptor = protractor.getInstance()
  ptor.addMockModule 'httpBackendMock', Mocks.httpBackendMock

  page = new Pages.LoginPage()
  loginURL = ''

  it 'should redirect to the login page if trying to load protected page while not authenticated', ->
    loginURL = page.get()

    browser.get '/#/'
    expect(browser.getCurrentUrl()).toEqual(loginURL)

  it 'should warn on missing/malformed credentials', ->
    page
      .clearEmail()
      .setPassword('test')
      .login()
    expect(page.getErrorText()).toMatch('missing email')

    page
      .setEmail('test')
      .setPassword('test')
      .login()
    expect(page.getErrorText()).toMatch('invalid email')

    page
      .clearPassword()
      .setEmail('test@example.com')
      .login()
    expect(page.getErrorText()).toMatch('missing password')

  it 'should accept a valid email address and password', ->
    page
      .setEmail('test@example.com')
      .setPassword('test')
      .login()
    expect(browser.getCurrentUrl()).not.toEqual(loginURL)

  it 'should return to the login page after logout', ->
    $('a.logout').click()
    expect(browser.getCurrentUrl()).toEqual(loginURL)

Lastly, if you’re using Grunt for automation, give grunt-protractor-runner a try.

You should now be well equipped to start tackling end-to-end testing in your own Angular apps! Be sure to check out Protractor’s documentation to explore even more of the functionality it offers. Leave a comment and let us know how your efforts turn out. Have fun with testing!

comments powered by Disqus