Michael Herman

Software Developer

Testing AngularJS With Protractor and Karma - Part 2

This article details how to test a simple AngularJS application using unit tests and end-to-end (E2E) tests.

angular + protractor


  • Part 1 - In the first part we’ll look at unit tests, which ensure that small, isolated pieces of code (e.g., a unit) behave as expected.
  • Part 2 - In part two we’ll address E2E tests, which verify that all the pieces of code (units) fit together by simulating the user experience through browser automation. (current)

Updates: December 3rd, 2016 - bumped dependencies

Having finished up unit testing, let’s now turn our attention to e2e testing using Protractor, which is a testing framework built specifically for AngularJS apps. Essentially, it runs tests against an app in the browser via Selenium Webdriver, interacting with the app from an end user’s perspective.

protractor components


Since e2e tests are much more expensive than unit tests - e.g., they generally take more time to run and are harder to write and maintain - you should almost always focus the majority of your testing efforts on unit tests. It’s good to follow the 80/20 rule - 80% of your tests are unit tests, while 20% are e2e tests. That said, this tutorial series breaks this rule since the goal is to educate. Keep this in mind as you write your own tests against your own application.

Also, make sure you test the most important aspects/functions of your application with your e2e tests. Don’t waste time on the trivial. Again, they are expensive, so make each one count.

The repo includes the following tags:

  1. v1 - project boilerplate
  2. v2 - adds testing boilerplate/configuration
  3. v3 - adds unit tests
  4. v4 - adds E2E tests

Project Setup

Assuming you followed the first part of this tutorial, checkout the third tag, v3, and then run the current test suite starting with the unit tests:

1
2
3
4
5
6
7
8
9
10
$ git checkout tags/v3
$ gulp unit

[23:30:01] Using gulpfile ~/angular-testing-tutorial/Gulpfile.js
[23:30:01] Starting 'unit'...
INFO [karma]: Karma v0.12.31 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 42.0.2311 (Mac OS X 10.10.2)]: Connected on socket i04LmGbgt7P1lNIUTgIJ with id 48442826
Chrome 42.0.2311 (Mac OS X 10.10.2): Executed 12 of 12 SUCCESS (0.236 secs / 0.051 secs)
[23:30:06] Finished 'unit' after 4.43 s

For the e2e tests, you’ll need to open two new terminal windows. In the first new window, run webdriver-manager start. In the second, navigate to your project directory and then run the app - gulp.

Finally, back in the original window, run the tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ gulp e2e

[23:31:11] Using gulpfile ~/angular-testing-tutorial/Gulpfile.js
[23:31:11] Starting 'e2e'...
Using the selenium server at http://localhost:4444/wd/hub
[launcher] Running 1 instances of WebDriver
.

Finished in 1.174 seconds
1 test, 1 assertion, 0 failures

[launcher] 0 instance(s) of WebDriver still running
[launcher] chrome #1 passed

Everything look good?

The Tests

Open the test spec, spec.js, within the “tests/e2e” directory. Let’s look at the first test:

1
2
3
4
5
6
7
8
describe('myController', function () {

  it('the dom initially has a greeting', function () {
    browser.get('http://localhost:8888/#/one');
    expect(element(by.id('greeting')).getText()).toEqual('Hello, World!');
  });

});

Notice how we’re still using Mocha and Chai to manage/structure the test so that it simply opens http://localhost:8888/#/one and then asserts that the text within the HTML element with an ID of greeting is Hello, World!. Simple, right?

Let’s take a quick look at the Angular services that we’re using:

  1. browser - loads the page in the browser
  2. element - interacts with the page
  3. by - finds elements within the page

Finally, one important thing to note is how these tests run. Notice that there’s no callbacks and/or promises in the test. How does that work with asynchronous code? Simple: Protractor continues to check each assertion until it passes or a certain amount of time passes. There also is a promise attached to most methods that can be access using then.

With that, let’s write some tests on our own.

TestOneController

Just like in the first part, open the controller code:

1
2
3
4
5
6
7
8
9
myApp.controller('TestOneController', function($scope) {
  $scope.greeting = "Hello, World!";
  $scope.newText = undefined;
  $scope.changeGreeting = function() {
    if ($scope.newText !== undefined) {
      $scope.greeting = $scope.newText;
    }
  };
});

