There are many ways to write automated tests, and you may have heard about test doubles as a way to make testing easier. Some parts of our code can be hard to test, so we replace those parts with fake test doubles. The main reason for this is that external components, such as databases and web services, cannot easily be restored to a consistent state for each test run.

For example, if you’re testing whether a new user account is created successfully, you’ll need to provide a randomly generated email address or phone number during each test. Otherwise, the test would fail. There are workarounds, of course –you could delete the user account after the test– but that adds an extra step to an already slow process. This brings me to the second reason why test doubles are preferred: external components can be much slower than a test double simply returning a success value.

“Now wait,” you might ask, “aren’t we supposed to test whether a new user has actually been created in the database? Isn’t that the whole point?” Well, not really. The actual account creation process is probably the least interesting part to test because the database will almost always do its job and create the corresponding user record. What we really want to test is under what conditions the account creation would fail – for example, when a duplicate email address is used or when some fields are missing or contain invalid data. These control conditions, which fall under “business logic,” are typically the most important to test.

It appears that there are about half a dozen types of test doubles with interesting names like fakes, spies, and mocks. There are also lengthy articles explaining the differences between them. Apparently, there’s a need for all those variations, but for me a test double is a test double. In an expressive language like JavaScript, with its support for first-class functions and closures, you can likely go a long way by simply tracking which functions are called with which arguments to determine whether your code dealing with external components is working as it should.

So, I came up with a tiny JavaScript utility called fn-tester to record function calls and define test doubles. I deliberately kept it simple – the first version is only 14 lines long (except for tests of course), and I hope it stays that way.

fn-tester has only three options and one method. It requires running functions indirectly, using a variation of the Command design pattern. This approach is necessary to intercept all function calls and preserve the value of “this”.

const fn = require('fn-tester');

// Instead of parentObj.methodName(argument1, argument2, ...), use:
fn.run(parentObj, 'methodName', argument1, argument2, ...);

// For simple functions, use:
fn.run(myFunction, null, argument1, argument2, ...);

Now, you might argue that changing all function calls like that isn’t simple at all. However, there’s no need to modify every single function call. In a typical Model-View-Controller (MVC) application, it’s usually enough to focus on controller-level functions that manage interactions between multiple services. Services that handle databases and remote APIs will need test doubles in any case.

fn-tester can be configured by simply changing the test property, and the defaults are:

fn.test = { enabled: false, calls: [], doubles: [] };

When enabled is true, fn-tester starts to record function calls in calls. It also runs the test double functions stored in doubles (if any) instead of the originals.

Here is how to define test doubles for a simple user account creation controller:

// (...)

const userService = {
    hashPassword: function (password) {
        return bcrypt.hash(password, 8);
    },

    getUserByEmail: function (email) {
        return db('user').where('email', email).then(_.head);
    },

    insertUser: function (email, name, password) {
        return db('user').insert({ email, name, password }).returning('id');
    },
};

function createUser(email, name, password) {
    return fn.run(userService, 'getUserByEmail', email).then((existingUser) => {
        if (existingUser) {
            return Promise.reject('error.duplicateEmail');
        }
        return fn
            .run(userService, 'hashPassword', password)
            .then((passwordHash) => fn.run(userService, 'insertUser', email, name, passwordHash));
    });
}

fn.test = {
    enabled: true,
    calls: [],
    doubles: [
        function getUserByEmail() {
            return Promise.resolve();
        },
        function hashPassword() {
            return Promise.resolve('hash');
        },
        function insertUser() {
            return Promise.resolve(1);
        },
    ],
};

fn.run(createUser, null, 'some@email', 'name', 'pass').then(() => console.log(fn.test.calls));

When fn-tester finds a function in test.doubles with the same name as the function called, it runs the test double instead of the original. test.calls contains the function calls complete with the arguments passed.

[
    ['createUser', 'some@email', 'name', 'pass'],
    ['getUserByEmail', 'some@email'],
    ['hashPassword', 'pass'],
    ['insertUser', 'some@email', 'name', 'hash'],
];

test.calls is useful to determine if a function is called and has received the correct arguments.

Finally, here is an example of how fn-tester can be used with Chai and Mocha, a popular assertion library and testing framework respectively:

const fn = require('fn-tester');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const should = chai.should();
chai.use(chaiAsPromised).should();

// (...)

describe('createUser', function () {
    it('should go through with account creation', async function () {
        fn.test = {
            enabled: true,
            calls: [],
            doubles: [
                function getUserByEmail() {
                    return Promise.resolve();
                },
                function hashPassword() {
                    return Promise.resolve('hash');
                },
                function insertUser() {
                    return Promise.resolve(1);
                },
            ],
        };

        const result = await fn.run(createUser, null, 'some@email', 'name', 'pass');
        fn.test.calls.should.be
            .an('array')
            .that.deep.includes(['getUserByEmail', 'some@email'])
            .and.that.deep.includes(['hashPassword', 'pass'])
            .and.that.deep.includes(['insertUser', 'some@email', 'name', 'hash']);
    });
});

Related: