How should we use stubs?
When writing unit tests, a developer will often find themselves needing to override internal functionality that is consumed by the code under test. This is achieved by using stubs.
Let's say for example you want to test that a given functional unit utilizes the output of another, tangential functional unit:
import {getData} from '../utils/get-data';
import {mapData} from '../utils/map-data';
export function myFunction(input) {
const data = getData(input);
const output = mapData(data);
return output;
}
In this example, myFunction
utilizes getData
to derive a data
value using the former's input. When we write unit tests for myFunction
, we could take one of two approaches:
- Assume the logical internals of
getData
as well asmapData
, and manually reproduce them in our tests to validateoutput
. - Use stubs!
Using Stubs
If it weren't already clear, the answer here is definitely to use stubs. getData
and mapData
should already be tested in isolation, and duplicating that test logic is largely unnecessary. There are, of course, exceptions to this statement; however more often than not if something is imported/included/required in your code, stub it in your tests!
Let's see an example of how we might test myFunction
:
import sinon from 'sinon';
import {expect} from 'chai';
import * as GetDataUtil from '../src/utils/get-data';
import * as MapDataUtil from '../src/utils/map-data';
import {myFunction} from '../src/my-function';
describe('myFunction', () => {
it('should convert input into output', () => {
const input = Symbol('my input');
const data = Symbol('data');
const expectedOutput = Symbol('the output I expect');
const getData = sinon.stub(GetDataUtil, 'getData');
const mapData = sinon.stub(MapDataUtil, 'mapData');
getData.returns(data);
mapData.returns(expectedOutput);
const output = myFunction(input);
expect(output).to.equal(expectedOutput);
});
});
This is a pretty basic test utilizing stubs. We're importing both getData
and mapData
and using sinon.js
to stub them. Now when we invoke myFunction
from the context of our test, neither getData
or mapData
will actually be invoked; instead, the new stub functions we've created will be called, and they will return what we've declared that they will return.
Using Stubs Correctly
A keen eye will notice that this test is woefully incomplete. If we were test driving myFunction
we could easily pass the tests without even using getData
! In fact, we could pass the test by effectively aliasing mapData
:
import {mapData} from '../utils/map-data';
export function myFunction() {
return mapData();
}
This will lead to a passing unit test, but likely won't accomplish what we want myFunction
to do when run in the broader context of our application. Because we built this theoretical system we know that mapData
will only actually return expectedOutput
when its provided with data
and that data
is only available from passing input
into getData
. But how can we configure our stubs (and subsequently, our tests) to reflect this reality?
Explicitly Testing Stubs
One very common approach is to analyze the stubs after invoking the unit under test and verifying that they were invoked correctly:
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import chai, {expect} from 'chai';
import * as GetDataUtil from '../src/utils/get-data';
import * as MapDataUtil from '../src/utils/map-data';
import {myFunction} from '../src/my-function';
chai.use(sinonChai);
describe('myFunction', () => {
it('should convert input into output', () => {
const input = Symbol('my input');
const data = Symbol('data');
const expectedOutput = Symbol('the output I expect');
const getData = sinon.stub(GetDataUtil, 'getData');
const mapData = sinon.stub(MapDataUtil, 'mapData');
getData.returns(data);
mapData.returns(expectedOutput);
const output = myFunction(input);
expect(output).to.equal(expectedOutput);
expect(getData).to.be.calledWithExactly(input);
expect(mapData).to.be.calledWithExactly(data);
});
});
You'll see that we've included the sinon-chai
package and configured chai
to use it. This module exposes some new assertions in chai
that we can use for this sort of explicit stub testing. Its certainly not the only way to do so, however.
This test is a lot better, but again, exposes a potential for abuse in our production code:
import {getData} from '../utils/get-data';
import {mapData} from '../utils/map-data';
export function myFunction(input) {
getData(input);
const data = getData();
mapData(data);
return mapData();
}
This code would pass our test. output
(in the context of our test) is the return value of mapData
, mapData
is invoked with the output of getData
, and getData
is invoked with input
. All of our expectations are met, but there's nothing forcing them to be "glued" together.
You'll notice that we're only able to skirt around our business requirements and still pass the tests by making arbitrary invocations of our getData
and mapData
utilities. Luckily, sinon
stubs come with a property we can observe that can force into the right direction:
describe('myFunction', () => {
it('should convert input into output', () => {
const input = Symbol('my input');
const data = Symbol('data');
const expectedOutput = Symbol('the output I expect');
const getData = sinon.stub(GetDataUtil, 'getData');
const mapData = sinon.stub(MapDataUtil, 'mapData');
getData.returns(data);
mapData.returns(expectedOutput);
const output = myFunction(input);
expect(output).to.equal(expectedOutput);
expect(getData.callCount).to.equal(1);
expect(getData).to.be.calledWithExactly(input);
expect(mapData.callCount).to.equal(1);
expect(mapData).to.be.calledWithExactly(data);
});
});
Now our test fail again, because those arbitrary invocations of our utilities will cause our callCount
checks to fail.
export function myFunction(input) {
const data = getData(input);
const output = mapData(data);
return output;
}
All of this is a roundabout way of forcing the correct interaction with stubbed dependencies. This was a lot of work to validate something that (I would consider) to be tangential to the actual business concerns that myFunction
was originall built to address. Let's see if there's a simpler approach that doesn't conflate testing our units with testing what exactly goes on inside of them.
Implicitly Testing Stubs
Sinon
offers a lot of ways to interact with stubs. Whether its by observing the number of times they've been called to enforcing a specific return value, if its something you want to know about or do with a function, sinon
stubs probably have your back.
While we're on the subject of forcing a return value from a stub, wouldn't it be great if we could make that return value conditional? What if getData
only returned data
when it was provided with input
? What if mapData
only returned output
when it was provided with data
? Introducing: .withArgs
.
const expectedInput = 'some input';
const expectedOutput = 'expected output';
const myStub = sinon.stub();
myStub.withArgs(expectedInput).returns(expectedOutput);
myStub(); // returns undefined
myStub(expectedInput); // returns 'expected output'
Using .withArgs
, we can configure our dependencies seperately from our actual unit test parameters. In this way, our unit tests reads as if its actually testing the business logic under test, and not enforcing some very specific implementation upon the developer writing code to satisfy our tests:
import sinon from 'sinon';
import {expect} from 'chai';
import * as GetDataUtil from '../src/utils/get-data';
import * as MapDataUtil from '../src/utils/map-data';
import {myFunction} from '../src/my-function';
describe('myFunction', () => {
it('should convert input into output', () => {
const input = Symbol('my input');
const data = Symbol('data');
const expectedOutput = Symbol('the output I expect');
const getData = sinon.stub(GetDataUtil, 'getData');
const mapData = sinon.stub(MapDataUtil, 'mapData');
getData.withArgs(input).returns(data);
mapData.withArgs(data).returns(expectedOutput);
const output = myFunction(input);
expect(output).to.equal(expectedOutput);
});
});
You'll likely notice that there is now nothing enforcing that we don't use getData
and mapData
over and over again. No callCount
validation here. As long as myFunction
returns expectedOutput
, this test will pass.
This is where things get a little subjective. I would argue the case that we shouldn't care. Our dependencies (getData
and mapData
) are configured to act as they would in production. input
and output
are both enforced explicitly. How one gets from point A (input
) to point B (output
) is enforced implicitly by the configuration of our stubs. Any superfluous usage of our dependencies should be cleaned up by refactoring (a process that should be separate from TDD).
Make Up Your Own Mind
Again, I'm of the opinion that this is how tests should be written. This by no means is to suggest that this is the only way tests can be written, nor that its the right approach for everyone.
Tests are a good way to express expectations of production code. They're especially useful if you're pair-programming in an environment where one person writes tests and the other writes code to "beat" those tests. How we express those expectations to both current and future developers is incredibly important, and isn't a decision that should be taken lightly.
Choose wisely.