There are many ways to write automated tests for testing code, and you may have heard about test doubles for making testing easier. Some of the code we write is hard to test, so we replace those parts with fake test doubles, and the number one reason for that is: External components such as databases and web services cannot easily be restored to a state where they would behave the same for each test run.

For example, if you are testing if a new user account is created successfully, you need to make sure to provide a randomly generated e-mail address or phone number during each test. Otherwise, the test would fail. There are other workarounds of course, you can always delete the user account after the test, but that adds an additional step to an already slow process, which brings me to the second reason why test doubles are preferred – external components can be much slower than some test double simply returning a success value.

“Now wait, aren’t we supposed to test if a new user has actually been created in the database? Isn’t that the whole point?”, you may ask. Well, not really. The actual account creation process is probably the least interesting part to test because almost 100% of the time the database will do its job and create the corresponding user record. What we really want to test is under which conditions the account creation would fail like when a duplicate e-mail address is used or some fields have missing/invalid data. These control conditions, which fall under “business logic”, are normally tested the most.

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 to use. It requires running functions indirectly, employing a variation of the Command design pattern, which is necessary to intercept all function calls and preserve the value of “this”.

var 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 may argue that changing all function calls like that isn’t simple at all, but there’s no need to change every single function call. In a typical Model-View-Controller (MVC) application, modifying controller-level functions that deal with running multiple services should mostly be sufficient. Services that deal with databases and remote APIs need to have 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:

// (...)

var 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:

var fn = require('fn-tester');
var chai = require('chai');
var chaiAsPromised = require('chai-as-promised');
var 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']);
    });
});

If you have any suggestions or comments, please let me know.


Related: