Implicit vs Explicit Tests

Jul 19th, 2018

javascript

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:

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