Learn how to test JavaScript, Node, and AngularJS

Learing TDD (Test-Driven Development) is something I've wanted to do for a while. I got the excuse to learn it so I could present it for a Lunch-and-Learn at Ethode.

I put together a Git repository and this documentation. Note the tags in the repo. I suggest going there and starting with first tag:

https://github.com/flackend/javascript-test-driven-design

This covers unit tests and end-to-end testing, general javascript, node, and AngularJS.

Jasmine (unit testing)

We'll be using Jasmine as our unit test framework.

Install jasmine globally so we can access the jasmine command-line tool:

npm install -g jasmine

Now initialize your project:

jasmine init

This creates the following folder structure:

|-- spec
|   |-- support
|   |   |-- jasmine.json

The config file, jasmine.json, is where you can specify the location of your tests, etc.

Create the first spec at spec/square.spec.js:

var square = require('../src/square.js') ;

describe('square', function() {
    it('should return the quare of the number it is passed', function() {
        expect(square(0)).toBe(0 * 0);
        expect(square(1)).toBe(1 * 1);
        expect(square(5)).toBe(5 * 5);
        expect(square(279217897291798792)).toBe(279217897291798792 * 279217897291798792);
        expect(square(-7)).toBe(-7 * -7);
    });
});

Create src/square.js:

module.exports = function () {};

Now we can run our tests:

jasmine

The tests should fail, which is always the first step of writing TDD.

Now fix square.js:

module.exports = function (num) {
    return num * num;
};

Karma

Setup

To make things easier, we're going to add use karma to run our tests. Go ahead and remove the spec folder, but leave the test folder and square.spec.js.

If you don't have a package.json set up yet, run npm init. Then install Karma:

npm install --save-dev karma karma-jasmine karma-phantomjs-launcher

This gives us Karma, the Karma Jasmine adapter, and the Karma PhantomJS launcher.

To make Karma easier to run, install the command-line utility globally:

npm install -g karma-cli

Now, we need to generate the Karma config file:

karma init

You'll be prompted with a few questions:

Which testing framework do you want to use ?
> jasmine

Do you want to use Require.js ?
> no

Do you want to capture any browsers automatically ?
> PhantomJS

What is the location of your source and test files ?
> src/**/*.js
> spec/**/*[sS]pec.js

Should any of the files included by the previous patterns be excluded ?
>

Do you want Karma to watch all the files and run the tests on change ?
> yes

Handling CommonJS modules with Browserify

Now that were using a browser (PhantomJS) to test, we need a way to resolve our modules. In square.js, we use module.exports and browsers generally don't know how to deal with that. Browserify can solve that issue for us. It compiles all our JS into a single file.

Organization

Our current setup:

|-- src
|   |-- square.js

Organization is up to you. The only requirement for working with Browserify is that you add a file that will be the entry point (main.js in this example) that will require our modules (i.e. square.js).

|-- dist
|-- src
|   |-- js
|   |   |-- main.js
|   |   |   |-- modules
|   |   |   |   |-- general
|   |   |   |   |   |-- square.js

So to follow this example, create the folders, move square.js and create main.js:

window.square = require('./modules/general/square.js');

We're putting square on window so that everything has access to it (including our tests). But this we'll change that when we turn this into an AngularJS project later.

Bundling

If you install browserify globally, npm install -g browserify, you can bundle your javascript manually:

browserify src/js/main.js -o dist/bundle.js

But, a better solution is to use a task runner like Gulp.

Create gulpfile.js (source):

var assign = require('lodash.assign');
var browserify = require('browserify');
var buffer = require('vinyl-buffer');
var gulp = require('gulp');
var gutil = require('gulp-util');
var source = require('vinyl-source-stream');
var sourcemaps = require('gulp-sourcemaps');
var watchify = require('watchify');

// Configure Browserify with custom options
var options = {
    entries: ['./src/js/main.js'],
    debug: true
};
// Merge your custom options with the options that watchify specifies for browserify
var options = assign({}, watchify.args, options);
// Create the browserify instance with watchify
var bundler = watchify(browserify(options));
// Add transformations here
// i.e. bundler.transform(coffeeify);
/**
 * Process all the JS
 */
function bundle() {
    return bundler.bundle()
        // Log errors
        .on('error', gutil.log.bind(gutil, 'Browserify Error'))
        // Source stream into a file named "bundle.js".
        .pipe(source('bundle.js'))
        // Optional, remove if you don't need to buffer file contents.
        .pipe(buffer())
        // Optional, remove if you dont want sourcemaps.
        // Loads map from browserify file.
        .pipe(sourcemaps.init({loadMaps: true}))
        // Add transformation tasks to the pipeline here.

        // Writes .map file
        .pipe(sourcemaps.write('./'))
        .pipe(gulp.dest('./dist'));
}

