Test Doubles
In object-oriented systems, it’s normal for objects to do their work in collaboration with other objects. When it comes to testing, this creates a problem, because unit tests are supposed to test a small part of the system in isolation from other parts.
The need for isolation becomes especially acute when the dependent code interacts with something external to the system under test—a file, a database, or a network resource, for example. We cannot write fast, reliable and repeatable tests when there are external dependencies of this kind.
We can solve this problem by introducing a test double. A test double is analogous to a ‘stunt double’ in a movie. A stunt double stands in for an actor when they are required to do something difficult or dangerous in a movie scene. Similarly, a test double stands in for real code in a test: real code that would have prevented that test from being fast, reliable or repeatable.
There are several different types of double:
-
Fakes are objects with working implementations that employ shortcuts or simplifications so that they are better suited for use in tests. For example, if we had code dependent on a database accessed over the network, we could have our tests use a fake in-memory database in place of that real database.
-
Stubs are simple objects that don’t have working implementations. Instead, they provide pre-programmed responses to method calls made on the object.
-
Spies are stubs that record some information about the methods that are invoked on them. For example, you could set up a spy that doubles for an email sending service. It wouldn’t actually send any emails, but it could count the number of times that the
sendMail()method is called. -
Mocks encode detailed expectations about the methods that will be invoked on them. We use them to verify that the code under test invokes those methods in the expected way. This is a deeper form of testing than the state verification approach that we typically use with stubs.
We will focus here on stubs.
An Example
Suppose you are implementing a financial application that converts amounts of money from British Pounds Sterling (GBP) to other currencies, such as US Dollars (USD) or Japanese Yen (JPY).
You might have a CurrencyConverter class, instances of which perform such
conversions. Those instances might use an ExchangeRateService object to
provide the required exchange rates between GBP and currencies:
val rateService = ExchangeRateService()
val converter = CurrencyConverter(rateService)
...
val amountInYen = converter.convertTo("JPY", amount)
Below is a working implementation of ExchangeRateService. It makes a network
call to a web API to obtain the current exchange rates between GBP and other
currencies. These rates are returned as JSON data. The implementation
uses the Jackson library to parse the data, extracting exchange rates
and storing them in map, with the three-letter currency codes used as keys.
class ExchangeRateService {
private val url = URI.create(SERVICE_URL).toURL()
private val mapper = jacksonObjectMapper()
private val rates = mutableMapOf<String, Double>()
init { updateRates() }
fun updateRates() {
val json = mapper.readTree(url) // network call made here!
for (rate in json.path("rates").fields()) {
rates[rate.key] = rate.value.asDouble()
}
}
fun rateFor(currency: String): Double = rates.getOrElse(currency) {
throw IllegalArgumentException("Unsupported currency: $currency")
}
}
When writing unit tests for CurrencyConverter, we shouldn’t use this
implementation of ExchangeRateService, for several reasons:
- Bugs in the implementation of
ExchangeRateServicemight affect our tests - Creation of an
ExchangeRateServiceobject involves a network call, which will slow down the tests (even if we limit this by doing it only once, in a test fixture) - Creation of an
ExhangeRateServiceobject could fail, e.g., if the API server is down - Exchange rates change, but to make assertions about whether
convertTo()returns the correct values, we need rates to be fixed
Creating Stubs With Mockk
We can create a stub for ExchangeRateService without having to define
another class, by using an object mocking library.
In this example, we will use Mockk, a library designed specifically for Kotlin.
-
The
tasks/task13_4subdirectory of your repository is a Gradle project containing the classes described above. Take some time to examine the source code. -
The project contains a small program that performs a currency conversion. It uses the full implementation of
ExchangeRateService. Try running the program with./gradlew run -
Open
build.gradle.kts. You will see familiar test dependencies on the Kotest libraries, plus a new dependency on Mockk:testImplementation("io.mockk:mockk:1.13.13") -
Now locate
CurrencyConverterTest.kt. In this file, create the following test fixture:val service = mockk<ExchangeRateService>() every { service.updateRates() } just Runs every { service.rateFor("GBP") } returns 1.0 every { service.rateFor("USD") } returns 1.5 every { service.rateFor("JPY") } returns 190.0 val conv = CurrencyConverter(service)Here, the
servicevariable is our stub object. We useeveryto specify how the stub should behave. The first use ofeverydeclares that callingupdateRates()is allowed, and that it does nothing. The other three uses ofeverypre-program the stub to return specific values whenrateFor()is called for currency codes ofGBP,USDandJPY.The final line of the code above simply plugs this stub object into the
CurrencyConverterobject that will be the subject of our tests. -
Add a test like this:
"Amount can be converted to JPY" { conv.convertTo("JPY", 2.0) shouldBe (380.0 plusOrMinus 0.00001) } -
Add similar tests for the other two currencies that the stub knows about. Then run the tests with
./gradlew testThe tests should all pass.
And that’s all there is to it! You have now successfully isolated
CurrencyConverterfrom its dependency, using a stub.
Creating Stubs Manually
This explanation depends on concepts yet to be covered. You might want to return to this section after having learned about interfaces.
Let’s imagine that you don’t have access to a mocking library. How could
you create your own stub, and have the ability to plug this in to a
CurrencyConverter object?
The trick is to make ExchangeRateService an interface, with updateRates()
and rateFor() methods. We then make the real working version of the service
implement this interface. Finally, we write a stub that also implements the
interface:
classDiagram
ExchangeRateService <|.. ExchangeRateServiceImpl
ExchangeRateService <|.. ExchangeRateServiceStub
class ExchangeRateService {
<<interface>>
updateRates()
rateFor(currency: String) Double
}
ExchangeRateServiceImpl will contain the code shown earlier.
ExchangeRateServiceStub will look like this:
class ExchangeRateServiceStub : ExchangeRateService {
fun updateRates() {
// does nothing!
}
fun rateFor(currency: String) = when(currency) {
"GBP" -> 1.0
"USD" -> 1.5
"JPY" -> 190.0
else -> throw IllegalArgumentException("$currency")
}
}
Now, our unit tests can use the stub when creating a fixture:
val service = ExchangeRateServiceStub()
val conv = CurrencyConverter(service)
Applications that use CurrencyConverter will obviously need to use the full
implementation of the service, rather than the stub:
val service = ExchangeRateServiceImpl()
val conv = CurrencyConverter(service)
...
val amountInYen = currency.convertTo("JPY", amount)
Clearly, this approach involves more code than using a mocking library.
However, there are other benefits to hiding the service implementation behind an interface. For one thing, it allows us to have multiple working implementations of the service, e.g., one that uses a web API, another that retrieves exchange rates from a database, etc.