Skip to content

Instantly share code, notes, and snippets.

@rikukissa
Last active June 12, 2024 02:39
Show Gist options
  • Save rikukissa/dcb422eb3b464cc184ae to your computer and use it in GitHub Desktop.
Save rikukissa/dcb422eb3b464cc184ae to your computer and use it in GitHub Desktop.
Unit testing Angular.js app with node.js, mocha, angular-mocks and jsdom #angular.js #testing
title slug createdAt language preview
Unit testing Angular.js app with node.js, mocha, angular-mocks and jsdom
unit-testing-angular-js-app-with-node
2015-07-05T18:04:33Z
en
Majority of search result about unit testing Angular.js apps is about how to do it by using test frameworks that run the tests in a real browser. Even though it's great to be able to test your code in multiple platforms, in my opinion it creates a lot of boilerplate code and makes it hard to run the tests in, for instance a CI-server.

Testing Angular.js app headlessly with node.js + mocha

Lean unit tests with minimal setup

Code examples

Keypoints

  • Fake DOM (Everything works without a real browser)
  • Uses ngMocks to inject and mock Angular.js dependencies
  • I'm assuming you are already using browserify (but everything works fine without it)

Some background

Majority of search result about unit testing Angular.js apps is about how to do it by using test frameworks that run the tests in a real browser. Even though it's great to be able to test your code in multiple platforms, in my opinion it creates a lot of boilerplate code and makes it hard to run the tests in, for instance a CI-server.

In most cases I just want to include a module from my code to my tests, call methods from the module and do assertions to assure that everything is working properly.

1. Set up a mock DOM

npm install jsdom@~3.x.x -D

Even though jsdom 4.0 is already available by the time I'm writing this we will still use the 3.x version because the most recent versions support only io.js.

tests/test-helper.js

var jsdom = require('jsdom').jsdom;

global.document = jsdom('<html><head><script></script></head><body></body></html>');
global.window = global.document.parentWindow;
global.navigator = window.navigator = {};
global.Node = window.Node;

2. Install and set-up ngMocks

If you have installed Angular with Bower:

bower install angular-mocks -D

If you have installed Angular with npm:

npm install angular-mocks -D

⚠️ Make sure your angular-mocks version matches your angular version

tests/test-helper.js

var jsdom = require('jsdom').jsdom;

global.document = jsdom('<html><head><script></script></head><body></body></html>');
global.window = global.document.parentWindow;
global.navigator = window.navigator = {};
global.Node = window.Node;

global.window.mocha = {};
global.window.beforeEach = beforeEach;
global.window.afterEach = afterEach;

/*
 * Only for Bower users
 */
require('../bower_components/angular');
require('../bower_components/angular-mocks');

/*
 * Only for NPM users
 */
require('angular/angular');
require('angular-mocks');

global.angular = window.angular;
global.inject = global.angular.mock.inject;
global.ngModule = global.angular.mock.module;

3. Install Mocha and write some tests

npm install mocha -D

I assume that your directory structure looks something similar to this

app/
  scripts/
    services/
      - userService.js
    controllers/
package.json

Lets create a new directory called _tests_ under our services/ directory and a new test file called userService.js there.

app/
  scripts/
    services/
      __tests__/
        - userService.js
      - userService.js
    controllers/
package.json

Here's a reference implementation of our userService.js

