Rodrigo Rosenfeld Rosas
Testing JavaScript with Node.js, Jasmine and Sinon.js
For some years now, I’ve been writing lots of JavaScript. Not that I chose to, but it is the only available language for client-side programming. Well, not really since there are some languages that will compile to JavaScript. So, I chose to work with CoffeeScript lately, since it is far better than JavaScript for my tastes.
All this client-side programming requires testing too. While sometimes testing using real browsers suits better, tools like Selenium are extremely slow if you have tons of JavaScript to test. So, I was looking for a faster alternative that allowed me to test my client-side code.
Before I present the approach I decided to take, I’d like to warn you that there are lots of good alternatives out there. If you want to take a look at how to use the excellent PhantomJS headless webkit browser, you might be interested in this article.
I decided to go with a solution based on Node.js, a fast runtime JavaScript environment built on top of Google’s V8 engine. Even using Node.js, you’ll find out many good alternatives like Zombie.js, which can also be integrated to the excellent integration test framework Capybara through capybara-zombie. It can also be integrated to Jasmine through zombie-jasmine-spike.
Even though there are great options out there, I still chose another approach for no special reason. The interesting thing about Node.js, is that there’s an interesting ecosystem behind it with tools like NPM which is a package manager for Node, similar to apt on Debian, for instance. On Debian, it can be installed with:
1 | apt-get install -y node npm |
But I would recommend installing just node through apt, and install npm using the instructions here:
1 | curl http://npmjs.org/install.sh | sh |
The reason for that is that the search command of the npm command provided by the Debian package was not working for me, running the list command instead. Maybe this happens only in the unstable distribution, but I don’t want to get out of the main subject here.
Since we want to test our client-side script, it is necessary to install some library to emulate the browsers DOM, since Node won’t provide one itself. The jsdom library seems to be the de facto standard one for creating a DOM environment.
I don’t really like to read assertions, prefering expectations instead. If you’re like me, you’ll like the Jasmine.js library for writing your expectations in JavaScript. If you don’t want to write integration tests, chances are that you’ll need to mock your AJAX calls. Sinon.js is an excellent framework that will allow you to do that. And since I avoid JavaScript itself at all cost, I’ll write all my examples using CoffeeScript.
If your web framework, differently from Rails doesn’t support CoffeeScript by default, and still you got an interest on this language, you can use Jitter to watch your CoffeeScript files and convert them to JavaScript on the fly. It will replicate your directory structure, converting all your .coffee files to .js:
1 | jitter src/coffee/ web-app/js/ |
Install all those dependencies with NPM:
1 | npm install jitter jasmine-node jsdom |
Although you can install jQuery and Sinon.js with ‘npm install jquery sinon’, that won’t make sense, since you’ll want to load them from your DOM environment. So download Sinon.js to your hard-disk to get faster tests.
I don’t practice TDD (or BDD) and I this is a conscious choice. I find it faster to write the implementation first and then write the tests. So, proceeding with this approach, let me show you an example for a “Terms and Conditions” page. Here’s a possible implementation (I’m showing only the client-side part):
1 | <!DOCTYPE html> |
2 | <html> |
3 | <head> |
4 | <script type="text/javascript" src="js/jquery.min.js"></script> |
5 | <link rel="stylesheet" type="text/css" href="css/jquery.ui.css"> |
6 | <script type="text/javascript" src="js/jquery-ui.min.js"></script> |
7 | <script type="text/javascript" src="js/wmd/showdown.js"></script> |
8 | <script type="text/javascript" src="js/show-terms-and-conditions.js"></script> |
9 | </head> |
10 | <body> |
11 | </body> |
12 | </html> |
Showdown is a JS library for converting Markdown to HTML. Here is the show-terms-and-conditions.coffee equivalent in CoffeeScript:
1 | $ -> |
2 | converter = new Attacklab.showdown.converter() |
3 | lastTermsAndConditions = {} |
4 | $.get 'termsAndConditions/lastTermsAndConditions', (data) -> |
5 | lastTermsAndConditions = data |
6 | $('<div/>').html(converter.makeHtml(lastTermsAndConditions.termsAndConditions)) |
7 | .dialog |
8 | width: 800, height: 600, modal: true, buttons: |
9 | 'I agree': onAgreement, 'Log out': onLogout |
10 | |
11 | onAgreement = -> |
12 | $.post 'termsAndConditions/agree', id: lastTermsAndConditions.id, => |
13 | $(this).dialog('close') |
14 | window.location = '../' # redirect to home |
15 | |
16 | onLogout = -> |
17 | $(this).dialog('close') |
18 | window.location = '../logout' # sign out |
As you can see, this will issue an AJAX request as soon as the page is loaded. So, we need to fake the AJAX call before we run show-terms-and-conditions.js. This can be easily done with this fake-ajax.js, using Sinon.js:
1 | sinon.stub($, 'ajax') |
If you’re not using jQuery, you can try the “sinon.useFakeXMLHttpRequest()” documented in the “Fake XHR” example in Sinon.js site.
Ok, so here is a possible example of specification for this code in CoffeeScript. Jasmine-sinon can help you to write better expectations, so download it to ‘spec/js/jasmine-sinon.js’.
1 | # spec/js/show-terms-and-conditions.spec.coffee: |
2 | |
3 | require './jasmine-sinon' # wouldn't you love if vanilla JavaScript also supported 'require'? |
4 | dom = require 'jsdom' |
5 | |
6 | #f = (fn) -> __dirname + '/../../web-app/js/' + fn # if you prefer to be more explicit |
7 | f = (fn) -> '../../web-app/js/' + fn |
8 | |
9 | window = $ = null |
10 | |
11 | dom.env |
12 | html: '<body></body>' # or require('fs').readFileSync("#{__dirname}/spec/fixures/any.html").toString() |
13 | scripts: ['sinon.js', f('jquery/jquery.min.js'), f('jquery/jquery-ui.min.js'), f('wmd/showdown.js'), 'ajax-faker.js', |
14 | f('showTermsAndConditions.js')] |
15 | # src: ["console.log('all scripts were loaded')", "var loaded=true"] |
16 | done: (errors, _window) -> |
17 | console.log("errors:", errors) if errors |
18 | window = _window |
19 | $ = window.$ |
20 | # jasmine.asyncSpecDone() if window.loaded |
21 | |
22 | # We must tell Jasmine to wait until the DOM is loaded and the script is run |
23 | # Jasmine doesn't support a beforeAll, like RSpec |
24 | beforeEach(-> waitsFor -> $) unless $ |
25 | # another approach: (you should uncomment the line above for it to work) |
26 | # already_run = false |
27 | # beforeEach -> already_run ||= jasmine.asyncSpecWait() or true |
28 | |
29 | describe 'showing Terms and Conditions', -> |
30 | |
31 | it 'should get last Terms and Conditions', -> |
32 | @after -> $.ajax.restore() # undo the stubbed ajax call introduced by fake-ajax.js after this example. |
33 | expect($.ajax).toHaveBeenCalledOnce() |
34 | firstAjaxCallArgs = $.ajax.getCall(0).args[0] |
35 | expect(firstAjaxCallArgs.url).toEqual 'termsAndConditions/lastTermsAndConditions' |
36 | firstAjaxCallArgs.success id: 1, termsAndConditions: '# title' |
37 | |
38 | describe 'after set-up', -> |
39 | beforeEach -> window.sinon.stub $, 'ajax' |
40 | afterEach -> $.ajax.restore() |
41 | afterEach -> $('.ui-dialog').dialog 'open' # it is usually closed at the end of each example |
42 | |
43 | it 'should convert markdown to HTML', -> expect($('h1').text()).toEqual 'title' |
44 | |
45 | it 'should close the dialog, send a request to server and redirect to ../ when the terms are accepted', -> |
46 | $('button:contains(I agree)').click() |
47 | ajaxRequestArgs = $.ajax.args[0][0] |
48 | expect(ajaxRequestArgs.url).toEqual 'termsAndConditions/agree' |
49 | expect(ajaxRequestArgs.data).toEqual id: 1 |
50 | |
51 | ajaxRequestArgs.success() |
52 | expect(window.location).toEqual '../' |
53 | expect($('.ui-dialog:visible').length).toEqual 0 |
54 | |
55 | it 'should close the dialog and redirect to ../logout when the terms are not accepted', -> |
56 | # the page wasn't really redirected in this simulation by the prior example |
57 | $('button:contains(Log out)').click() |
58 | expect(window.location).toEqual '../logout' |
59 | expect($('.ui-dialog:visible').length).toEqual 0 |
You can run this spec with:
1 | jasmine-node --coffee spec/js/ |
The output should be something like:
1 | Started |
2 | .... |
3 | |
4 | Finished in 0.174 seconds |
5 | 2 tests, 9 assertions, 0 failures |
Instead of writing “expect($(‘.ui-dialog:visible’).length).toEqual 0”, BDD would advice you to write “expect($(‘.ui-dialog’)).toBeVisible()” instead. Jasmine allows you to write custom matchers. Take a look at my jQuery matchers for an example.
Unfortunately, due to a bug in jsdom, the expected implementations of toBeVisible and toBeHidden won’t work for my cases, where I usually do that by toggling the hidden CSS class (.hidden {display: none}) of my elements. So, I check for this CSS class on my jQuery matchers.
Anyway, I’m just starting to write tests this way. Maybe there are better ways of writing tests like those.
Finally, if you want, you can also set up some auto-testing environment using a tool such as Guard that will watch your JavaScript (or CoffeeScript) files for changes and call jasmine-node on them. Here is an example Guardfile:
1 | guard 'jasmine-node', jasmine_node_bin: File.expand_path("#{ENV['HOME']}/node_modules/jasmine-node/bin/jasmine-node") do |
2 | watch(%r{^(spec/js/[^\.].+\.spec\.coffee)}) { |m| m[1] } |
3 | watch('spec/js/jasmine-sinon.js'){ 'spec/js/' } |
4 | end |
If you have any tips, please leave a comment.
Enjoy!