Skip to main content

Test Doubles

Introduction

Difficulties Icon

When writing unit tests, you want to isolate the code under test from its dependencies. Dependencies are hard to control and can cause your tests to be flaky. They also make it hard to test failure cases that are difficult to reproduce in a real environment. This is where test doubles come in. They allow you to replace real dependencies with objects that you can control. This makes your tests more reliable, run faster, and easier to maintain. Test doubles are an essential part of any testing strategy, and they are a powerful tool for writing effective unit tests.

The term Test Double should make you think of a stunt double. It is a stand-in for the real dependency. There are several types of test doubles:

  • Dummy: A dummy object is passed around but never actually used. It is used when a method requires an argument, but the argument is not used in the test.
  • Stub: A stub is an object that returns a hard-coded response. It is used when you need to return a specific value from a method.
  • Mock: A mock is a test double that can be controlled at runtime.
  • Spy: A spy is an object that records how it was used. It is used when you need to verify that a method was called with specific arguments and how many times it was called.
  • Fake: A fake object has a working implementation, but it is usually simplified. It is used when you need a working implementation, but the real implementation is too complex to use in a test.

Dummy

A dummy object is the simplest form of test double. It is just something that the system under test depends on, but isn't used in the flow we are trying to test. For example, if you have a method that takes a parameter, but doesn't use it, you can pass a dummy object.

Simply passing null is probably the most common solution, but it isn't always viable. Sometimes you need to create a dummy subclass that doesn't have any real implementation.

@Test 
fun `withoutAuthorizer should return true given any input`() {
val systemUnderTest = SystemUnderTest(DummyAuthorizer())
assertTrue(authorizer.withoutAuthorizer("test"))
}

companion object DummyAuthorizer : Authorizer {
override fun authorize(user: String): Boolean {
throw RuntimeException("Dummy method is not implemented")
}
}

When using a mocking library you can often just pass an empty mock object instead of needing to write your own.

private val systemUnderTest = SystemUnderTest(mockk())

Stub

A stub is a simple object that returns a hard-coded response. It is used when you need to return a specific value from a method, but don't need to control it at runtime. For example, if you have a method that returns the current date, you can use a stub to return a specific date to ensure consistent results.

The stub is interacted with during the test, but it doesn't actually do anything. It just returns a value that you specify. This allows you to test how your system under test reacts to different values.

@Test 
fun `usesAuthorizer should return true given a true response from the authorizer`() {
val systemUnderTest = SystemUnderTest(StubAuthorizer())
assertTrue(authorizer.usesAuthorizer("test"))
}

companion object StubAuthorizer : Authorizer {
override fun authorize(user: String): Boolean = true
}

Mock

A mock test double is much more powerful than a stub. It can be programmed with expectations about what inputs it should receive and what outputs it should return. You are able to be a lot more specific about what you expect to happen and control its behavior at runtime. These are generally too complex to write by hand, so you will need to use a mocking framework.

private val mockAuthorizer = mockk<Authorizer>()
private val systemUnderTest = SystemUnderTest(mockAuthorizer)

@Test
fun `usesAuthorizer should call authorize given any input`() {
every { mockAuthorizer.authorize(any()) } returns true // return true for any input
assertTrue(authorizer.usesAuthorizer("test"))
verify { mockAuthorizer.authorize("test") } // verify that authorize was called with "test"
}

@Test
fun `usesAuthorizer should throw exception given a false response from the authorizer`() {
every { mockAuthorizer.authorize(any()) } returns false // return false for any input
assertThrows<RuntimeException> { authorizer.usesAuthorizer("test") }
}

Spy

A spy is usually a wrapper around a real object that records how it was used, but can also just be a fake implementation that tracks usage. It is useful when we want to check that a method was called with specific arguments or how many times it was called. It should be used when you want to make sure that some dependency was used correctly. This is often the only way to verify that a void method was called.

@Test 
fun `usesAuthorizer should call authorize given any input`() {
val spy = SpyAuthorizer()
val systemUnderTest = SystemUnderTest(spy)
assertTrue(authorizer.usesAuthorizer("test"))
assertEquals(1, spy.authorizeCount)
}

companion object SpyAuthorizer : Authorizer {
var authorizeCount = 0
override fun authorize(user: String): Boolean {
authorizeCount++
return true
}
}

Many mocking library also allow you to spy on real objects. This is useful when you want to test a real object, but you also want to verify that it was used correctly.

private val realAuthorizer = Authorizer()
private val spyAuthorizer = spyk(realAuthorizer)
private val systemUnderTest = SystemUnderTest(spyAuthorizer)

Fake

A fake object is a working implementation of a dependency, but it is usually simplified. It is used when you need a working implementation, but the real implementation is too complex to use in a test. For example, if you have a database dependency, you can create a fake database that stores data in memory instead of on disk.

class FakeAccountDatabase : AccountDatabase {
val accounts = mapOf(
User("rincewind@uu.edu") to UserAccount(),
User("librarian@uu.edu") to AdminAccount()
)

override fun getAccount(user: User): Account? = accounts[id]

override fun getPasswordHash(user: User): String {
val account = accounts[id] ?: throw RuntimeException("Account not found")
return account.passwordHash
}
}

Test Double Frameworks

Most modern programming languages have libraries that make it easy to create test doubles. These libraries provide a way to create test doubles without having to write a lot of boilerplate code. They also provide a way to verify that the test doubles were used correctly.

Some popular test double frameworks that I've used include:

  • Mockito: A popular Java library for creating test doubles.
  • Mockk: A Kotlin library for creating test doubles.
  • Jest: A JavaScript library for creating test doubles.
  • unittest.mock: A Python library for creating test doubles.

Conclusion

Test doubles are a powerful tool for writing effective unit tests. They allow you to isolate the code under test from its dependencies, which makes your tests more reliable, run faster, and easier to maintain. By using test doubles, you can mimic failure cases that might be impossible to reproduce in a real environment, resulting in more robust tests. Test doubles are an essential part of any testing strategy, and they can help you write better code and catch bugs earlier in the development process.

Image Credits