Testing AngularJS requires too much boilerplate code. We were so fed up with writing extra lines, looking up the syntax, etc. that we wrote a helper library kensho/ng-describe with a single function. Yet that function can hide all the gory AngularJS details, allowing you to write unit tests in record times. In this tutorial I will show how to start unit testing an AngularJS code, how to get code coverage, and how to test different Angular features using ng-describe library.
The code to go with this tutorial is available at bahmutov/unit-testing-angularjs-tutorial. I advise to clone that repo to a local folder, checkout each tag as you read the tutorial, try running the code and the unit tests.
git clone [email protected]:bahmutov/unit-testing-angularjs-tutorial.git
cd unit-testing-angularjs-tutorial
npm install
Unit testing JavaScript code
step-0
You can find the source for the following section under tag step-0
. If you checked out
the repository, run git checkout step-0
to get to this commit.
Let us start with the simplest example to get a feel for Jasmine framework and Karma test runner. First we need to write the simplest function one can test.
1 | function add(a, b) { |
There are no unit tests yet - we are not sure how the add
function behaves when we pass numbers
or strings or undefined values. We need to unit test the code in add.js
.
Let us create a spec file - in the Behavior Driven Development (BDD for short) testing language
we are going to use, the spec file specifies how the add
function behaves. Typically, I
give the spec file the same name as the source file plus -spec.js
suffix.
1 | describe('add', function () { |
File add-spec.js
describes the add
function, telling us that it
(meaning add
) adds numbers.
When we add number 2 to number 3 we expect to get back number 5.
Where do functions describe
and it
come from? From the Jasmine testing library we are going to use
to execute the unit tests. While we could get a stand alone Jasmine command line tool, a much better
solution is to use Karma test runner. It has Jasmine adapter for executing Jasmine unit tests,
plus can do a lot more. It is easier to just start using it right away.
step-1
In the project folder, I added Karma configuration file. You can create one if you install
Karma globally (npm install -g karma
) and then call karma init
. Here is the file I generated
1 | module.exports = function(config) { |
Then I installed Karma and the necessary plugins (like Jasmine, Chrome launcher) in the project's folder.
npm install --save-dev karma karma-jasmine karma-chrome-launcher
Then I added the karma start
command as the package test script.
Here is the package.json
1 | "scripts": { |
You can now execute npm test
command, which will flash Chrome browser and will report a single
successfully passed unit test.
$ npm test
> [email protected] test /Users/gleb/git/unit-testing-angularjs-tutorial
> karma start
15 08 2015 17:28:06.164:INFO [karma]: Karma v0.13.9 server started at http://localhost:9876/
15 08 2015 17:28:06.169:INFO [launcher]: Starting browser Chrome
15 08 2015 17:28:07.260:INFO [Chrome 44.0.2403 (Mac OS X 10.10.2)]:
Connected on socket aU7IWF2FR43DDxjmAAAA with id 6382992
Chrome 44.0.2403 (Mac OS X 10.10.2): Executed 1 of 1 SUCCESS (0.006 secs / 0.002 secs)
You can see the code and run the test command by using tag step-1
step-2
Before we run each unit test, we might need to set things up. In the simple source example above,
let us verify that the add
is an existing function. In Jasmine (and other BDD testing libraries)
one can perform actions before each unit test by registering a single or multiple callbacks
1 | describe('add', function () { |
Running npm test
should still pass, but if we expected add
to be an object instead of a function
we would get an error
1 | describe('add', function () { |
$ npm test
> [email protected] test /Users/gleb/git/unit-testing-angularjs-tutorial
> karma start
15 08 2015 17:32:28.636:INFO [karma]: Karma v0.13.9 server started at http://localhost:9876/
15 08 2015 17:32:28.641:INFO [launcher]: Starting browser Chrome
15 08 2015 17:32:30.081:INFO [Chrome 44.0.2403 (Mac OS X 10.10.2)]:
Connected on socket tjX2X1rAcWJHTSmJAAAA with id 38618292
Chrome 44.0.2403 (Mac OS X 10.10.2) add adds numbers FAILED
Expected 'function' to equal 'object'.
at Object.<anonymous> (/Users/gleb/git/unit-testing-angularjs-tutorial/add-spec.js:3:24)
Chrome 44.0.2403 (Mac OS X 10.10.2): Executed 1 of 1 (1 FAILED) ERROR (0.009 secs / 0.004 secs)
npm ERR! Test failed. See above for more details.
We will use beforeEach
callbacks to initialize AngularJS state before running unit tests in the
future steps. Note, there is also afterEach
callback, and you can register multiple before
and
after
functions to run.
step-3
It is important for the unit tests to execute most if not every line of the source code. One can check if the unit tests do this by computing code coverage automatically every time the unit tests run. This is very simple to do using another Karma plugin.
npm install --save-dev karma-coverage
Add coverage
preprocessor for the JavaScript files (typically I prefer to only instrument the source files,
not the specs), and add the coverage
reporter to save the results
1 | module.exports = function(config) { |
Run the command npm test
and then open the coverage report page in the browser
open coverage/Chrome<...>/index.html
It will show code coverage information line by line for both add.js
and add-spec.js
.
We can see that we have hit every line of the add.js
source file
If we add some other code to the add.js
, we will see missed lines after executing the same tests
1 | function add(a, b) { |
The coverage shows missed line and missed branch if (false)
.
Code coverage will become invaluable when you want to target the unit tests to the specific areas of the code before refactoring or fixing a bug.
step-4
Let us make a tiny Angular application that will add numbers. The simplest way to use our addition function is to make it a value of an Angular module.
1 | angular.module('Calc', []) |
We now need AngularJS library and if we want to test it, angular-mocks helper library.
npm install --save angular
npm install --save-dev angular-mocks
We should add these two libraries to the Karma configuration file, loading them before our source files.
1 | module.exports = function(config) { |
This tutorial is mainly about using ng-describe to perform AngularJS unit
testing with minimum boilerplate code, but just for fun, let us first write a unit
test using just the angular-mocks
library. Modify the add-spec.js
to load the
Calc
module and then inject add
value.
1 | describe('add', function () { |
Notice the extra lines and calls to first load the module Calc
to be available
during the testing, second to inject the function provided by the value('add', function ...)
We also had to surround normal name add
with underscores in inject(function (_add_) { ...
to avoid clashing with the local variable add
.
This is a common theme when unit testing AngularJS code - things hidden by the framework's
dependency injection and other "magic" code are suddenly exposed and require non-trivial
amount of code to be setup during unit testing. To get even better perspective, let us
write a controller that would use add
function to perform additions.
step-5
I created simple page that adds user-entered two number. The markup:
1 |
|
The code for AddApp
module will be in add-app.js
1 | angular.module('AddApp', ['Calc']) |
The result is simple to see, you can open the index.html
file using any browser locally
open index.html
Enter two numbers and see their sum
We have already unit tested the add
function using angular-mocks
and have encountered
writing a little bit of boilerplate code. Let us unit test the controller AddController
if
it is an easy thing to do using angular-mocks
.
step-6
Let us test if the controller AddController
initializes properties a
, b
and sum
to
zeroes. It must be simple, right?
1 | describe('add controller', function () { |
The unit test looks nothing like the simplet AngularJS application code. The same magic
that works so nicely behind the scenes now requires the developer to work to execute.
We had to use 3 beforeEach
callbacks to set things up before our unit test
- Load the module under test
AddApp
, which is reasonable - Inject both
$controller
and$rootScope
services - something we almost never do in the normal AngularJS code - Create a new scope object using
$rootScope.$new
method and then create the controller instance.
This is a lot of boilerplate code just to get the ball rolling.
step-7
Let us use the kensho/ng-describe to remove all the boilerplate code from the above
unit test. First install the library and add it to the karma.conf.js
before your specs
npm install --save-dev ng-describe
1 | files: [ |
ng-describe
is a library with a minimal api. It is a single function that takes an object of options
ngDescribe({ ... });
That is it. You can think that ngDescribe
replaces describe
function calls used above to perform
Angular-specific initialization before each unit test. For example, to test add
function from
the add-spec.js
change the code to do the following
1 | ngDescribe({ |
Unit test adds numbers
needs function add
provided by the module Calc
. The module loading
and add
injection happen because we specified the module and the list of services to inject
1 | ngDescribe({ |
You can specify multiple modules and multiple values to inject. All injected values will be properties
of the first argument to the special test
callback. In my code I usually name the first argument deps
,
which is short for dependencies
. Thus the unit test just calls deps.add
inside:
1 | ngDescribe({ |
This is how ng-describe
works - it does a lot of Angular-specific work behind the scenes, placing
the result into the deps
argument. The unit tests go inside the test
callback, and have full access
to the deps
object because it encloses them.
step-8
Let us look how much boilerplate ng-describe
removes when testing the controller AddController
.
We already saw that unit testing a controller using angular-mocks
requires a lot of code just
a controller instance. Here is the same unit test switched to ng-describe
1 | ngDescribe({ |
That is it. We tell ng-describe
that we need a controller AddController
and it does the rest behind
the scenes. What is the object deps.AddController
then? In most cases when creating a controller,
we really need the $scope
object. Thus the ng-describe
when creating a controller, places
its created scope into the object passed to the tests. The unit tests then interact with the scope
object
expect(deps.AddController.a).toEqual(0);
// logically same as
expect($scope.a).toEqual(0);
step-9
We have only tested the initial values set inside the controller.
Can we test the summation logic too? If we set the values of a
and b
in the controller's scope,
we should get the property sum
updated too. Here is the second unit test we can add to
the add-controller-spec.js
1 | ngDescribe({ |
The deps.step()
method is equivalent to $rootScope.$digest()
or deps.AddController.$apply();
but removes need to inject the $rootScope
dependency or remember the syntax. It is up to you which one you
prefer.
step-10
We will finish this tutorial with another powerful ng-describe
feature: simple server response mocking.
Sometimes our application has to make requests to the server. For example, imagine that instead of computing
the sum on the client side, we send both numbers a
and b
to a server that performs the computation and
returns the sum.
1 | angular.module('RemoteCalc', []) |
We will change our application code to use RemoteCalc
instead of plain Calc
to provide add
,
and will use the resolved value instead of the returned result
1 | angular.module('AddApp', ['RemoteCalc']) |
If we run the unit tests now, we get an error - an Ajax request!
Chrome 44.0.2403 (Mac OS X 10.10.2) default tests computes the sum FAILED
Error: Unexpected request: GET /add/?a=10&b=20
Of course, our unit test sets values 10 and 20, the controller makes the request right away!
1 | ngDescribe({ |
We need to return 30 in this case, luckily it is simple to do using ngDescribe
. Just add another property
to the ngDescribe
options with values to be returned for each expected HTTP request
1 | ngDescribe({ |
When during the course of the unit test, the controller uses RemoteCalc.add
service, which
calls $http.get
and executes the GET /add/?a=10&b=20
- the mock backend will be ready to
return 30.
step-11
While mocking the responses to the expected http
requests is powerful, I would argue that the unit
test gets way too deep to the implementation details. We are testing AddController
, yet we looked
inside the RemoteCalc
to find that it makes a GET request to the server. The unit test is mocking
an action very far from the code under the test itself. Can we do better?
Yes we can using mocks, which are pieces of fake code to be used instead of the real code during
unit tests. For example, the entire actual RemoteCalc.add
service making the GET http request
can be replaced with a mock for the duration of the unit test. With ng-describe
it is simple
1 | ngDescribe({ |
Using the special property mock
we are setting fake implementation to be injected by the
Angular dependency injection:
1 | mock: { |
Whenever module AddApp
injects service add
, the environment will provide our fake function
1 | function ($q, a, b) { |
Notice that ng-describe
is smart enough to inject other services into the fake code. For example,
because $http.get
returns a promise, we need to return a promise too from the fake code. We do this
by asking for the $q
service and returning $q.when(expression)
. This is every better code than
mocking http
because it will return a correct computed sum a+b
instead of hardcoded 30 for
a single expected request /add/?a=10&b=20
.
Conclusion
I hope this tutorial set you on the path to effectively unit test your AngularJS application code. You can now target a particular code with the tests and write a test with minimum boilerplate.
The ng-describe/README.md has lots of examples showing every supported test feature, including testing directives, spying on services and other boilerplate-prone test cases. I would start by reading the examples. If there is a question, or a particular testing feature missing, please open a Github issue with a detailed example; including the code to be tested and the unit test that is not working.
Additional information
- Unit testing AngularJS - slides for the presentation I have delivered at AngularJS NYC meetup in August 2015
- Testing AngularJS under Node
- Testing Angular async stuff