Rubyist's first attempt at testing JavaScript
I have to confess something. I have written way too many lines of JavaScript without ever writing a single test. Untested JavaScript used to be silently accepted. All the JavaScript tutorials I did never even mentioned testing. Fortunately, it’s all changing rapidly. Testing is becoming a must in the JavaScript community as much as it is in all the others.
I have joined a React project recently. It was already setup with Mocha. I was very pleasantly surprised to find out how much it resembled RSpec. I want to share with you a side by side comparison of identical test suites, the first one written in RSpec, the second one in Mocha.
The test subjects
To make the examples easier to understand, let’s break the rules of TDD and write the code under test first.
- A
Bomb
can be defused or detonated. When detonated, it raises an error. It can be detonated instantly or with a delay. It has a production date.
- A
RedButton
detonates a bomb with a delay of 1000 ms.
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| # lib/bomb.rb
class Bomb
def initialize
@production_date = Time.now
end
def detonate(delay = nil)
sleep delay if delay
raise 'You are dead. No more coding.' unless @defused
end
def cut_wire(color)
if the_right_wire == color
@defused = true
else
detonate
end
end
def the_right_wire
rand < 0.5 ? :blue : :red
end
def production_date
@production_date
end
end
|
1
2
3
4
5
6
7
8
| # lib/red_button.rb
require_relative 'bomb'
class RedButton
def press
Bomb.new.detonate(1)
end
end
|
JavaScript (ES6)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| // lib/bomb.js
export default class Bomb {
constructor() {
this.productionDate = new Date;
}
detonate(delay) {
let goOff = () => {
if (!this.disarmed) {
throw 'You are dead. No more coding.';
}
};
if (delay) {
setTimeout(goOff, delay);
}
else {
goOff();
}
}
cutWire(color) {
if (this.theRightWire() == color) {
return this.disarmed = true;
}
else {
this.detonate();
return false;
}
}
theRightWire() {
return Math.random() < 0.5 ? 'blue' : 'red'
}
getProductionDate() {
return this.productionDate;
}
}
|
1
2
3
4
5
6
7
8
| // lib/red_button.js
import Bomb from './bomb'
export default class RedButton {
press() {
new Bomb().detonate(1000);
}
}
|
Dependencies
RSpec
- RSpec - a testing framework,
- Timecop - a gem for testing time-dependent code.
1
2
3
4
| # Gemfile
source 'https://rubygems.org'
gem 'rspec'
gem 'timecop'
|
Mocha
- Mocha - a testing framework,
- Chai - an assertion library that offers three different interfaces, the BDD/expect interface is very similar to RSpec’s
- Sinon - a library for spying, stubbing, and mocking,
- Sinon-Chai - a set of pretty Chai assertions for Sinon’s spies,
- Proxyquire - a tool for messing with the way modules get imported, allows us to swap some dependencies of a module we want to test to mocks without changing anything in the module’s source code, works with ES6 modules and Babel,
- Timekeeper - a library for testing time-dependent code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // package.json
{
"scripts": {
"test": "mocha --recursive --compilers js:babel-register"
},
"dependencies": {
"babel": "^6.5.2",
"babel-preset-es2015": "^6.6.0"
},
"devDependencies": {
"babel-register": "^6.7.2",
"chai": "^3.5.0",
"mocha": "^2.4.5",
"proxyquire": "^1.7.4",
"sinon": "^1.17.3",
"sinon-chai": "^2.8.0",
"timekeeper": "0.0.5"
}
}
|
Setup
RSpec
The default setup of RSpec done with rspec --init
creates a setup file called spec_helper.rb
, which is loaded with --require spec_helper
in .rspec
.
Mocha
Mocha has no convention of a setup file. There is a way to load a file running the test suite with mocha --require foo.js
. In this file, however, Mocha is not available (we might want to have Mocha available to set global before
and after
hooks) . To solve this problem I have decided to create a test_helper.js
and simply include it in every test file. Mocha loads all .js
files from the test directory before running any tests, so that’s technically not necessary unless you want to run separate test files, which I definitely do.
1
2
3
4
5
6
7
8
9
10
11
12
13
| // test/test_helper.js
import chai from 'chai'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
import proxyquire from 'proxyquire'
import timekeeper from 'timekeeper'
chai.use(sinonChai);
global.sinon = sinon;
global.expect = chai.expect;
global.proxyquire = proxyquire;
global.timekeeper = timekeeper;
|
Basic syntax
It’s practically identical. We have the same describe
, context
, it
, before
and after
methods, with the exception of beforeEach
and afterEach
being separate methods.
RSpec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # spec/lib/bomb_spec.rb
require_relative '../../lib/bomb'
RSpec.describe Bomb do
let(:bomb) { Bomb.new }
describe '#detonate' do
it 'kills the developer' do
expect { bomb.detonate }.to raise_error(
RuntimeError,
'You are dead. No more coding.'
)
end
end
end
|
Mocha + Chai
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // test/lib/bomb_test.js
import '../test_helper'
import Bomb from '../../lib/bomb'
describe('Bomb', () => {
let bomb;
beforeEach(() => {
bomb = new Bomb;
});
describe('#detonate', () => {
it('kills the developer', () => {
expect(() => { bomb.detonate(); }).to.throw(
'You are dead. No more coding.'
);
});
});
});
|
Properties like to
, be
, been
, have
, has
etc. are there only for readability. They do not affect the assertions.
Overwriting dependencies
The RedButton
has a very dangerous dependency on Bomb
. We want to be able to test the button without actually detonating any bombs, so we need to get rid of the dangerous parts. In Ruby every class is accessible in the global scope, so that’s how we grab it and just rewrite its behavior.
RSpec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # spec/lib/red_button_spec.rb
require_relative '../../lib/red_button'
describe RedButton do
let(:button) { described_class.new }
let(:bomb) { double('bomb') }
before do
allow(Bomb).to receive(:new) { bomb }
allow(bomb).to receive(:detonate)
end
describe '#press' do
it 'can be pressed' do
expect { button.press }.not_to raise_error
end
end
end
|
Mocha + Proxyquire
In JavaScript, every file imports modules to its own local scope. To overwrite a dependency, we have to fiddle with the import. Proxyquire is the tool for that job. It takes two arguments. The first one is the path to the module whose dependencies we will change (lib/red_button.js
). This path is relative to the file where Proxyquire got imported, i.e. test/setup.js
. The second one is a mapping of paths to objects overwriting exports. Those paths are relative to the file whose dependencies we’re rewriting, i.e. lib/red_button.js
. Specifying { default: SafeBomb }
means that the object exported with the name default
(exports default class Bomb ...
) will be swapped to SafeBomb
. If there were any other exports in that file, they would not be affected. Proxyquire returns an object with all the exports from the imported file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // test/lib/red_button_test.js
import '../test_helper'
import Bomb from '../../lib/bomb'
describe('RedButton', () => {
let RedButton, button;
before(() => {
class SafeBomb extends Bomb { detonate() {} }
RedButton = proxyquire(
'../lib/red_button',
{ './bomb': { default: SafeBomb } }
).default;
});
beforeEach(() => {
button = new RedButton;
});
describe('#press', () => {
it('can be pressed', () => {
expect(() => { button.press(); }).not.to.throw();
});
});
});
|
Spying
Spies are fake methods that know how they were used - how many times they were called and with what arguments.
Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # spec/lib/red_button_spec.rb
require_relative '../../lib/red_button'
describe RedButton do
let(:button) { described_class.new }
let(:bomb) { spy('bomb') }
before do
allow(Bomb).to receive(:new) { bomb }
end
describe '#press' do
it 'detonates a bomb' do
button.press
expect(bomb).to have_received(:detonate)
.with(1).exactly(1).times
end
end
end
|
Mocha + Proxyquire + Sinon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| // test/lib/red_button_test.js
import '../test_helper'
describe('RedButton', () => {
let RedButton, button, detonate;
before(() => {
detonate = sinon.spy();
class SafeBomb extends Bomb {
constructor() { super(); this.detonate = detonate }
}
RedButton = proxyquire(
'../lib/red_button',
{ './bomb': { default: SafeBomb } }
).default;
});
beforeEach(() => {
button = new RedButton;
});
afterEach(() => {
detonate.reset();
});
describe('#press', () => {
it('detonates a bomb', () => {
button.press();
expect(detonate).to.have.been.calledWith(1000);
expect(detonate).to.have.been.calledOnce;
});
});
});
|
Stubbing
Stubs are fake methods pre-programmed to return a certain value or to throw an error. They can change their behavior depending on arguments received or how many times they were called previously.
RSpec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| # spec/lib/bomb_spec.rb
require_relative '../../lib/bomb'
RSpec.describe Bomb do
let(:bomb) { described_class.new }
describe '#cut_the_wire' do
before :each do
allow(bomb).to receive(:the_right_wire)
.and_return(:red, :blue, :red)
# allow(bomb).to receive(:the_right_wire)
# .with('foo').and_throw('bar')
end
it 'disarms the bomb' do
expect(bomb.cut_wire(:red)).to eq(true)
expect(bomb.cut_wire(:blue)).to eq(true)
expect(bomb.cut_wire(:red)).to eq(true)
expect(bomb.cut_wire(:red)).to eq(true)
expect(bomb.cut_wire(:red)).to eq(true)
end
end
end
|
Mocha + Sinon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // test/lib/bomb_test.js
import '../test_helper'
import Bomb from '../../lib/bomb'
describe('Bomb', () => {
let bomb;
beforeEach(() => {
bomb = new Bomb;
});
describe('#cutWire', () => {
beforeEach(() => {
bomb.theRightWire = sinon.stub();
bomb.theRightWire.onCall(0).returns('red');
bomb.theRightWire.onCall(1).returns('blue');
bomb.theRightWire.returns('red');
// bomb.theRightWire.withArgs('foo').throws('bar');
});
it('disarms the bomb', () => {
expect(bomb.cutWire('red')).to.be.true
expect(bomb.cutWire('blue')).to.be.true
expect(bomb.cutWire('red')).to.be.true
expect(bomb.cutWire('red')).to.be.true
expect(bomb.cutWire('red')).to.be.true
});
});
});
|
Time travel
RSpec + Timecop
1
2
3
4
5
6
7
8
9
10
| # spec/lib/generic_spec.rb
context 'in the past' do
before do
Timecop.travel(Time.at(1))
end
after do
Timecop.return
end
end
|
Mocha + Timekeeper
1
2
3
4
5
6
7
8
9
10
| // test/lib/generic_test.js
context('in the past', () => {
before(() => {
timekeeper.travel(new Date(1));
});
after(() => {
timekeeper.reset();
});
});
|
Freezing time
RSpec + Timecop
1
2
3
4
5
6
7
8
9
10
| # spec/lib/generic_spec.rb
context 'frozen in time' do
before do
Timecop.freeze(Time.at(1))
end
after do
Timecop.return
end
end
|
Mocha + Timekeeper
1
2
3
4
5
6
7
8
9
10
| // test/lib/generic_test.js
context('frozen in time', () => {
before(() => {
timekeeper.freeze(new Date(1));
});
after(() => {
timekeeper.reset();
});
});
|
Custom assertions
One of my favorite things to do to tidy up the test suite is defining custom assertions. In this example, I will write one for asserting that a bomb is old. Let’s assume a bomb is old if it was produced before 1976 (that’s in bomb years! I am not saying the same condition applies to people). But does that mean that any bomb produced in or after 1976 is not old by our standards? I don’t think so. The line between being old and not being old is blurry. I’m going to assume that a bomb is not old if it’s been produced in 1996 ar later. That’s not a problem because both RSpec and Chai allow for a different boolean expression in the case of a negated assertion.
RSpec
Definition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # spec/support/matchers/old.rb
require 'rspec/expectations'
RSpec::Matchers.define :be_old do
match do |actual|
actual.production_date.year < 1976
end
match_when_negated do |actual|
actual.production_date.year >= 1996
end
failure_message do |actual|
"expected object to be produced before 1976, "\
"but it was produced in #{actual.production_date.year}"
end
failure_message_when_negated do |actual|
"expected object not to be produced before 1996, "\
"but it was produced in #{actual.production_date.year}"
end
end
|
Import
1
2
| # spec/spec_helper.rb
require_relative 'support/matchers/old'
|
Usage
1
2
| # spec/lib/bomb_spec.rb
expect(bomb).to be_old
|
Mocha + Chai
Definition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // test/support/assertions/old.js
export default function (_chai, utils) {
utils.addProperty(_chai.Assertion.prototype, 'old', function() {
let condition,
message,
messageWhenNegated,
expected,
actual,
subject = this._obj;
if (utils.flag(this, 'negate')) {
expected = 1996;
}
else {
expected = 1976;
}
actual = subject.getProductionDate().getFullYear();
condition = actual < expected;
message =
'expected object to be produced before #{exp}, ' +
'but it was produced in #{act}';
messageWhenNegated =
'expected object not to be produced before #{exp}, ' +
'but it was produced in #{act}';
this.assert(
condition,
message,
messageWhenNegated,
expected,
actual
);
});
};
|
Import
1
2
3
| // test/test_helper.js
import old from './support/assertions/old';
chai.use(old);
|
Usage
1
2
| // test/lib/bom_test.js
expect(bomb).to.be.old;
|
In Chai, we can define an assertion as either a property or a method. There is an addMethod
method for the latter. Method assertions are, obviously, used like so:
1
| expect(bomb).to.be.old();
|
A really cool thing in Chai is flagging. Using utils.flag()
, which is either a getter or a setter, we can flag an assertion in a chainable method, to later read that flag in another method, used further in the chain. negate
is a built-in flag that gets set when the assertion chain includes not
(expect(...).not.to.equal(...)
). I recommend reading the documentation on Chai’s plugin concepts and plugin utilities.
Testing asynchronous methods
Something that rarely applies to Ruby, but almost always is needed in JavaScript.
Waiting for a callback
It is important to know that Mocha will not wait for all the callbacks to be executed. If this example below happens to be the only one to be run, it will pass. If other examples get run afterwards and thus there is enough time for this callback to be executed, it will fail. Do not do this!
1
2
3
4
5
6
| it('will not wait the callback', () => {
// this test is unpredictable!
setTimeout(() => {
expect(false).to.be.true;
}, 1000);
});
|
We can, however, force Mocha to wait. The it
function can take an argument, usually called done
, which is a method. Mocha will wait for the test suite to call this method before finishing running the example. We should call it inside the callback.
1
2
3
4
5
6
7
8
| // test/lib/generic_test.js
import '../test_helper'
describe('Mocha', () => {
it('can wait for a callback', (done) => {
setTimeout(() => { console.log('ding!'); done(); }, 1000);
});
});
|
It is also important to remember that a test taking longer than 2000ms will fail with a timeout.
Mocha reports the time of unusually slow tests.
1
2
3
| Mocha
ding!
✓ can wait for a callback (1004ms)
|
Manually moving time forward with Sinon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // test/lib/bomb_test.js
import '../test_helper'
import Bomb from '../../lib/bomb'
describe('Bomb', () => {
let bomb;
context('manually controlled time', () => {
let clock;
before(() => {
clock = sinon.useFakeTimers();
});
after(() => {
clock.restore();
});
it('kills the developer with a delay', () => {
bomb.detonate(10);
expect(() => { clock.tick(9) }).not.to.throw();
expect(() => { clock.tick(1) }).to.throw(
'You are dead. No more coding.'
);
});
});
});
|
Source
If you want to mess around with the tests I wrote, they can be found here.
Conclusion
It’s really easy to get into testing JavaScript with Mocha if you already know RSpec. Chai has pretty awesome utilities for adding new assertions and extending existing ones. I would recommend using Mocha with Chai to my fellow RSpec fans, it feels really familiar.