Testing AngularJS Part 1 - Karma, Jasmine, and ngMock

This is part of a series of posts on testing. If you haven't yet and you're new to testing in JavaScript, please read and follow along with the previous posts before moving on:

  1. Testing Basics
  2. Testing JavaScript with Jasmine
  3. Karma

It also assumes you're a relatively experienced student at V School and have already spent a considerable amount of time learning and working in AngularJS. No time will be spent explaining the basic AngularJS code in this post.


Introduction

Due to the complexities that come with AngularJS -- its layers of dependency injection, its digest cycle, its different components that make it an "MV*" framework, and so forth -- testing AngularJS requires a bit more than simply specifying all your .js files in karma.conf.js. We need a way to specify which Angular modules we're testing, inject our Angular components, etc.

Enter ngMock.

ngMock is an Angular module created by the Angular team -- similar to how ngRoute is a separate Angular module -- whose purpose is specifically to accomplish the task of testing AngularJS code!

Setup

We're going to continue with the idea of a calculator, but this time we'll make a very simple web app to add some numbers using inputs on an HTML page and an Angular controller to do the math and output the results

Project setup
  1. Create a new folder called ng-calc somewhere logical on your computer and cd to it in a terminal window.
  2. Run npm init -y to quickly make a package.json file
  3. Run npm install -g bower
    1. For purposes of learning something new, we'll use the frontend package manager Bower to get AngularJS and Bootstrap this time. If you know you already have bower installed, you can skip this step.
  4. Run bower init to create a bower.json file. This is the same idea as what you do with npm init to create package.json. Answer the questions/fill out the form, or just hit enter through them all to accept the defaults.
  5. Run bower install --save angular bootstrap
  6. Create 2 new files, index.html and app.js, in the project root folder and add the following code:
<!-- index.html -->

<!DOCTYPE html>
<html lang="en" ng-app="Calculator">

<head>
    <meta charset="UTF-8">
    <title>ngCalculator</title>
    <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.css">
</head>

<body ng-controller="CalcController">

    <div class="container">
        <div class="row">
            <div class="col-md-2">
                <div class="form-group">
                    <label>First Number: </label>
                    <input type="number" class="form-control" ng-model="first">
                </div>
                <br>
                <div class="form-group">
                    <label>Second Number: </label>
                    <input type="number" class="form-control" ng-model="second">
                </div>
                <br>
                <button class="btn btn-lg btn-success" ng-click="sum()">+</button>
                <h1>{{ result }}</h1>
            </div>
        </div>
    </div>

    <script src="bower_components/angular/angular.js"></script>
    <script src="app.js"></script>

</body>

</html>
//  app.js

var app = angular.module("Calculator", []);

app.controller("CalcController", ["$scope", function ($scope) {
    $scope.result = 0;

    $scope.sum = function () {
        $scope.result = $scope.first + $scope.second;
    };
}]);

Take a couple of minutes to look through that code and make sure everything makes sense. Then open the app in a browser and make sure everything is working as it should. (You can put a number in each box, click the "+" button, and the sum of the two numbers should appear below.) If you've done some Angular coding before, nothing here should be new to you.

Set up for testing