angular.module('myServices.user', []).service('UserService', function() {
  return {
    getUsers: function () {
      return [];
    }
});

app/scripts/services/_tests_/userService.js

var assert = require('assert');
require('../../../tests/test-helpers');

// Loads the module we want to test
require('../userService');

describe('User service', function() {
  beforeEach(ngModule('myServices.user'));
  
  it('should return a list of users', inject(function(UserService) {
    assert.equal(UserService.getUsers().length, 0);
  }));
});

Now by running ./node_modules/.bin/mocha app/scripts/**/__tests__/**/*.js we should see that our tests work.

Protip: You most likely want to add that to your package.json's scripts.test field.

"scripts": {
  "test": "mocha app/scripts/**/__tests__/**/*.js"
}

Notice how you don't have to use the ./node_modules... path anymore since npm resolves it for you. If you are using npm >=2.0 you can no also use npm test -- --watch to start Mocha in watch mode.

For Browserify users

If you want to also mock the modules your services (or what ever you are testing) are using I would recommend proxyquire.


Known issues

Broken --watch mode

If you've installed Angular.js with npm you'll notice that on Mocha's watch mode your fake DOM is recreated on every reload but the Angular.js code isn't re-evaluated which leads into thrown exceptions about angular being undefined. There's two causes for this: angular and angular-mocks are both singletons and Mocha doesn't re-evaluate anything in node_modules. This problem doesn't exist for Bower users since their angular and angular-mocks are in the bower_components directory.

Solution

Even though this is a bit dirty, I solved this by invalidating require cache for angular and related modules.

tests/test-helper.js

//...
delete require.cache[require.resolve('angular')];
delete require.cache[require.resolve('angular/angular')];
delete require.cache[require.resolve('angular-mocks')];

require('angular/angular');
require('angular-mocks');

global.angular = window.angular;
global.inject = global.angular.mock.inject;
global.ngModule = global.angular.mock.module;
@rikukissa
Copy link
Author

Wow, weird that I didn't get any notifications of received comments. @rodoabad did you find a solution for that?
I updated the gist and the repository to fix the Node is not defined error. Thanks for the comment @BrenoC. I'll update the guide asap.

@xwendotnet
Copy link

Run I run this with mocha, got this error. My Node version is 5.4.0.

global.navigator = window.navigator = {};

TypeError: Cannot set property 'navigator' of undefined

@konsumer
Copy link

konsumer commented Feb 2, 2016

@xwendotnet: I fixed this by changing the window line to global.window = global.document.defaultView;

I get another issue: TypeError: ngModule is not a function

It seems like global.angular.mock.module isn't defined.

here are my versions:

angular-mocks: "^1.4.9",
jsdom: "^8.0.2"
angular: "^1.4.9"
node: "v5.3.0"
npm: "3.3.12"

@konsumer
Copy link

konsumer commented Feb 3, 2016

Ah, I figured it out. It's a combination of several things. First of all, I am running a script to programatically add my tests then run them. At the point I was including angular-mocks, window.mocha and beforeEach wasn't in scope, so it wasn't loading. I ended up with this:

/**
 * Test preloader: setup environment & use cross-platform globs
 */

var glob = require('glob').glob;
var Mocha = require('mocha');
var chai = require('chai');
var jsdom = require('jsdom').jsdom;
var mocha = new Mocha();

chai.use(require('sinon-chai'));
chai.use(require('chai-as-promised'));

var ui =  process.argv.length === 3 && process.argv[2] === 'ui';
var tests = ui ? 'app/**/test.ui.js' : 'app/**/test.js';

// manipulate globals in env to create a clean DOM/module environment
global.setUpTests = function(modName){
    modName = modName || 'app';
    global.document = jsdom('<html><head><script></script></head><body></body></html>');
    global.window = global.document.defaultView;
    global.navigator = window.navigator = {};
    global.Node = window.Node;

    require('angular/angular');
    global.angular = window.angular;

    // emulate mocha running in browser (makes angular-mocks work)
    window.mocha = true
    window.beforeEach = beforeEach
    window.setup = setup
    window.afterEach = afterEach
    window.teardown = teardown

    require('angular-mocks/angular-mocks');
    if (angular && angular.mock){
        beforeEach(angular.mock.module(modName));
        beforeEach(angular.mock.inject(function(_$controller_){ 
            global.ngCtrl = function(name, params){
                return _$controller_(name, params)
            }
        }));
    }
}

glob(tests, function(err, files){
    if (err) throw err
    files.forEach(function(f){ mocha.addFile(f); })
    mocha.reporter('spec').ui('tdd').run(function(failures) {
        process.on('exit', function() {
            process.exit(failures);
        });
    });
})

then, my tests are super-elegant, and chai is all preconfigured with as-promised & sinon. here is an example test:

var expect = require('chai').expect;

setUpTests('app');
require('../../../app');

describe('compA', function(){
    describe('controller', function(){
        it('should run :addOneandOne', function(){
            var $scope = {};
            ngCtrl('compACtrl', {$scope: $scope});
            expect($scope.addOneandOne()).to.equal(2);
        });
    });
});

@winnemucca
Copy link

Thanks for the write-up I am wondering how you would test directives with templateUrl? Did you have to do anything specific such as create a jsdom environment?

@rikukissa
Copy link
Author

rikukissa commented May 20, 2016

@winnemucca It's been a while since the last time I did anything with angular, but I'm thinking whether you could for instance mock the directive function in your tests like this:

var fs = require('fs');
var originalDirectiveMethod = angular.directive.bind(angular);
angular.directive = function(name, opts) {
  if(opts.templateUrl) {
    opts.template = fs.readFileSync('your-template.html');
    delete opts.templateUrl;
  }
  return originalDirectiveMethod(name, opts);
}

Sorry for the late response. Github still seems to be missing notification feature from gists

@fnocke
Copy link

fnocke commented Jun 22, 2016

☀❀❤ Thank you, thank you, thank you for this post. You saved my day. ☀❀❤
If someone encounters the same:

require('../src/bower_components/angular');

gave me

D:\depot\bf\bookandsmile\bas-webapp\src\bower_components\angular\index.js:2
module.exports = angular;

despite using the right path... being a bit more „specific“ helped... (not entirely sure, why)

require('../src/bower_components/angular/angular.min.js'); // min or non-min version, no difference

@rikukissa
Copy link
Author

Thanks for the feedback, so glad that this actually helped someone :)