gulp.task('js', bundle);
bundler.on('update', bundle);
// Output build logs to terminal
bundler.on('log', gutil.log);
gulp.task('default', ['js']);

Before you can run gulp, you need to install it and all the other libaries we're using in our gulpfile.js:

npm install --save-dev lodash.assign browserify vinyl-buffer gulp gulp-util vinyl-source-stream gulp-sourcemaps watchify

Notice that we use --save-dev since we only will be running gulp during development. You may need to adjust the Browserify entry point and output file in your gulpfile.js if you're organizing your project differently.

Now you can run gulp:

gulp

Since we're using watchify in our gulpfile.js, gulp won't exit. Instead it watches for changes to the files it bundled and rebundles as needed. Watchify is a special watcher built to work with Browserify. Instead of rebundling your whole bundle, it only recompiles what has changed. This cuts down on the processing time significantly.

You should have a new bundle.js and bundle.js.map in your dist folder.

The sourcemap (bundle.js.map) is optional. You can remove that part of the gulpfile if you'd like. But maps are very useful for debugging.

There are two more steps before Karma will run correctly. Update the sources in karama.conf.js. Replace src/**/*.js with dist/bundle.js. And lastly, remove the require line from the beginning of spec/square.spec.js.

Running Karama

Run Karma:

karma start

We should have a passing test. Just to make sure it's working correctly, you might change what square.js returns and make sure it breaks. And just like gulp, karma has a watcher and will re-run the tests when it notices you've made a change.

Karama Reporters

The ouput you see on the command line when Karama runs is produced by the "progress" reporter. You can pick a differerent built-in reporter (like "dots"), install a new reporter (look here), or create your own. They don't all output to the command line either. You can get HTML or XML output for instance.

My preferred reporter is the Nyan reporter:

nyan reporter

Testing AngularJS

Let's add AngularJS:

npm install --save angular angualr-route

Update src/js/main.js (remove the square.js require statement):

// Our AngularJS app
require('./modules/app.js');

Note: If I'm going to use Twitter Bootstrap's JS, Semantic UI, etc-- main.js is where I include it.

Remove src/js/modules/general/square.js and add src/js/modules/services/square:

angular.module('square', [])
    .factory('square', function() {
        return function (num) {
            return num * num;
        };
    });

Create src/js/modules/app.js:

/**
 * Require AngularJS
 */
require('angular');
require('angular-route');
// Modules
require('./controllers.js');
require('./services/square.js');

//  _  _ ____     _      _  _
// | \|_|_ | |\ ||_  /\ |_)|_)
// |_/|_| _|_| \||_ /--\|  |
//
module.exports = angular.module('app', [
    'ngRoute',
    'controllers',
    'square'
]).config(['$routeProvider', require('./routes.js')]);

src/js/modules/controllers.js:

angular.module('controllers', [])
    /**
     * Home Controller
     */
    .controller('HomeController', ['$scope', function($scope) {
        $scope.greeting = 'Hello, World';
    }]);

src/js/modules/routes.js:

module.exports = function ($routeProvider) {

    $routeProvider
        // HOME
        .when('/', {
            templateUrl: 'views/home.html',
            controller:  'HomeController'
        })
        // DEFAULT
        .otherwise({
            redirectTo: '/'
        });
};

Now remove test/square.spec.js and add test/services.spec.js:

describe('Services', function() {

    beforeEach(function() {
        // Bring our app module that has all of our services attached to it.
        module('app');
    });

    it('square should return the square of the number it is passed', function() {
        var square;

        inject(function(_square_){
            square = _square_;
        });

        expect(square(0)).toBe(0 * 0);
        expect(square(1)).toBe(1 * 1);
        expect(square(5)).toBe(5 * 5);
        expect(square(279217897291798792)).toBe(279217897291798792 * 279217897291798792);
        expect(square(-7)).toBe(-7 * -7);
    });
});

In order for our updated test to work, we need Angular's ngMock:

npm install --save-dev angular-mocks

Now include it in our karma.conf.js:

// list of files / patterns to load in the browser
files: [
  'dist/bundle.js',
  'node_modules/angular-mocks/angular-mocks.js',
  'test/**/*.js'
],

Note: angular-mocks.js depends on angular.js, so the order here is important. We need to include angular-mocks.js after we include angular.js which is part of bundle.js.

Now test!

karma start

Again, I like to break my test to make sure everything is working and then change it back.

Filter

We tested a service. Let's test a filter test/filters.spec.js:

describe('Filters', function() {

    var $filter;

    beforeEach(function() {
        // Bring our app module that has all of our filters attached to it.
        module('app');
        // This makes filter testing work. :shrug:
        // @link https://docs.angularjs.org/guide/unit-testing
        inject(function(_$filter_){
            $filter = _$filter_;
        });
    });

    it('camelcase filter should transform text', function() {
        var camelcase = $filter('camelcase');
        expect(camelcase('Hello World')).toMatch('helloWorld');
    });
});