We need to set up Karma and Jasmine for testing like we have in the past, and we also need to include Angular-Mocks so we can test AngularJS code.

  1. In terminal, run bower install --save angular-mocks
  2. Run npm install --save-dev karma
  3. Add a folder called tests to the project root.
  4. In terminal, run karma init
    1. Select "Jasmine" as the framework (should be the first one to show up).
    1. Choose "no" for using Require.js.
    1. Switch the browser from "Chrome" to "PhantomJS" by hitting the tab key until "PhantomJS" appears, then hit enter twice.
    1. Enter bower_components/angular/angular.min.js, bower_components/angular-mocks/angular-mocks.js, app.js and tests/*.test.js, for the source and test files, hitting enter between each entry.
    1. Hit enter to skip the exlusions.
    1. Hit enter to choose "yes" to having Karma watch all the files and re-run the tests on changes.
  5. Open package.json and change the test script to be "test": "./node_modules/karma/bin/karma start karma.conf.js".

After all these steps, we should be ready to write tests for our AngularJS application!


Using ngMock

Angular-Mocks (A.K.A. ngMock) gives us an API to let us pull in our Angular Modules and inject Angular controllers, services, directives, and so forth so that we can actually test them.

Setup
  1. Under the tests folder, create a new file called calcController.test.js.
  2. Write the basic describe block:
describe("calculator", function () {
    
});
beforeEach

Jasmine comes with a method that allows you to keep your tests DRY (which stands for Don't Repeat Yourself) called beforeEach. This method will run any code you put inside it before each test spec (it() block). Since we may be adding a number of tests to this describe() block, we should use the beforeEach() method to both grab our Calculator Angular Module and inject the Angular controller CalcController we created in app.js.

Inside your describe() block, add the following code:

describe("calculator", function () {
    
    beforeEach(module("Calculator"));
    
});

This sets the module for this test to be the "Calculator" module, so that when we try to grab controllers, services, etc., we're grabbing them from the right Angular module.

There is also an afterEach() method available from Jasmine in case you ever need to break something back down before any further tests proceed. See the Jasmine docs for more information on both beforeEach() and afterEach()

Our job

We've been spoiled a little bit by everything AngularJS automatically does under the hood for us so that we don't have to. We take for granted that Angular will instantiate all the Controllers, Directives, Services, Factories, etc. for us when the scripts are read into the browser's JavaScript engine.

When we're testing, however, we are missing some of the luxuries that come with Angular automatically doing things for us. In order to prepare our tests to work correctly, we need to manually take a few steps to bring the Controllers, Directives, etc. into our test so we can use them.

Inject the correct controller and scope

ngMock comes with a service called $controller which is responsible for creating new and retrieving existing Angular controllers. (Angular calls this service automatically every time it finds app.controller(...) in its code in order to create a new controller).

There is also a service called $rootScope which is actually the parent to all $scope objects (also sometimes referred to as $childScopes. It also gives us the ability to instantiate new $scope objects with its .$new() method.

Why is this important? We need to inject the controller we want to test, but we need to use the $controller service to grab it first, and $rootScope to create a new scope object to pass to the controller so it knows what you mean when the code says $scope.add(), etc. We've become used to simply injecting the $scope service into the controller's function, but now we have to take these extra steps so our controller runs correctly during the test.

Notice at the top of the describe() block we add the var scope; line. This is so we can reference it inside the beforeEach block, but then use it inside our it() block without it disappearing due to regular JavaScript scoping issues.

describe("calculator", function () {
    
    // Declare this variable now so we can use it inside the beforeEach() block below.
    var scope;  // You can call this $scope if you want. I named it plain old "scope" so you can see where it gets used below.
    
    beforeEach(module("Calculator"));
    
    // Inject Angular's $rootScope and $controller services so we can use them in this beforeEach method.
    beforeEach(inject(function($rootScope, $controller) {
        
        // Instantiate a new scope object so we can use it in the next line...
        scope = $rootScope.$new();
        
        // Get the existing "CalcController" from the "Calculator" module and set the scope to be that controller's $scope object.
        $controller("CalcController", {$scope: scope});
    }));
});

Now we have a reference to our module and a reference to our controller with the correct $scope attached to it and everything! The last part is the easiest:

describe("calculator", function () {
    
    var $scope;
    
    beforeEach(module("Calculator"));
    
    beforeEach(inject(function($rootScope, $controller) {
        $scope = $rootScope.$new();
        $controller("CalcController", {$scope: $scope});
    }));
    
    it("1 + 2 should = 3", function () {
        // Imitate entering numbers into the <input> fields
        $scope.first = 1;
        $scope.second = 2;
        
        // Call the $scope.sum() function, imitating the ng-click on the <button> element
        $scope.sum();
        
        // Check that the result is correct.
        expect($scope.result).toBe(3);
    });
});

And there you have it! You now can do any refactoring to this set of functionality, add other methods (like subtraction, multiplication, division, etc.) and write new tests to make sure they're all working the way they're supposed to. You now have an insurance policy against mistakes made while changing code!


Conclusion

The setup for testing our Angular code is a little tricky at first, but once you become accustomed to the process, you can easily write tests for each component of your AngularJS application and create a nice safety net against cowboy coding that can ruin other parts of your application.