@hinok
Copy link

hinok commented Sep 1, 2016

delete require.cache[require.resolve('angular')];
delete require.cache[require.resolve('angular/angular')];
delete require.cache[require.resolve('angular-mocks')];

Saved my day, thanks! ❤️

@bjarketrux
Copy link

This is great, but how do you include jQuery so that directives using more than jQLite will still works?
E.g. I get "element.toggle is not a function"

@ShahparShabani
Copy link

delete require.cache[require.resolve('angular/angular')];

very helpful, thanks!

@rikukissa
Copy link
Author

@bjarketrux

If you have installed jQuery with NPM you can most likely do something like this

global.$ = require('jquery');

or if you are using Bower then

require('../bower_components/jquery');

global.$ = window.$;

@PrestonII
Copy link

@konsumer

thanks ! your solution helped me get a bunch of client tests running in Mocha finally!

@adamrbennett
Copy link

@rikukissa thanks for sharing this!

I had problems with Mocha --watch ... The first run would work, but subsequent test runs would fail, most likely due to leaky dom. So I created my own watcher that runs Mocha in a child process so each run is clean:

// watch.js

if (process.argv.length < 3) {
    console.log('Usage: node watch.js [mocha arguments...]');
    return;
}

var spawn = require('child_process').spawn;
var chokidar = require('chokidar');
var args = process.argv.slice(2);

var onChange = function() {
    var mocha = spawn('./node_modules/mocha/bin/_mocha', args, {stdio: "inherit"});

    mocha.on('close', function(code) {
        console.log('watching...');
    });
};

var opts = {
    ignored: /(^|[\/\\])\../,
    persistent: true
};

// watch for changes
chokidar.watch('**/*.js', opts).on('change', onChange);

// don't wait for first change before running tests
onChange();

@brandonros
Copy link

Broken in 2018. :/

var jsdom = require('jsdom');

global.document = new jsdom.JSDOM('<html><head><script></script></head><body></body></html>');
global.window = global.document.defaultView;
global.navigator = window.navigator = {};
global.Node = window.Node;
global.navigator = window.navigator = {};
                                    ^

TypeError: Cannot set property 'navigator' of undefined

@kindy
Copy link

kindy commented Jan 22, 2018

2018

const jsdom = require("jsdom");
const {JSDOM} = jsdom;

global.document = new JSDOM('<html><head><script></script></head><body></body></html>');
global.window = Object.create(global.document.window);
Object.defineProperties(global, {
  angular: {get() {return window.angular;}},
  inject: {get() {return window.angular.mock.inject;}},
  ngModule: {get() {return window.angular.mock.module;}},
});

require('angular');
require('angular-mocks');

@lars-erik
Copy link

Brilliant!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment