Software engineers are almost never writing code in a vacuum. There are always dependencies that are not controlled by the team and are required for the application to function correctly. These dependencies may have a persistent state (like databases), have real-world limitations (like a slow or inconsistent API) or may only exist as an interface from the team that is building the dependency. 

For this reason, writing testable code requires adhering to architectural patterns (like the SOLID principles) that allow you to substitute external dependencies with test doubles. This enables automated tests to be produced that test the required behavior of the software without being tied to outside factors.

A test double is an object that mimics the behavior of a real object in a controlled way. A test double adheres to the same interface that the real object supplies, but the results of calls to the object methods have been replaced in order to return expected values. 

The behavior of test doubles can vary in complexity but are usually classified in a few different levels: stub objects, mock objects, spy objects and fake objects. The following examples will illustrate these levels of complexity for a fictitious database access object:

class UserDatabase {
    ...
    connect(): number {
        //Connect to the database
        //returns the value '1' if successful
        ...
    }

    getUser(id: string): User {
        //lookup the supplied user id and return the associcated user object
        ...
    }
}

For this object the interface specifies two methods: connect which supplies the connection to the database, and a sample query getUser that returns some information from the database about a user with a specified id.

The examples in this article illustrate what test doubles for this database object might look like. Generally, you would not write these doubles by hand. It is much more efficient to use a mocking framework, but this will help you to understand what is going on behind the scenes.

Stub object

In the simplest case (usually referred to as a stub), the real object's methods are replaced by simple statements that mimic expected behavior. For the above database access object, a stub may look something like this:

class StubUserDatabase {
    ...
    connect(): number {
        return 1;
    }

    getUser(id: string): User {
        return User({
            id: id,
            firstName: "Bob",
            lastName: "Loblaw",
            description: "Lawyer and Blogger",
            url: "https://www.bobloblawslawblog.com"
        });
    }
}

The stub object does not provide any information to the test that you are performing, but it behaves in a way that is consistent between test runs. In this case, every time you run a test on code that is calling this object, the "database" will connect successfully and will return a user object no matter which id you specify. 

A real database object would not behave as consistently, potentially causing tests to fail erroneously.

For most test frameworks, stubs can be customized to return specific values on a test-by-test basis. This can be helpful if you would like to test specific events, such as a connection failure or a user that is not found.

Mock object

At the next level of test double behavior, the object may record whether methods were called and/or what variables were passed to those methods. This is what is usually referred to when the word "mock" is used. For the example above, it may look like this:

class MockUserDatabase {
	...
    constructor() {
        this.mockInfo = {
            connect: {
                wasCalled: false
            },
            getUser: {
                wasCalled: false,
                arg1: null
            }
        }
    }
    
    getMockInfo() {
        return this.mockInfo;
    }

    connect(): number {
        this.mockInfo.connect.wasCalled = true;

        return 1;
    }

    getUser(id: string): User {
        this.mockInfo.getUser.wasCalled = true;
        this.mockInfo.getUser.arg1 = id;

        return User({
            id: id,
            firstName: "Bob",
            lastName: "Loblaw",
            description: "Lawyer and Blogger",
            url: "https://www.bobloblawslawblog.com"
        });
        return mockUser;
    }
}

In this object, the method getMockInfo has been included to provide an accessor for the history that the mock records. If you wanted to know if the method connect had been called on the mock object, you could request this information and the mock would respond with a boolean value that tells you if the code has attempted to connect to the database. This allows us to add assertions to our test code — we can check that the necessary methods were called and that the correct arguments were supplied to the methods by our production code.

Spy objects

A spy is an object that intercepts calls to a real dependency and allows you to assert that particular methods were called with the correct parameters. This type of object does not substitute the dependency at all, it merely records the interactions with that dependency. 

Spy objects are primarily used as a test tool in cases where there is no control over the use of the dependency and where test coverage is desired. Since spy objects call through to the real object, there is no advantage in using a spy when speed/reliability/repeatability of test results is desired.

class SpyUserDatabase {
	...
    constructor(database) {
    	this.database = database;
        this.spyInfo = {
            connect: {
                wasCalled: false
            },
            getUser: {
                wasCalled: false,
                arg1: null
            }
        }
    }
    
    getSpyInfo() {
        return this.spyInfo;
    }

    connect(): number {
        this.spyInfo.connect.wasCalled = true;

        return this.database.connect();
    }

    getUser(id: string): User {
        this.mockInfo.getUser.wasCalled = true;
        this.mockInfo.getUser.arg1 = id;

        return this.database.getUser(id);
    }
}

Similar to the mock object, the spy contains an accessor that allows you to assert that methods were called with the correct arguments, however, you can also assert against the results in the real database object in this case. There are certain cases where this may be desirable, for example, if you needed to test both that your code was functioning and that the code was connected to and interfacing with the database correctly. This may be useful for service-level automated tests.

Fake objects

At the next level of complexity, a test double may supply much of the expected behavior of the real object. This is generally referred to as a "fake." It is usually a real object, but is not the object that the code depends on in production. For the above example, this may be an in-memory database that is populated with test data for the purposes of the tests.

class FakeUserDatabase {
	users: Array<User> = [];
    ...
    connect(): number {
        return 1;
    }
    
    addUser(user: User) {
    	users.push(User);
    }

    getUser(id: string): User {
        return users.find(user => user.id === id)
    }
}

In the example above, the fake replaces calls to the real database with an in-memory array. For each test that requires specific data in the database, the fake will have to be "seeded" with test data. 

The advantage of a fake over testing against the real database is that the state of the fake can be more easily controlled. The test data can be added and cleaned up without concern for the production data that needs to be preserved. A fake object is generally the closest you can get to testing your code against the real thing, however, it is also the most complicated to set up and maintain.

Conclusion

Regardless of which test double you choose to use for your automated test harness, employing one will allow you to more reliably exercise your code and build confidence that what you are creating will function when put into production. 

Understanding how each type of test double works can help you to choose the correct solution for the kind of test that you require. For a deeper dive into the use of test doubles, this article by Martin Fowler is a great resource. If you would like to put these concepts into practice by implementing a mock object of your own, check out this lab on Mocking External Dependencies.

Learn more about our development expertise.
Explore