So far we've learned how to test your JavaScript code with
Jasmine and running them against
Node.js and browsers with
Karma. We've also got familiar with
modular design patterns in JavaScript. And yet, somehow it seems that we're still missing one last puzzle piece connecting all the others, it's called
Grunt.js.
What is it?
According to it's site:
In one word: automation. The less work you have to do when performing repetitive tasks like minification, compilation, unit testing, linting, etc, the easier your job becomes. After you've configured it, a task runner can do most of that mundane work for you—and your team—with basically zero effort.
Zero or not, there is a bit of effort in making everything play together, but no worry - we'll figure it out. So what's our plan?
- Write classes, which are both usable in Node.js, Require.js and global environment.
- Write Jasmine specs to test our code in both Chrome and Firefox
- Write Karma and Node.js runners
- Write Grunt task to automate the testing
Writing universal JavaScript classes
In the end we'll type one command to test our code from every aspect. Feeling excited? Let's start! All the code can be found in
GitHub, to where I copied some code from my project called
Raceme.js, JavaScript clustering algorithms framework (some harmless PR :) First one is
Vector class, which wraps the JavaScript array with minor functionality:
(function () {
'use strict';
var Vector = function Vector(v) {
var vector = v;
this.length = function length() {
return vector.length;
};
this.toArray = function toArray() {
return vector;
};
};
if (typeof define === 'function' && define.amd) {
// Publish as AMD module
define(function() {return Vector;});
} else if (typeof(module) !== 'undefined' && module.exports) {
// Publish as node.js module
module.exports = Vector;
} else {
// Publish as global (in browsers)
var Raceme = window.Raceme = window.Raceme || {};
Raceme.Common = Raceme.Common || {};
Raceme.Common.Vector = Vector;
}
}());
Notice the lower part of the code, where we define our class as AMD module using Require.js, CommonJS module for Node.js and global class for window environment. To spice things up, we'll add additional class,
PlaneMapper, which will depend on our
Vector class. It exposes one method,
mapVector, mapping 2-dimensional coordinate point into vector. The problem with writing dependent universal classes is the loading process.
As you remember, Require.js and Node.js use different loading methods - asynchronous versus synchronous.
loadDependencies method unifies the approaches into one loading process. Pay attention to continuation of declaration logic in line 29; Once we have our
PlaneMapper object defined, we finalize the declaration depending upon the method.
(function () {
'use strict';
var COMMONJS_TYPE = 2, GLOBAL_TYPE = 3;
var loadDependencies = function loadDependencies(callback) {
if (typeof define === 'function' && define.amd) {
// define AMD module with dependencies
define(['common/Vector'], callback); // cannot pass env type
} else if (typeof(module) !== 'undefined' && module.exports) {
// load CommonJS module
callback(require('../common/Vector.js'), COMMONJS_TYPE);
} else {
// Publish as global (in browsers)
callback(Raceme.Common.Vector, GLOBAL_TYPE);
}
};
loadDependencies(function (Vector, env) {
var PlaneMapper = function () {
var mapVector = function mapVector(node) {
return new Vector([node.x, node.y]);
};
return {
mapVector: mapVector
};
};
// finalize the declaration
switch(env) {
case COMMONJS_TYPE:
module.exports = PlaneMapper();
break;
case GLOBAL_TYPE:
var Raceme = window.Raceme = window.Raceme || {};
Raceme.DataMappers = Raceme.DataMappers || {};
Raceme.DataMappers.PlaneMapper = PlaneMapper();
break;
default:
return PlaneMapper();
}
});
}());
Writing universal Jasmine specs
Code is written, time for testing. We'll create two Jasmine specs, each for one of the classes. As in before, we start with
Vector class:
(function () {
'use strict';
describe('Mappers', function () {
var loadDependencies = function loadDependencies(callback) {
if (typeof define === 'function' && define.amd) {
// load AMD module
define(['common/Vector'], callback);
} else if (typeof(module) !== 'undefined' && module.exports) {
// load CommonJS module
callback(require('../../src/common/Vector.js'));
} else {
// Publish as global (in browsers)
callback(Raceme.Common.Vector);
}
};
loadDependencies(function (Vector) {
var vector;
describe('Vector', function () {
beforeEach(function() {
vector = new Vector([1, 2, 3]);
});
it('check length', function () {
expect(vector.length()).toEqual(3);
});
it('check toArray', function () {
expect(vector.toArray()).toEqual([1, 2, 3]);
});
});
});
});
})();
Nothing new here - we load the
Vector class prior to declaring the spec using the same technique. Same with our mapper, besides loading two classes.
(function () {
'use strict';
describe('Mappers', function () {
var loadDependencies = function loadDependencies(callback) {
if (typeof define === 'function' && define.amd) {
// load AMD module
define(['common/Vector', 'dataMappers/PlaneMapper'], callback);
} else if (typeof(module) !== 'undefined' && module.exports) {
// load CommonJS module
callback(require('../../src/common/Vector.js'),
require('../../src/dataMappers/PlaneMapper.js'));
} else {
// Publish as global (in browsers)
callback(Raceme.Common.Vector, Raceme.DataMappers.PlaneMapper);
}
};
loadDependencies(function (Vector, PlaneMapper) {
var vector;
describe('PlaneMapper', function () {
var mapper, node;
beforeEach(function() {
mapper = PlaneMapper;
node = {
x: 5,
y: 10
};
});
it('check mapping', function () {
vector = mapper.mapVector(node);
expect(vector.toArray()).toEqual([5, 10]);
});
});
});
});
})();
Configuring Jasmine spec runners
Testing Node.js modules is easy - just run the
jasmine-node command with path to the specs.
jasmine-node test/spec
Moving on to browser testing. We'll start with easier case using global declarations. First we create Karma configuration file,
karma.conf.js. The main interest is in
files and
browsers sections, where we define our source and spec files in correct order and browsers we want to test.
...
files: [
'src/common/*.js',
'src/dataMappers/*.js',
'test/spec/*Spec.js'
],
...
browsers: ['Chrome', 'Firefox'],
...
Then invoking the tests using karma command.
karma start karma.conf.js
Lastly, let's test our Require.js modules. Since the modules will by loaded by Require.js instead of Karma, a new Karma configuration file is required -
karma.conf.require.js. The first difference appears in
frameworks section, where we tell Karma to use Require.js framework. This will require installing additional package called
karma-requirejs.
...
frameworks: ['jasmine', 'requirejs'],
...
files: [
{pattern: 'src/common/*.js', included: false},
{pattern: 'src/dataMappers/*.js', included: false},
{pattern: 'test/spec/*Spec.js', included: false},
'test/test-require-main.js'
],
...
Additional difference comes in
files section. Here we inform the test runner not to load our source and spec files. So why to list them at all? Listing the files enables us to use them later, during configuration of Require.js in
test-require-main.js. Usually Require.js configuration appears in JavaScript file, mentioned in
data-main attribute of
script tag. However since we don't want to load HTML files, we configure our modules in
test-require-main.js.
(function () {
'use strict';
var tests = [];
for (var file in window.__karma__.files) {
if (window.__karma__.files.hasOwnProperty(file)) {
if (/Spec\.js$/.test(file)) {
tests.push(file.replace(/^\/base\//,
'http://localhost:9876/base/'));
}
}
}
requirejs.config({
// Karma serves files from '/base'
baseUrl: 'http://localhost:9876/base/src/',
// ask Require.js to load these files (all our tests)
deps: tests,
// start test run, once Require.js is done
callback: window.__karma__.start
});
}());
At first we pass through each file listed in the configuration by using
window.__karma__.files list and initiate spec files list. While doing so, we adjust the domain of the specs modules to one used by Karma -
localhost:9876. It will also be used as a
baseUrl attribute in Require.js configuration. Then we integrate Require.js and Karma together by passing Karma's stating method,
window.__karma__.start, as a callback in line 21. The heart of the fusing appears in line 18, where we configure to load our specs prior to calling the callback. Once specs are loaded, callback will be invoked starting the testing.
Writing Grunt tasks
As promised, it's time to integrate all parts using Grunt.js. For this to happen, we'll require four packages:
grunt,
grunt-cli and
grunt-karma,
grunt-jasmine-node. The first two for running the tasks and the rest are for calling Karma and Node.js runners. Make sure to install the packages locally into project's folder, otherwise it will not work. In fact all the packages should be installed locally, when you work with Grunt.js.
Installing them can be done easily using
package.json and
bower.json files. Once the files are in place just call appropriate
install commands. It will download all the packages automatically into project's folder.
npm install
bower install
If you an eager environmentalist like me, who doesn't wish to store anything, but essential data on your repository, you may use
.gitignore file, which tells Git to ignore specified paths.
node_modules/
bower_components/
Grunt tasks are defined using JavaScript code in
gruntfile.js.
(function () {
'use strict';
module.exports = function(grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
karma: {
unit_global: {
configFile: 'karma.conf.js'
},
unit_requirejs: {
configFile: 'karma.conf.require.js'
}
},
jasmine_node: {
options: {
forceExit: true,
match: '.',
matchall: false,
extensions: 'js',
specNameMatcher: 'spec'
},
all: ['test/spec/']
}
});
grunt.loadNpmTasks('grunt-karma');
grunt.loadNpmTasks('grunt-jasmine-node');
grunt.registerTask('default', ['jasmine_node',
'karma:unit_global', 'karma:unit_requirejs']);
};
}());
Not very intimidating, isn't it? Basically what it does is configures our test tasks, loads the required packages and then runs the tasks. Now in details. At first it configures our Karma tasks by specifying two children in
karma node:
unit_global and
unit_requirejs, each states it's configuration file name. Then it configures Node.js runner. Since it doesn't have any configuration file, all the settings are listed here. In the end, it runs the tasks in the order they appear in parameter array of
registerTask method. Notice the usage of semicolon, when Karma tasks are specified. It tells Grunt to run specific tasks under
karma node.
Tasks names can be changed, both jasmine_node and karma node's names cannot.
Aren't you eager to see the results?
grunt
Grunt will load and run the
gruntfile.js file emitting the following result:
Running "jasmine_node:all" (jasmine_node) task
Common
Vector
check length
check toArray
Mappers
PlaneMapper
check mapping
Finished in 0.014 seconds
3 tests, 3 assertions, 0 failures
Running "karma:unit_global" (karma) task
INFO [karma]: Karma v0.12.23 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [launcher]: Starting browser Firefox
INFO [Chrome 36.0.1985]: Connected on socket HrOcIkaJ5aqQG85SOqIS
with id 63263274
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.032 secs / 0.005 secs)
INFO [Firefox 31.0.0]: Connected on socket wrrkgK5_skzDJztmOqIT wi
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.032 secs / 0.005 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.032 secs / 0.005 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.032 secs / 0.005 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.032 secs / 0.005 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.032 secs / 0.005 secs)
Firefox 31.0.0: Executed 3 of 3 SUCCESS (0.026 secs / 0.002 secs)
TOTAL: 6 SUCCESS
Running "karma:unit_requirejs" (karma) task
INFO [karma]: Karma v0.12.23 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [launcher]: Starting browser Firefox
INFO [Chrome 36.0.1985]: Connected on socket PXxh9c5vacKQovhSOsI2
with id 36823086
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.004 secs / 0.002 secs)
INFO [Firefox 31.0.0]: Connected on socket Xu3qldD3wfmNskyOOsI3 wi
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.004 secs / 0.002 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.004 secs / 0.002 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.004 secs / 0.002 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.004 secs / 0.002 secs
Chrome 36.0.1985: Executed 3 of 3 SUCCESS (0.004 secs / 0.002 secs)
Firefox 31.0.0: Executed 3 of 3 SUCCESS (0.005 secs / 0.002 secs)
TOTAL: 6 SUCCESS
Done, without errors.
Perfection! But it's only a tip of the iceberg. We'll be talking more about Grunt.js using conditional logic and reporting, so stay tuned ;)