Dan D Kim

Let's share stories

Do you even stub bro?

2019-07-15 Dan D. Kimjavascript

"Protection" 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?!

Ugh

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.

Bunch of wires connecting one panel to another, to symbolize 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
})

Calm 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.