This is part 3 of our series. You can find part 1 here and part 2 here.
This post’s topic is unit testing. We’ll be using Mocha, Chai, and Sinon.JS. Everything will be run with Karma on PhantomJS, so we will be able to run this easily on a CI server such as Travis or Jenkins.
Step 1: Set up Karma
Karma is pretty straight-forward, there’s a short wizard to step through.
$ npm install -g karma-cli
$ karma init
Be sure to select mocha, yes, PhantomJS, and enter a glob pattern matching
your build directory. I strongly recommend having your test files live
next to the files that they are testing, for wonky-typescript-compilation
reasons explained below. If you’re using our gulpfile from part 1, this
should be set to src/js/**/*.js
, as karma is run from the root of our
project directory. Let karma set you up with a default RequireJS
configuration file. We’ll have to tweak it, but it’s easier to tweak when
you’re starting from somewhere.
Once you have a karma.conf.js
file and a main-test.js
file, we’ll have
to install the plugins required to get everything going.
$ npm install --save-dev karma-mocha karma-chai karma-sinon karma-phantomjs-launcher karma-requirejs karma-chai-sinon
If you have NPM v3 or higher installed (which I do recommend), you may
have to install the peerDependencies
manually:
$ npm install --save-dev mocha chai sinon sinon-chai
We do have to go in and modify the generated files. First, we have to add
some frameworks to our array in karma.conf.js
module.exports = function(config) {
config.set({
//...
frameworks = [
'mocha',
'requirejs',
'chai',
'sinon',
'chai-sinon'
],
//...
Then, we have to modify our main-test.js
so RequireJS knows where to
find our application dependencies.
require.config({
//...
paths: {
"angular": "./dist/bower_components/angular/angular",
"angular-mocks": "./dist/bower_components/angular-mocks/angular-mocks",
"angular.ui.router": "./dist/bower_components/angular-ui-router/release/angular-ui-router",
"angular.ui.bootstrap": "./dist/bower_components/angular-bootstrap/ui-bootstrap",
"chai": "./node_modules/chai/chai",
"sinon": "./node_modules/sinon/pkg/sinon"
},
shim: {
"angular": {
"exports": "angular"
},
"angular-mocks": ["angular"],
"angular.ui.router": ["angular"],
"angular.ui.bootstrap": ["angular"]
},
//...
});
Finally, we’ll have to install the Angular mock library, as well as its typings, and typings Mocha, Chai, and Sinon.
$ bower install angular-mocks
$ tsd install mocha chai sinon sinon-chai --save
Create Your First Test
As I said above, our tests will live next to our regular files. This makes
the compilation logic much easier. We’ll follow a particular pattern, so
that it would be easy to filter out our spec files when bundling. All
files will have a .spec.ts
extension. We’re going to get right into the
complicated stuff, so follow along below.
Given the following service:
import angular = require("angular");
import {app} from "../app.module";
export interface ITestService {
getPost(id: number): angular.IHttpPromise<IJsonPlaceholderPost>;
}
export interface IJsonPlaceholderPost {
userId: number;
title: string;
body: string;
}
export class TestService {
static $inject = ["$http"];
public message: string;
private $http: angular.IHttpService;
constructor($http: angular.IHttpService) {
this.$http = $http;
}
public getPost(id: number): angular.IHttpPromise<IJsonPlaceholderPost> {
return this.$http.get(`http://jsonplaceholder.typicode.com/posts/${id}`);
}
}
app.service("TestService", TestService);
We can test it like so:
/// <amd-dependency path="angular-mocks" />
/// <amd-dependency path="./testService" />
import {expect} from "chai";
import {spy} from "sinon";
import {mock, IHttpBackendService, IScope, auto} from "angular";
import {TestService} from "./testService";
// Describe blocks are the starting point of mocha testing
describe("TestService", () => {
// Have a reference to save our service for testing later.
var service: TestService;
// This runs before each child block of our current block.
beforeEach(() => {
// tell angular to mock our entire application
mock.module("app");
// and retrive the injector to retrive anything we need.
mock.inject(($injector: auto.IInjectorService) => {
service = $injector.get<TestService>("TestService");
});
});
it("should be a TestService", () => {
// Always good to make sure we're testing the right class.
expect(service).to.be.an.instanceOf(TestService);
});
// complications below!
// Our beforeEach block above runs once, then runs this entire block
describe("#getPost", () => {
// saving for later
var $httpBackend: IHttpBackendService;
var $rootScope: IScope;
beforeEach(() => {
mock.inject(($injector: auto.IInjectorService) => {
// We need to $apply things for promises to resolve in angular
$rootScope = $injector.get<IScope>("$rootScope");
// and set up a mock response for hitting our mocked backend
$httpBackend = $injector.get<IHttpBackendService>("$httpBackend");
$httpBackend.expect(
"GET",
// with regex
/http:\/\/jsonplaceholder.typicode.com\/posts\/(\d+)/,
undefined,
undefined
)
.respond((method: string, url: string, data: any, headers: any) => {
console.log(url);
return "Hello";
});
});
});
it("should call jsonplaceholder", () => {
var randomPost = Math.floor(Math.random() * Number.MAX_VALUE);
// Because we can't used chai-as-promised, spying is a good way
// to see the result of a promise
var httpSpy = spy();
service.getPost(randomPost).then(httpSpy);
// Flush the requests out
$httpBackend.flush();
// Apply to resolve promises
$rootScope.$apply();
// Check any expectations.
expect(httpSpy).to.be.called;
// We can test what the promise was called with by using the
// calledWith assertion as well.
});
});
});
The above is a good basis to get started. It shows you the
angular-specific things that you need to get started, as well as some of
the gaps that can confuse beginners, such as the fact that promises are
tied to Angular’s digest cycle, and so an $apply
call must be made
before they will resolve.
To test a controller, we can request the $controller
service from the
injector, and request our controller by name. Because of this, if your
application is entirely made up of directives, it is still a smart idea to
register any controllers with an app.controller
call.
Running our Tests
First, to ensure everything works now, run karma start
in your terminal,
and squash any errors you see. Some things to look out for:
-
“No timestamp for $SCRIPT”: This means that, for whatever reason, Karma hasn’t loaded a particular file into the test context. Check your
files
orframeworks
key in yourkarma.conf.js
to make sure everything is getting loaded. -
“Error: No provider for “framework:$FRAMEWORK”!: You have a framework listed that isn’t installed. NPM that up!
-
Module timeouts: Something somewhere in your code is probably creating a circular dependency, which RequireJS cannot handle. Find that and fix it! If your application is made up of several Angular modules, a good recommendation is to not
require
anything from a different module, and insteadrequire
things in your entry-point for that module.
To add our tests to our gulpfile
, we don’t need any fancy plugins.
var gulp = require('gulp');
var Server = require('karma').Server;
gulp.task('test', function (done) {
new Server({
configFile: __dirname + '/karma.conf.js',
singleRun: true
}, done).start();
});
gulp.task('test:watch', function (done) {
new Server({
configFile: __dirname + '/karma.conf.js'
}, done);
});
Now, you can just have your CI server run gulp test
to test our
application.
That is that! This is the end of the series (finally). I hope you’ve managed to get your application up and running, or maybe you’ve decided to check out Angular 2 instead. I know that’s what I’d be doing now.