How about the HTML?

1
2
3
4
<h2>Say something</h2>
<input type="text" ng-model="newText">
<button class="btn btn-default" ng-click="changeGreeting()">Change!</button>
<p id="greeting">{{ greeting }}</p>

Looking at the Angular code along with the HTML, we know that on the button click, greeting is updated with the user supplied text from the input box. Sound right? Test this out: With the app running via Gulp, navigate to http://localhost:8888/#/one and manually test the app to ensure that the controller is working as it should.

Now since we already tested the initial state of greeting, let’s write the test to ensure that the state updates on the button click:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('TestOneController', function () {

  beforeEach(function() {
    browser.get('http://localhost:8888/#/one');
  });

  it('initially has a greeting', function () {
    expect(element(by.id('greeting')).getText()).toEqual('Hello, World!');
  });

  it('clicking the button changes the greeting if text is inputed', function () {
    element(by.css('[ng-model="newText"]')).sendKeys('Hi!');
    element(by.css('.btn-default')).click();
    expect(element(by.id('greeting')).getText()).toEqual('Hi!');
  });

  it('clicking the button does not change the greeting if text is not inputed', function () {
    element(by.css('.btn-default')).click();
    expect(element(by.id('greeting')).getText()).toEqual('Hello, World!');
  });

});

So, in both new test cases we’re targeting the input form - via the global element function - and adding text to it with the sendKeys() method - Hi! in the first test and no text in the second. Then after clicking the button, we’re asserting that the text contained within the HTML element with an id of “greeting” is as expected.

Run the tests. If all went well, you should see:

1
2
3
4
5
6
7
8
9
10
11
12
[06:15:45] Using gulpfile ~/angular-testing-tutorial/Gulpfile.js
[06:15:45] Starting 'e2e'...
Using the selenium server at http://localhost:4444/wd/hub
[launcher] Running 1 instances of WebDriver
...

Finished in 3.606 seconds
3 tests, 3 assertions, 0 failures

[launcher] 0 instance(s) of WebDriver still running
[launcher] chrome #1 passed
Michaels-MacBook-Pro-3:angular-testing-tutorial michael$

Did you see Chrome open in a new window and run the tests, then close itself? It’s super fast!! Want to run the tests in Firefox (or a different browser) as well? Simply update the Protractor config file, protractor.conf.js, like so:

1
2
3
4
5
6
7
8
9
exports.config = {
  seleniumAddress: 'http://localhost:4444/wd/hub',
  specs: ['tests/e2e/*.js'],
  multiCapabilities: [{
    browserName: 'firefox'
  }, {
    browserName: 'chrome'
  }]
};

Test it again. You should now see the tests run in both Chrome and Firefox simultaneously. Nice.

Finally, to simplify the code and speed up the tests (so we only search the DOM once per element), we can assign each element to a variable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
describe('TestOneController', function () {

  var greeting = element(by.id('greeting'));
  var textInputBox = element(by.css('[ng-model="newText"]'));
  var changeGreetingButton = element(by.css('.btn-default'));

  beforeEach(function() {
    browser.get('http://localhost:8888/#/one');
  });

  it('initially has a greeting', function () {
    expect(greeting.getText()).toEqual('Hello, World!');
  });

  it('clicking the button changes the greeting if text is inputed', function () {
    textInputBox.sendKeys('Hi!');
    changeGreetingButton.click();
    expect(greeting.getText()).toEqual('Hi!');
  });

  it('clicking the button does not change the greeting if text is not inputed', function () {
    textInputBox.sendKeys('');
    changeGreetingButton.click();
    expect(greeting.getText()).toEqual('Hello, World!');
  });

});

Test one last time to ensure that this refactor didn’t break anything.

TestTwoController

Again, start with the code.

Angular:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
myApp.controller('TestTwoController', function($scope) {
  $scope.total = 6;
  $scope.newItem = undefined;
  $scope.items = [1, 2, 3];
  $scope.add = function () {
    if(typeof $scope.newItem == 'number') {
      $scope.items.push($scope.newItem);
      $scope.total = 0;
      for(var i = 0; i < $scope.items.length; i++){
        $scope.total += parseInt($scope.items[i]);
      }
    }
  };
});

HTML:

