[Learned This Week]: Taming Dependencies in Integration Tests

"Learned This Week" is a series where I briefly discuss concepts that I have just learned newly.

Integration Tests and Stubs

Integration tests are used to test interrelated parts of an app. Some typical candidates for integration tests are code that interacts with a database and code that relies upon different standalone modules within a codebase. Often these tests will require you to know and/or control the output and side-effects of some of the interrelated modules, and this is where stubbing comes in. Very briefly, stubs allow you to create an alternate implementation of a function, with the added ability to track useful information such as when that function is executed, how many times it has been executed, and what arguments it was executed with. Therefore, stubs give you the ability to define how those interrelated modules will behave during your tests. So what do you do when you want to test a function that relies on values defined in other scripts? You could use one of the 2 packages I learned about this week: proxyquire and esmock.

proxyquire

The project's README summarizes its functionality succinctly:

Proxies nodejs's require in order to make overriding dependencies during testing easy while staying totally unobtrusive

In other words, proxyquire provides a way to require() modules and specify which of their imports are replaced by the custom functionality you have defined in your tests. I won't repeat basic code samples here because the README provides good examples. One feature I found particularly helpful is that proxyquire detects which of the require()d values are not stubbed, and ensures that the file continues to use the original implementation for those values. And even better, it gives you the flexibility to disable this behavior via the "call thru" option. Pretty neat!

Beware though, you will need to supply your stubbed dependencies using the exact same path that the parent file uses to import them. To illustrate this, consider a project with file structure as follows:

📦ballonDor
 📂modules
   - 📜helpers.js
   - 📜action.js
 📂test
   - 📜action.spec.js

The scripts have the following contents:

// modules/helpers.js
module.exports { predictWinner: () => Math.random() < 0.5 ? 'player1' : 'player2' };
// modules/action.js
const { predictWinner } = require('./helpers');

const playerNamesMap = new Map([
  ['player1', 'Kylian Mbappe'],
  ['player2', 'Erling Haaland'],
  ['player3', 'Karim Benzema']
]);
const getWinnerName = () => playerNamesMap.get(predictWinner());

module.exports = { getWinnerName };

We want to test that the getWinnerName function gets the correct name, so we do this by supplying a value ("player3") that it would normally not receive. We can see that predictWinner only returns "player1" or "player2", so we need a custom implementation of predictWinner that will return "player3". The path to the stubbed import should NOT be relative to the test file, but instead should be the exact same as the path used in the module file. In Code This Means that the correct way to stub the import would be as follows:

// test/action.spec.js
const { expect } = require('chai');
const sinon = require('sinon');

describe(`getWinnerName`, function () {
  it(`picks the correct name`, function() {
    const customPredictWinner = sinon.stub().returns('player3');
    const { getWinnerName } = proxyquire('../modules/action', {
      // "./helpers" is the same path used in modules/action.js
      './helpers': { predictWinner: customPredictWinner },
    });
    expect(getWinnerName()).to.equal('Karim Benzema');
    expect(customPredictWinner.called).to.be.true;
  });
});

Notice that the first argument of proxyquire is the correct path to the module being imported relative to the test file, but the customPredictWinner stub is supplied using the exact same path that's present in modules/action.js.

Unfortunately, proxyquire doesn't support ES module imports, which brings us to the second package: esmock.

esmock

esmock is very similar to proxyquire, providing essentially the same functionality but with a focus on environments where ES modules are imported using the import keyword rather than require. Its README gives a similarly straightforward description:

esmock provides native ESM import mocking for unit tests.

esmock also covers modules loaded with dynamic import()s by using a slightly different syntax, esmock.p('pathToDynamicallyImportedModule'). One potential gotcha with esmock is that it is always asynchronous, so all the assertions that rely on esmock imports will need to await the import statements and therefore be contained within async functions. Probably not a big deal in most scenarios, but something to be aware of nevertheless.

Conclusion

Both proxyquire and esmock do the same job quite well: they give you an efficient and easy way to control the behavior of imported dependencies in your tests. Before I discovered these options, I would use the much more tedious strategy of globally stubbing any imports whose behavior I wanted to control, and then resetting to the original behavior when necessary. With proxyquire and esmock, I can now do the opposite by preserving the original behavior except when I something custom, which is quite nice.

Happy learning!