Check karma to make sure our test failed.

src/js/modules/filters/camelcase.js:

angular.module('camelcase', [])
    .filter('camelcase', function() {
        return function (text) {
            return text.replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, function (match, index) {
                if (+match === 0) {
                    return "";
                }
                return index === 0 ? match.toLowerCase() : match.toUpperCase();
            });
        };
    });

And update src/js/modules/app.js:

/**
 * Require AngularJS
 */
require('angular');
require('angular-route');
// Modules
require('./controllers.js');
require('./filters/camelcase.js');
require('./services/square.js');

//  _  _ ____     _      _  _
// | \|_|_ | |\ ||_  /\ |_)|_)
// |_/|_| _|_| \||_ /--\|  |
//
module.exports = angular.module('app', [
    'ngRoute',
    'controllers',
    // FILTERS
    'camelcase',
    // SERVICES
    'square'
]).config(['$routeProvider', require('./routes.js')]);

If you noticed I put our new filter before the service that's already there-- I'm just keeping things alphabetical. Just a preference.

Ok, check Karma and our test should be passing now!

End to end testing (e2e) with Protractor

Now we'll set up Protractor so we can create test things that happen on pages.

Organization

Let's change our organization a little:

|-- test
|   |-- unit
|   |   |-- filters.spec.js
|   |   |-- services.spec.js
|   |-- e2e

Now update your karma.conf.js. Replace 'test/**/*.js' with 'test/unit/**/*.js'.

Serve up the app

We need a site to serve up, so dist/index.html:

<!DOCTYPE html>
<html lang="en" ng-app="app">
<head>
    <meta charset="utf-8">
    <title>JS TDD Sample App</title>
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
</head>
<body>
    <div class="container">
        <h1>JS TDD Sample App</h1>
        <div ng-view></div>
    </div>
<script src="bundle.js"></script>
</body>
</html>

And we'll need the view we referenced in our routes file, dist/views/home.html:

<p>{{greeting}}</p>

You can use whatever you want to serve up the app (Nginx, Apache, Node, PHP, etc). I'm using httpster. It's a simple http webserver.

npm install -g httpster
cd dist/
httpster

Now my app is being served up to http://localhost:3333.

Install and configure Protractor

Install Protractor globally so we can use the protrator command-line utility:

npm install -g protractor

The above command also installs the webdriver-manager utility. We need to install the required binaries before it'll work:

webdriver-manager update

We'll need a config file. We have to create it manually, protractor.conf.js:

exports.config = {
    framework: 'jasmine2',
    specs: ['test/e2e/**/*.js'],
    directConnect: true,
    capabilities: {
        browserName: 'chrome',
        shardTestFiles: true,
        maxInstances: 5
    }
};

Using directConnect and chrome runs the tests locally instead of using Selenium Server. And using shardTestFiles and maxInstances allows Protractor to run tests asynchronously (for better performance).

Directive

Create a directive test, test/e2e/directives.spec.js:

describe('Directive', function() {

    describe('btn', function() {

        beforeEach(function() {
            browser.get('/#/test/btn');
        });

        it('element should exist', function() {
            expect($('#btn > button').isPresent()).toBeTruthy();
        });

        it('element should have "btn" and "btn-default" classes', function() {
            expect($('#btn > button.btn.btn-default').isPresent()).toBeTruthy();
        });

        it('element text should be the value from the label attribute', function() {
            expect($('#btn[label="Click me"] > button.btn.btn-default').isPresent()).toBeTruthy();
            expect($('#btn[label="Click me"] > button.btn.btn-default').getText()).toMatch(/^Click me$/);
        });
    });
});

Run protractor:

protractor protractor.conf.js

Our test should fail. Now create the directive, src/js/modules/directives/btn.js:

angular.module('btn', [])
    .directive('btn', function() {
        return {
            scope: {label: '@'},
            template: '<button class="btn btn-default">{{label}}</button>'
        };
    });

Note: Remember to require the new module in src/js/modules/app.js and include it in the app module.

Create a route and a view to test our directive.

src/js/modules/routes.js:

module.exports = function ($routeProvider) {

    $routeProvider
        // HOME
        .when('/', {
            templateUrl: 'views/home.html',
            controller:  'HomeController'
        })
        // TESTS
        .when('/test/btn', {
            templateUrl: 'views/test/btn.html'
        })
        // DEFAULT
        .otherwise({
            redirectTo: '/'
        });
};

dist/views/test/btn.html:

<btn id="btn" label="Click me"></btn>

Now our test should pass:

protractor protractor.conf.js

Resources

Say Hello

Near the Cleveland, Akron or Medina area and want to stop by our office? Let us know and we'll get the coffee and whiteboards ready. :)