Codesmith Blog

Spies, Stubs, and Mocks: An Introduction to Testing Strategies

Written by Abbey Campbell | Jan 30, 2020 8:00:00 AM

You've put in days of work on your current feature, and are about to integrate it with your team’s master version. You’ve built out a lot of new functionality, and even made use of a new library or two. It looks like everything is working, but how can you be sure your code will play nicely with the team’s code?

In this blog post, we'll break down some helpful methods for running tests for your code and the key concepts that you need to understand: spies, stubs, and mocks.

The Importance of Writing Good Unit Tests

As developers, we're well aware of the importance of testing. We add features to an increasingly complex application, and it becomes paramount to ensure that none of the code we are adding breaks any existing functionality.

This is where Test-Driven Development (TDD) comes into play. Test-Driven Development encourages developers to write tests before writing the actual code. By doing so, you not only ensure that your code meets its intended functionality, but you also maintain a higher standard of quality throughout the development process.

Unit tests are a fundamental part of any testing process—they have strict outcomes, are relatively quick to write, and allow developers to identify issues early in the development cycle.

Writing good unit tests depends heavily on isolation—making sure we're only testing the functionality of one thing at a time, in order to verify the integrity of a specific part of the application. If your test is dependent on external factors and suddenly starts to fail, it can be a Herculean task to figure out exactly what is causing the failing result.

Since applications tend to be constructed of interlocking modules, external fetches, and conditional logic, how can we go about teasing apart the functionality, so we can test it piece by piece?

This is where spies come in.

What Are Spies in Test Automation?

Spies are essentially functions that have the ability to track information about the instance of a function being called. In a well-structured test environment, they're extremely useful for testing purposes because they can record many types of data, from the number of invocations of a specific function to the arguments passed into it and the resulting returned value. Here’s an example:

/** I’m using the popular testing framework Sinon.js for our examples,
* since its sole focus is spies, stubs, and mocks, it’s relatively
* simple and is compatible with other unit-testing frameworks.*/
const sinon = require('sinon');

// create a spy
const ourSpy = sinon.spy();

// invoke the spy
ourSpy('testing', 1, 2, 3);

// use methods to access specifics about the invocation
console.log(ourSpy.called); // logs -> true
console.log(ourSpy.firstCall.args); // logs -> [ 'testing', 1, 2, 3 ]
console.log(ourSpy.secondCall) // logs -> null

In the above example, we created an anonymous spy called ourSpy, a call to which returns an object with properties that contain information about the specific call. If we want to make sure the function was invoked, we can use the ‘called’ method to check. We can also use the firstCall property to see exactly what arguments ourSpy was called with, but if we try to utilize the secondCall property, we get null, since ourSpy was only called once!

We can also use our spies to wrap our own functions:

