Do you even stub bro?
Covering your code with tests is just like wearing protection - it may be a bother but it will prevent unwanted scenarios.
So, I was writing tests this other day using mockery
for mocking my mocks, and came across some stupid feature / bug.
The example code has been dumb-ed down for getting my point across.
The Code follows standard-js
syntax.
Problem
Let’s say we had a model called accountant that had a function calculateTaxCredits
based on a social insurance number.
Accountant.js
const formulas = require('formulas') // Some library
function calculateTaxCredits (socialNumber) {
return formulas.calculateTaxCredits(socialNumber)
}
Notice how it imports a library called formulas
.
Let’s unit-test this.
First, enable mockery.
mockery.enable({
warnOnReplace: false, // don't need these warnings
warnOnUnregistered: false // don't need these warnings
})
Import the accountant.js
module.
const accountant = require('../../../source/models/accountant')
Set up test variables.
const testSIN = 123456789
const expectedTaxes = 100
Create a Sinon stub to mock the calculateTaxCredits
function.
const stubCalculateTaxCredits = sinon.stub()
Create the mock for the formulas
library.
const formulaMock = {
calculateTaxCredits: stubCalculateTaxCredits
}
Then register the mock with formulas
library.
mockery.registerMock('formulas', formulaMock)
Now, we can run a test to see what happens.
const testSIN = 123456789
const expectedTaxes = 100
...
stubCalculateTaxCredits.returns(expectedTaxes)
const result = accountant.calculateTaxCredits(testSIN)
expect(result).to.equal(expectedTaxes)
And don’t forget to disable mockery after.
mockery.disable()
Wait, how will I validate that formulaMock
will be called instead of the formulas
library?
For this post, I’m going to make a modification to the library formulas
so that I can clearly know if it’s being called or not.
Formulas Library
function calculateTaxCredits (socialNumber) {
return 0 // DUN DUN DUN
}
Now, let’s run our test and see the results.
calculateTaxCredits
should succeed:
AssertionError: expected 0 to equal 100
+ expected - actual
-0
+100
Expectation - The mock should return 100.
Reality - Mock was never called.
Here is the full code, for reference.
Accountant.spec.js
describe('calculateTaxCredits', () => {
before(() => {
mockery.enable({
warnOnReplace: false, // don't need these warnings
warnOnUnregistered: false // don't need these warnings
})
})
after(() => {
mockery.disable()
})
it('should succeed', () => {
// Arrange
const accountant = require('../../../source/models/accountant')
const testSIN = 123456789
const expectedTaxes = 100
const stubCalculateTaxCredits = sinon.stub()
const formulaMock = {
calculateTaxCredits: stubCalculateTaxCredits
}
mockery.registerMock('formulas', formulaMock)
// Act
const result = accountant.calculateTaxCredits(testSIN)
// Assert
expect(result).to.equal(expectedTaxes)
})
Use a clean cache
So, how do I fix my test?
A quick Google search leads me to this related Stackoverflow post.
Basically, it says enable mockery with useCleanCache
set to true.
If you read the documentation, it does indeed sound like a fix.
Basically, Node will cache module exports but using a clean cache will force all the require
to load again.
Updated setup
before(() => {
mockery.enable({
warnOnReplace: false, // don't need these warnings
warnOnUnregistered: false, // don't need these warnings
useCleanCache: true
})
})
Updated output
calculateTaxCredits
should succeed:
AssertionError: expected 0 to equal 100
+ expected - actual
-0
+100
Still doesn’t work. This is expected, as explained below.
registerMock
before require
To make it work, you need to put the registerMock
before the require
calls.
mockery.registerMock('formulas', formulaMock)
// Import module after registering with mockery
const accountant = require('../../../source/models/accountant')
Updated output
calculateTaxCredits
√ should succeed (88ms)
This works because the accountant
module is imported after the mocks have been registered. mockery
works via the cache, and if a module is cached before mockery
registers anything, it won’t work.
Full test block for reference
describe('calculateTaxCredits', () => {
before(() => {
mockery.enable({
warnOnReplace: false,
warnOnUnregistered: false,
useCleanCache: true
})
})
after(() => {
mockery.disable()
})
it('should succeed', () => {
// Arrange
const testSIN = 123456789
const expectedTaxes = 100
const stubCalculateTaxCredits = sinon.stub().returns(expectedTaxes)
const formulaMock = {
calculateTaxCredits: stubCalculateTaxCredits
}
mockery.registerMock('formulas', formulaMock)
// Require comes after mock
const accountant = require('../../../source/models/accountant')
// Act
const result = accountant.calculateTaxCredits(testSIN)
// Assert
expect(result).to.equal(expectedTaxes)
})
}
Seriously though?!
What a bs rule though. My example is simple but this can get real shitty in an actual codebase.
- What if you have previously-imported modules setup inside
before
or other global blocks? - The error message doesn’t explain why your test is failing. Here is the stack trace from the failed test
The entire log from failing example above
1) calculateTaxCredits
should succeed:
AssertionError: expected 0 to equal 100
+ expected - actual
-0
+100
at Proxy.assertEqual (node_modules\chai\lib\chai\core\assertions.js:1026:12)
at Proxy.methodWrapper (node_modules\chai\lib\chai\utils\addMethod.js:57:25)
at doAsserterAsyncAndAddThen (node_modules\chai-as-promised\lib\chai-as-promised.js:289:22)
at Proxy.<anonymous> (node_modules\chai-as-promised\lib\chai-as-promised.js:255:20)
at Proxy.overwritingMethodWrapper (node_modules\chai\lib\chai\utils\overwriteMethod.js:78:33)
at Assertion.<anonymous> (node_modules\dirty-chai\lib\dirty-chai.js:115:25)
at Assertion.overwritingMethodWrapper (node_modules\chai\lib\chai\utils\overwriteMethod.js:78:33)
at Context.it (test\unitTests\models\accountant.spec.js:55:23)
I searched for a fix, and ta-dah~. Here came rewire.
Rewire basically let’s you “wire” a method call to some arbitrary method. Here’s a quick example.
First, you import the module the same way as you do a normal require
.
// Path is same as require
const accountant = rewire('../../../source/models/accountant')
Create the stub.
// Method stub
const stubCalculateTaxCredits = sinon.stub()
Wire the stub to the method calculateTaxCredits
.
// Wiring
accountant.__set__('calculateTaxCredits', stubCalculateTaxCredits)
And you are done! Whenever accountant.calculateTaxCredits
is called, it will call the stub.
stubCalculateTaxCredits.returns(100)
accountant.calculateTaxCredits(testSIN) // 100
The entire code for reference
// Path is same as require
const accountant = rewire('../../../source/models/accountant')
// Method stub
const stubCalculateTaxCredits = sinon.stub()
accountant.__set__('calculateTaxCredits', stubCalculateTaxCredits)
You can also easily wire multiple functions.
accountant.__set__({
'calculateTaxCredits': stubCalculateTaxCredits
'calculateFee': stubCalculateFee
})
Voila. Now I can write some tests in peace.
On a serious note, I’m not saying mockery
is useless. There are scenarios where it’s superior. I’m just outlining a tip for others who may have the same struggle as I did.