1
2
3
4
<h2>Add values</h2>
<input type="number" ng-model="newItem">
<button class="btn btn-default" ng-click="add()">Add!</button>
<p>{{ total }}</p>

Then test it in the browser.

Like last time, we simply need to ensure that total is updated appropriately when the end user submits a number in the input box and then clicks the button.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
describe('TestTwoController', function () {

  var total = element(by.tagName('p'));
  var numberInputBox = element(by.css('[ng-model="newItem"]'));
  var changeTotalButton = element(by.css('.btn-default'));

  beforeEach(function() {
    browser.get('http://localhost:8888/#/two');
  });

  it('initially has a total', function () {
    expect(total.getText()).toEqual('6');
  });

  it('updates the `total` when a value is added', function () {
    numberInputBox.sendKeys(7);
    changeTotalButton.click();
    numberInputBox.clear();
    expect(total.getText()).toEqual('13');
    numberInputBox.sendKeys(7);
    changeTotalButton.click();
    expect(total.getText()).toEqual('20');
    numberInputBox.clear();
    numberInputBox.sendKeys(-700);
    changeTotalButton.click();
    expect(total.getText()).toEqual('-680');
  });

  it('does not update the `total` when an empty value is added', function () {
    numberInputBox.sendKeys('');
    changeTotalButton.click();
    expect(total.getText()).toEqual('6');
    numberInputBox.sendKeys('hi!');
    changeTotalButton.click();
    expect(total.getText()).toEqual('6');
  });

});

Run the tests and you should see:

1
6 tests, 9 assertions, 0 failures

Moving along…

TestThreeController

You know the drill:

  1. Look at the Angular and HTML code
  2. Manually test in the browser
  3. Write the e2e test to automate the manual test

Try this on your own before looking at the code below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
describe('TestThreeController', function () {

  var modalNumber = element.all(by.tagName('span')).get(1);
  var modalButton = element(by.tagName('button'));
  var iterateButton = element(by.css('[ng-click="changeModalText()"]'));
  var hideButton = element(by.css('[ng-click="$hide()"]'));
  var justSomeText = element(by.tagName('h2'));

  beforeEach(function() {
    browser.get('http://localhost:8888/#/three');
  });

  it('initially has a modalNumber', function () {
    modalButton.click();
    expect(modalNumber.getText()).toEqual('1');
  });

  it('updates the `modalNumber` when a value is added', function () {
    modalButton.click();
    iterateButton.click();
    expect(modalNumber.getText()).toEqual('2');
    iterateButton.click().click().click();
    expect(modalNumber.getText()).toEqual('5');
    hideButton.click();
    expect(justSomeText.getText()).toEqual('Just a modal');
  });

TestFourController

Since this controller makes an external call to https://api.github.com/repositories you can either mock out (fake) this request using ngMockE2E, like we did for the unit test, or you can actually make the API call. Again, this depends on how expensive the call is and how important the functionality is to your application. In most cases, it’s better to actually make the call since e2e tests should mimic the actual end user experience as much as possible. Plus, unlike unit tests which test implementation, these tests test user behavior, across several independent units - thus, these tests should not be isolated and can rely on making actual API calls either to the back-end or externally.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
describe('TestFourController', function () {

  var loadButton = element(by.tagName('button'));
  var ul = element.all(by.tagName('ul'));
  var li = element.all(by.tagName('li'));

  beforeEach(function() {
    browser.get('http://localhost:8888/#/four');
  });

  it('updates the DOM when the button is clicked', function () {
    expect(ul.count()).toEqual(1);
    expect(li.count()).toEqual(5);
    loadButton.click();
    expect(ul.count()).toEqual(101);
    expect(li.count()).toEqual(105);
  });

});

Here, when the button is clicked, the API call is made and the scope is updated. We then assert that there are 101 UL tags and 105 LI tags, representing a Github username and repo returned from the API call, present on the DOM.

That’s it!

Conclusion

Want more?

  1. Take a look at the Page Objects design pattern and refactor the tests so that they are better organized.
  2. Break a test, and then pause the test before the break via browser.pause() and/or browser.debugger() to debug.
  3. Test your own Angular app, and then add a link to the comments to get feedback.

Be sure to check the Protractor documentation for more. Thanks again for reading, and happy testing!



Interested in learning how to test an Angular + Django app? Check out Real Python for details.

Comments