// our function
const users = {
createUser: function(firstName, lastName, age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}

// set up a spy on the function
const spyOnCreateUser = sinon.spy(users, 'createUser')

// invoke the function with some data
users.createUser('Peregrin', 'Took', 29);
users.createUser('Samwise', 'Gamgee', 36);
users.createUser('Bilbo', 'Baggins', 129);

// use the spy to see information about function calls
console.log(spyOnCreateUser.callCount) // logs -> 3
console.log(spyOnCreateUser.calledWith('Bilbo')) // logs -> true

// clean up
spyOnCreateUser.restore();

Notice that here, we use a method called restore on our spy once we’ve completed our test. This resets our function and removes the spy—if we leave the spy in place, it could affect future tests on the same function and give us misleading results.

We started out with a pretty basic example. We could use a spy to wrap a specific method on an object to gather precise data about what that method is doing (you could also spy on the object in its entirety, but this is not optimal since, remember, our goal is to break our code down into in most granular components!). This is especially useful when testing methods that are part of dependencies or libraries you haven’t written yourself, and you want to be especially sure they are executing as expected.

Test Doubles in Software Testing

If our code were an espionage movie, our hero might disguise herself as catering staff in order to infiltrate a gala full of high-profile people and obtain sensitive information. She creates an alternative version of herself, a double, and uses it to move through her world and gather information accordingly.

Similarly, we can tell our functions to perform as specific, alternate versions of themselves. Gerard Meszaros, in his book xUnit Test Patterns, calls this strategy creating a “test double”—a reference to the stunt doubles required to perform technical or dangerous maneuvers in film.

Stunt double

We have already examined spies, which are the simplest test doubles—simply tracking information about function calls without actually changing the nature of the function itself.

In the above examples of spies, the outcome of a function is only affected by the specific arguments we pass to it. But in other cases, a function might interface with collaborators, or other units of code—making a database call, or directly reading or manipulating state. In these cases, we need a way to test our process without actually being dependent on these external factors, which might give us unexpected results should we refactor, or make our test suite take forever to run, wasting developer time.

Using Stubs for Testing

Stubs are similar to spies, but they allow you to replace a function entirely, so you can force it to do very specific things, like return a certain value. Essentially, stubs provide canned answers that simplify your testing process.

Let’s say we want to test a function that saves a user in a database, and we’re using jQuery’s Ajax implementation to do so. Because the following function makes an asynchronous call to a specific URL, testing this in isolation would be difficult.

function saveBook(book, callback) {
$.post('/books', {
title: book.title,
author: book.author,
published: book.published
}, callback);
}


Using a stub, we can completely replace our Ajax call, so we’re never actually hitting a server and waiting for a response—simplifying our test dramatically. Here’s an example where we are testing whether the request was made with expected arguments.

// in this case, we want to check if our stub was called with the correct arguments:
describe('saveBook', function() {
it('should send correct parameters to the expected URL', function() {

// stub the 'post' method, similarly to how we spied on functions before
const post = sinon.stub($, 'post');

// define the results we expect
const expectedUrl = '/books';
const expectedParams = {
title: 'The Fellowship of the Ring',
author: 'J. R. R. Tolkien',
published: 1954
};

// set up the user object that will be saved as a result of the request
const book = {
title: expectedParams.title,
author: expectedParams.author,
published: expectedParams.published
}

// actual invocation of the function we're testing, followed by clean up
saveBook(book, function(){} );
post.restore();

// here we use sinon's built in assertions to ensure that our stubbed request was called with the correct parameters to the correct URL
sinon.assert.calledWith(post, expectedUrl, expectedParams);
});
});

We use stubbing when we need to use data to test an outcome, but the specifics of the data don’t really matter.

What happens if we need to test more than just one function at once?

The Mock Testing Technique

Mocks are similar to stubs, but much more complex and robust. Like stubs, they help you verify a result, but they are also used to determine how that result was realized. They allow you to set up expectations ahead of time, and verify the results after the unit test has run.

This is particularly useful when you want to control the behavior of a real object and ensure it interacts with other parts of your application as expected. In this context, fake objects serve as stand-ins for real objects, allowing you to simulate different scenarios without needing the actual implementations.

Be careful though—because mocks have built-in expectations, they can cause tests to fail when used not used as expected. You have to be very intentional with their implementation. You could have several stubs running in a test file, but it’s best practice to have only a single mock object, and if you don’t need to set up expectations for a specific function call, it’s best to stick with a stub.

Here’s an example where we mock an object that has methods to allow us to store users:

// an object with methods that get, set, and delete users from storage
const userStorage = {
/// ...
}

describe('incrementUsers', function() {
it('should increment the number of users by one', function() {
// create a mock of our object
const userStorageMock = sinon.mock(userStorage);

// set up expectations
userStorageMock.expects('get').withArgs('data').returns(0);
userStorageMock.expects('set').once().withArgs('data', 1);

// invoke the function in our application that makes use of userStorage
incrementUsers();

// clean up
userStorageMock.restore();

// verify that our expectations are correct
userStorageMock.verify();
});
});

With this syntax, userStorageMock.expects(‘get’) sets up an expectation that the userStorageMock.get method will be called, and will return 0 (since we have no stored users). When we call verify(), that is when we check the actual results of our call against our expectations.

Unit Testing Strategies: Next Steps

It’s not always a straightforward task to write unit tests, but utilizing these techniques is an indispensable part of testing your code in an efficient and maintainable manner. All of the above can help reduce the complexity of your tests.

The world of testing can be a wild one, with many different approaches and frameworks to familiarize yourself with.

More on Spies, Stubs, and Mocks in Unit Testing

What is the difference between mock and spy in unit testing?

In unit testing, the key difference between a mock and a spy lies in their purpose. A mock is a test double set up with predefined expectations about how it should be used, allowing verification of specific interactions, like function calls and parameter values. In contrast, a spy records details of how a function was called without enforcing any expectations. While mocks assert expected behaviors, spies simply provide insights into actual behaviors, making them complementary tools in testing.

How are dummies used in testing?

Dummies are a type of test double used as placeholders in unit tests. They fill parameter lists where an actual object is needed but isn't relevant to the test's logic. For example, when a method requires an object as an argument but doesn't rely on it for testing, a dummy object can be passed in. This simplifies the testing process, allowing developers to isolate the functionality being tested without the complexity of real implementations.

What are the four types of spy?

There are generally four types of spies used in testing: Function Spies track calls to specific functions, recording details like invocation counts and arguments. Object Spies monitor the methods of an object, enabling observation of interactions with its functions. Stub Spies combine features of stubs and spies, allowing for controlled return values while tracking function calls. Mock Spies set expectations about how many times a function should be called and the arguments it should receive. Each type provides varying levels of insight into function usage during tests.