How to better isolate your tests

šŸ—“ļø
ā€¢
šŸ”„
ā€¢
ā³ 7 min

Introduction

These are tools used to imitate or substitute parts of the production code in our testing environment. Usually Services, Repositories, event buses, etc. although you can apply the same principles in much simpler contexts (like katas). They are useful to ensure we are testing the different parts of the system in isolation.

In practical terms, we can say that when the system under test (SUT) depends on a separate piece of our application (separate as in should be tested separately), the second one should be substituted. Specifically the parts we are not interested in testing but are required for our SUT to function.

As was the case for the tools we saw on the previous post, these Doubles might take a minute to set up, but not using these tools makes our tests significantly more fragile and less reliable, since when they break we wonā€™t have a clear picture of who exactly is at fault.

In the end, Doubles are just a false implementation of production code. Say you want to test UserService, but it depends on and requires a UserRepository with a search() function. You would make an InMemoryUserRepository that implements UserRepository (with its search() function) to test the Service independently of the Repository.

That InMemoryUserRepository is a sort of Double. Weā€™ll use this common example going forward.


Note on definitions

The terms Mock, Spy and Double are often used in different ways depending on the source material

When naming things in your code base please make sure there is a consensus within the team regarding what each word refers to. When looking for help online or debating with a co-worker, ensure you understand what they mean by these concepts. You might be using the same words but talking about different things.

Some consider Stubs to be very different from Fakes, some donā€™t include Dummy as a testing Double, and some donā€™t differentiate between Mocks and Spies. Some just consider everything a type of Mock Object.

So hereā€™s more fuel to the fire.

fuel

Good luck šŸ™ƒ


Dummy

False implementation of production code with no real behavior. Itā€™s literally there to make your code compile.

Use Case

You would use a Dummy to substitute a dependency of your SUT when itā€™s only needed at compile time but doesnā€™t really do anything in your testing scenario.

For Example, if our InMemoryUserRepository were a Dummy, it would implement the production UserRepository and have a search() function that does nothing (or the absolute minimum to compile).

Fake

False implementation of production code with very basic, test specific behavior. It would receive some starting data to simulate operations.

Use Case

A Fake is useful whenever you need some very simplistic behavior.

For example, if our InMemoryUserRepository were a Fake, it would implement the production UserRepository and have a search() function that actually implements production logic, but searches in an Array that it got via constructor (starting data).

Stub

False implementation of production code with basic, use case specific and re-usable behavior. It would use some hard coded data to simulate operations.

Use Case

Use a Stub when you need some basic test independent behavior. Basically as soon as you use the same Fake twice with the same starting data, build a Stub with that starting data.

Following our example, instead of our previous InMemoryUserRepository, you would build an InMemoryAdminUserRepository that implements the production UserRepository and has a search() function with production logic, but searches in a predefined, hard coded Array made up of a bunch of random Admin Users. This hard coded Array substitutes the starting data from the Fake example.

This way you could use the same Stub in multiple tests without rewriting the Array and ensuring they all work with the same data.

Spy

Piece of code (or external library) that allows the tester to check if and how a specific interaction with the spied code has taken place. It can tell the tester how many times its methods were called, what parameters were passed to each of them, in what order they were called, etc.

This is the first concept that only takes into account behavior, disregarding output.

It is also the first tool that allows us to see the inside workings of the systems we are testing. Useful, but prone to coupling. Use them sparingly!

Use Case

One would use a Spy to further detach the SUT from its dependency and/or to only make assertions regarding the interaction between the two, not really caring about the final output.

Common scenarios are the assertions ā€˜if the function was called at least onceā€™ or ā€˜if the function was called with X argumentā€™. Youā€™ll tend to find this behavior implemented within other Doubles, since it is often insufficient by itself.

So now, our InMemoryUserRepository would implement UserRepository and a search() function that literally does whatever, as long as it notifies the tester that it was called.

In our very simple example, the tester could ask the InMemoryUserRepository for the state of searchHasBeenCalled and use that to assert the expected behavior, no matter what the search() function actually does.

You can hopefully see that this couples our Service test to how our service functions (we test if it calls a given function), rather than to what it actually achieves (simply testing the output).

Mock

Keep in mind that, as noted before, some literature refer to everything weā€™ve seen here as ā€˜mock objectsā€™. That being said, the sources Iā€™ve found that consider them as its own specific thing all agree on Mocks being the more sophisticated of the lot.

Speaking of sources, the actual source material states:

[Mock Objects] replace domain code with dummy implementations that both emulate real functionality and enforce assertions about the behaviour of our code.

Similarly, Martin Fowler describes them as:

Objects pre-programmed with expectations which form a specification of the calls they are expected to receive.

One major benefit of using them is:

It makes it a lot easier to write a mocking tool.

Thus, they are often provided by an external library.

Mock objects give the tester full control over the behavior of the code being mocked, which can even be manipulated dynamically. It usually offers all the benefits from the previous tools as well.

As you might imagine, this can be as complex to implement as one heartā€™s desires (hence the external library), but they are very useful and easy to work with.

A super simple Mock might look a lot like a Spy that, instead of exposing whether a given function was called, has some sort of assert function. Mocks ā€œknow what they are testingā€, they make assertions on their own.

That being said, things can (and usually do) get more complicated than that.

Use Case

Say you want to simulate some specific complex behavior of our UserRepository to see how the UserService responds. Given a complex enough behavior, you might have to duplicate (and maintain) quite a bit of code or give up completely and test both elements together.

This might put you between a rock and a hard place, having to choose between flaky tests or giving up test isolation.

You can use a Mock to abstract that complexity away altogether. In fact, if using an external library, you wouldnā€™t even be implementing a substitute for our Repository (like the InMemoryUserRepository from before), since those usually provide a way to create mocks on the fly based on the interface it should implement.

Example

Suppose that, when our UserService calls the UserRepository implementation, the Repository needs to go fetch some data from the database, wait for an email to be sent, check for authentication with a third party service and call your mom to say you love her. Then, based on the results, the Repository returns either an empty array, an array with 4 elements or null (which as it turns out is the behavior you need for your use case).

You could ā€œre-implementā€ all that code/behavior, or you could mock the whole thing. With Mockito for example you would annotate the Repository with @Mock and use it like this:

java
when(mockRepo.doTheThing()).thenReturn(null)
// the rest of your test...

A lot simpler than the alternative! Although you are adding a dependency to your tests. Pick your poison!


Other posts you might like