Karma Tutorial - Unit Testing JavaScript
A quick start guide to testing client side code using Karma.
on
What’s Karma?
Karma is a tool that enables the running of source code (i.e. JavaScript) against real browsers via the CLI. The fact that it runs against real browsers rather than “fakes” with a virtual DOM is extremely powerful. DOM implementations vary across browsers therefore the idea is to use the actual browsers for correctness.
You can find more details and documentation via the official Karma website. I found that there is some assumed knowledge once the setup is complete, I’ve used Karma before so I know the ins and outs so this is a reference for my future-self as well as others.
This post re-caps the setup, explains how to get up and running once the installation is complete and details my own opinionated setup. There is also a karma-seed project on Github.
Installation
Node.js is a requirement since Karma runs on Node.
For a granular approach to the installation, see the official documentation.
People in a hurry can download the following package.json file:
curl -O https://gist.githubusercontent.com/bbraithwaite/7432f64ea45e028eb23c/raw/2a6e23cee09e7737c30cc790393a9b273070fbcb/package.json
OR copy-paste into package.json:
{
"name": "karma-seed",
"version": "1.0.0",
"description": "",
"scripts": {
"test": "./node_modules/karma/bin/karma start karma.conf.js"
},
"devDependencies": {
"jasmine-core": "^2.2.0",
"karma": "^0.12.31",
"karma-chrome-launcher": "^0.1.7",
"karma-jasmine": "^0.3.5"
}
}
Then run:
npm install
Once complete, verify the installation by the terminal command:
./node_modules/karma/bin/karma start
If all is well, something similar to the following will be output:
INFO [karma]: Karma v0.12.31 server started at http://localhost:9876/
Configuration
The next step is to provide some configuration for the Karma service. To template this, run the following and accept the defaults:
./node_modules/karma/bin/karma init
A new file will be created in the root called karma.conf.js.
If you want to read more about all the properties, see the configuration docs.
To run the tests via npm test, adjust the package.json file to reflect the following changes.
Replace the property of package.json:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
With:
"scripts": {
"test": "./node_modules/karma/bin/karma start karma.conf.js"
},
The tests can now be exectuted as:
npm test
NB Using npm test as a façade is my own personal preference. Regardless of what tools being used or what project, I can run npm test without having to remember the specific details.
As per the Karma documentation, you can optionally install karma-cli to avoid having to enter ./node_modules/karma/bin/karma start to run the tests.
If you wish to follow this step, run the following:
npm install -g karma-cli
If the karma-cli is installed the npm test line in package.config can be updated with:
"scripts": {
"test": "karma start karma.conf.js"
},
Adding Tests
Most of the examples I’ve seen when using Karma assume use of AngularJS. Given that this is a vanilla setup no such framework shall be used.
As a simple example we will unit test an app that has a single screen to add two numbers and display the result upon a button click.
Here is the HTML:
<html>
<head>
<title>Magic Calculator</title>
</head>
<body>
<input id="x" type="text">
<input id="y" type="text">
<input id="add" type="button" value="Add Numbers">
Result: <span id="result" />
<script type="text/javascript" src="lib/calculator.js"></script>
<script>
calculator.init();
</script>
</body>
</html>
Create two new files:
- lib/calculator.js
- test/calculator.test.js
Paste the following into test/calculator.test.js:
describe('Calculator', function() {
it('should add numbers');
});
Running npm test doesn’t detect this new test. We need to make a config change so that Karma knows about these new files. Update the ‘files’ property of karma.conf.js:
Replace:
// list of files / patterns to load in the browser
files: [
],
With:
// list of files / patterns to load in the browser
files: [
'lib/*.js',
'test/*.js'
],
Running npm test should now display something similar to the below in the terminal:
INFO [karma]: Karma v0.12.31 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 40.0.2214 (Mac OS X 10.10.2)]: Connected on socket bDMxeU51miCD4wNe3 with id 34236680
Chrome 40.0.2214 (Mac OS X 10.10.2): Executed 0 of 1 (skipped 1) ERROR (0.004 secs / 0 secs)
A very useful feature when targeting a browser like Chrome, is that accessing the url in the output (in this example http://localhost:9876/) via Chrome offers additional debugging.
Let’s add something more meaningful to the calculator.test.js file:
/*
* Unit tests for lib/calculator.js
*/
describe('Calculator', function() {
// inject the HTML fixture for the tests
beforeEach(function() {
var fixture = '<div id="fixture"><input id="x" type="text">' +
'<input id="y" type="text">' +
'<input id="add" type="button" value="Add Numbers">' +
'Result: <span id="result" /></div>';
document.body.insertAdjacentHTML(
'afterbegin',
fixture);
});
// remove the html fixture from the DOM
afterEach(function() {
document.body.removeChild(document.getElementById('fixture'));
});
// call the init function of calculator to register DOM elements
beforeEach(function() {
window.calculator.init();
});
it('should return 3 for 1 + 2', function() {
document.getElementById('x').value = 1;
document.getElementById('y').value = 2;
document.getElementById('add').click();
expect(document.getElementById('result').innerHTML).toBe('3');
});
it('should calculate zero for invalid x value', function() {
document.getElementById('x').value = 'hello';
document.getElementById('y').value = 2;
document.getElementById('add').click();
expect(document.getElementById('result').innerHTML).toBe('0');
});
it('should calculate zero for invalid y value', function() {
document.getElementById('x').value = 1;
document.getElementById('y').value = 'goodbye';
document.getElementById('add').click();
expect(document.getElementById('result').innerHTML).toBe('0');
});
});
Key points of this file:
- HTML is injected into the browser (in the beforeEach block) for this test fixture.
- The DOM elements can be accessed in the same way as working in real browser.
To add the basic calculator logic, paste in the following to lib/calculator.js:
window.calculator = window.calculator || {};
(function() {
var calculate = function() {
var x = document.getElementById('x').value;
var y = document.getElementById('y').value;
document.getElementById('result').innerHTML = x + y;
};
window.calculator.init = function() {
document.getElementById('add').addEventListener('click', calculate);
};
})();
I’ve left in some bugs to work through via the unit tests to get into the feedback loop of using this approach.
Run npm test and there should be a failing test stating Expected ‘12’ to be ‘3’. Using the auto watch feature, the tests will be re-run as the files are edited so there is no need to re-run npm test after each change.
After clearing up the bugs, this is what I ended up with for calculator.js:
'use strict';
window.calculator = window.calculator || {};
(function() {
var getIntById = function(id) {
return parseInt(document.getElementById(id).value, 10);
};
var calculate = function() {
var sum = getIntById('x') + getIntById('y');
document.getElementById('result').innerHTML = isNaN(sum) ? 0 : sum;
};
window.calculator.init = function() {
document.getElementById('add').addEventListener('click', calculate);
};
})();
All tests should now pass:
INFO [karma]: Karma v0.12.31 server started at http://localhost:9876/
INFO [launcher]: Starting browser PhantomJS
INFO [PhantomJS 1.9.8 (Mac OS X)]: Connected on socket WE5_e1Pp_f3CI1Zmm4tw with id 82088536
PhantomJS 1.9.8 (Mac OS X): Executed 3 of 3 SUCCESS (0.016 secs / 0.001 secs)
Optional Config Changes
In past projects I’ve made the following configuration changes:
- Using Mocha & Should
- Using a Headless Browser
- Setting up Test Coverage
- Setting Up Config Profiles
- Handling HTML Fixtures
Using Mocha & Should
If you have written unit tests for Node.js projects before you will likely have used Mocha. My preference is Mocha and should style assertions. The default initialisation uses Jasmine but it is very easy to change this if you prefer to use Mocha.
Install (and drop the jasmine dependencies from package.json if starting from the defaults):
npm install karma-mocha --save-dev
npm install karma-chai --save-dev
Update the karma.conf.js property:
frameworks: ['mocha', 'chai'],
Now we can write test assertions in the style of:
1.should.equal(2);
Using a Headless Browser
The tests can become slower when targeting multiple browsers with lots of tests. I don’t deem it necessary to run against all the browsers each time, so for speed I use PhantomJS which is a headless browser. I find that the feedback loop is faster this way.
I run against the full set of browsers at the end of a set of changes.
To use PhantomJS, install via:
npm install karma-phantomjs-launcher --save-dev
In the file karma.conf.js, set the browsers property to:
browsers: ['PhantomJS'],
Setting up Test Coverage
I sometimes include test coverage reports. This can be achieved using a tool called Istanbul.
To use test coverage, install via:
npm install karma-coverage --save-dev
In the file karma.conf.js, set the following properties to:
preprocessors: {
'lib/*.js': 'coverage'
},
reporters: ['progress', 'coverage'],
When running the tests, a new folder called coverage will be generated. Open index.html file in the browser to see the coverage report.
Setting Up Config Profiles
I like to be able to easily switch config profiles as I’m working. When making small iterations I only want to run PhantonJS and without the code coverage report as the test cycles are faster. Once I’ve worked through a feature I will then test the code against multiple browsers and run the coverage report.
I achieve this by using environment variables. I update the package.json with:
"scripts": {
"test": "karma start karma.conf.js",
"full-test": "NODE_ENV=test karma start karma.conf.js"
},
If I execute npm run full-test I will get the slower test run, covering more browsers and including the coverage report.
You can see how I have achieved this by reviewing the files/folders from the karma-seed project:
Handling HTML Fixtures
In the example test provided an HTML fixture is injected:
beforeEach(function() {
var fixture = '<div id="fixture">' +
'<input id="x" type="text">' +
'<input id="y" type="text">' +
'<input id="add" type="button" value="Add Numbers">' +
'Result: <span id="result" />' +
'</div>';
document.body.insertAdjacentHTML(
'afterbegin',
fixture);
});
// remove the html fixture from the DOM
afterEach(function() {
document.body.removeChild(document.getElementById('fixture'));
});
The inject/remove boilerplate code can become tedious as the number of tests grow. There are many additional plug-ins and approaches to solving this problem. I’ve used:
Once configured the HTML fixtures can be kept in HTML files which can be loaded in the style of:
beforeEach(function() {
fixture.load('calculator.fixture.html');
});
See an example test here.
Keep DOM accessor code out of Tests
This is a personal preference concerning style. The test example given previously had code to find elements in the test method e.g. document.getElementById. This additional detail can muddy the tests making them less readable.
it('should return 3 for 1 + 2', function() {
document.getElementById('x').value = 1;
document.getElementById('y').value = 2;
document.getElementById('add').click();
expect(document.getElementById('result').innerHTML).toBe('3');
});
I like to abstract this away to make highly readable tests and cut down on the duplicate setup code. Here is an example of how I prefer the tests to look:
it('should calculate 3 for 1 + 2', function() {
controls.x = 1;
controls.y = 2;
controls.clickAdd();
controls.result.should.equal('3');
});
See an example test here.
Seed Project
A seed project containing all of the above concepts can be found at https://github.com/bbraithwaite/karma-seed.