Replacing Mocks
Sam Edwards recently posted his strategy for wrapping mock objects to avoid using verbose syntax and facilitate reuse. After reading the post, my first thought was that this example comes really close to showing my strategy for removing mock objects from tests.
Why Remove the Mocks⌗
There has been endless discussion about the benefits and drawbacks of Mock objects in testing. As I’ve become better at testing, and programming in general, I’ve come to use mock objects less and less, even removing them from my existing projects as I come across them.
People often ask how I can test without using mock objects, and then follow it up with the why. Because of that I thought I’d take the opportunity to iterate on Sam’s great example to show how you can easily replace Mocks with Fakes. But first, let’s tackle the why.
Mocks are simply another tool that you can leverage in your code, they aren’t inherently good or bad. That being said, there are tradeoffs to using mock objects, so it’s up to you to determine whether you want to avoid them, or not.
For me, the single biggest reason to avoid using Mock objects is because they encourage poor programming practices, and poor testing practices. Mock objects are generally used in tests to allow you to provide dummy responses from your class dependencies, fine, but also to verify that your class made certain calls with certain arguments.
This means that you’re testing implementation details, as opposed to the external state and API of a class, and leads to brittle tests. Brittle tests that require constant maintenance as your codebase evolves, adding to your workload instead of protecting you from catastrophe.
In Sam’s example, he uses a FakeOven to wrap a mock Oven. This allows him to encapsulate all of the result and verification logic within the fake.
/** Wraps the Mockito mock for reuse */
class FakeOven {
val mock: Oven = mock()
fun givenOvenResult(ovenResult: OvenResult) {
// Setup
whenever(mock.start()).thenReturn(ovenResult)
}
fun thenOvenSetTo(temperatureFahrenheit: Int, timeMinutes: Int) {
// Verification
verify(mock).setTemperatureFahrenheit(temperatureFahrenheit)
verify(mock).setTimeMinutes(timeMinutes)
}
}
This is a pretty simple, contrived example, but you can see how this mock wrapper definitely simplifies the test code:
class DessertTestWithFake {
@Test
fun bakeCakeSuccess() {
val fakeOven = FakeOven()
val dessert = Dessert(fakeOven.mock)
fakeOven.givenOvenResult(OvenResult.Success)
dessert.bakeCake()
fakeOven.thenOvenSetTo(
temperatureFahrenheit = 350,
timeMinutes = 30
)
}
}
You might then clap your hands and think you’ve avoided the certain pitfalls of testing with mocks, but you’d be wrong. The underlying problem with mocks is that they encourage you to test how a class does something, not what a class does. This is a relatively subtle distinction, and is often hidden by mocks.
In this example, wrapping the mock makes it appear as though you are verifying that once you start baking a cake the timer and temperature are set appropriately, but you’re really just testing that some methods were called.
To illustrate this, imagine that as your project and baking skills grow you realize that you need to account for altitude when baking your cake. You update your Dessert class accordingly.
class Dessert(val oven: Oven, val altitude: Int) {
fun bakeCake(): OvenResult {
oven.setTemperatureFahrenheit(350)
if (altitude > 5000) {
oven.setTemperatureFahrenheit(400)
}
oven.setTimeMinutes(30)
return oven.start()
}
}
This doesn’t compile, so you update the inputs to your test class.
class DessertTestWithFake {
@Test
fun bakeCakeSuccess() {
val fakeOven = FakeOven()
val dessert = Dessert(fakeOven.mock, 5001)
fakeOven.givenOvenResult(OvenResult.Success)
dessert.bakeCake()
fakeOven.thenOvenSetTo(
temperatureFahrenheit = 350,
timeMinutes = 30
)
}
}
To your surprise, this test passed. This test, that should be protecting us from burned confectionary, instead allowed us to feel safe while our cake turned dark.
The test passed because it’s testing behavior, not results. It is correctly verifying that the Dessert class called the setTemperatureFahrenheit
method with a parameter of 350. It fails to account for the fact that when start()
was called, that was no longer the setting.
While this can seem contrived, I think it’s a perfect example of how mocks make it look like you’re testing one thing, when you’re actually testing something different (and, incidentally, something you shouldn’t even be testing).
Fixing the Fake⌗
What you really want to test is that when you bake a cake, the oven is started with the appropriate settings.
Note: In reality that could even be considered an implementation detail. What you really want to test is that the cake is safely cooked, and has the right texture and taste, but that’s outside the scope of this example.
This is the perfect instance for a real Fake, and it’s pretty easy to iterate on Sam’s example to get there. The first step is to separate the Oven into an interface and implementation.
interface Oven {
fun setTemperatureFahrenheit(tempF: Int)
fun setTimeMinutes(minutes: Int)
fun start(): OvenResult
}
class GasOven : Oven {
override fun setTemperatureFahrenheit(tempF: Int) {
// ...
}
override fun setTimeMinutes(minutes: Int) {
// ...
}
override fun start(): OvenResult {
// ...
}
}
Now that we’re programming to an interface, it’s easy to update our Fake to implement the interface and simply store the state in instance variables.
class FakeOven : Oven {
// State
var tempF: Int = 0
var timer: Int = 0
var isStarted = false
// Returned values which can be overridden in tests
var result: OvenResult = OvenResult.Success
override fun setTemperatureFahrenheit(tempF: Int) {
this.tempF = tempF
}
override fun setTimeMinutes(minutes: Int) {
timer = minutes
}
override fun start(): OvenResult {
isStarted = true
return result
}
fun givenOvenResult(ovenResult: OvenResult) {
// Setup
result = ovenResult
}
fun thenOvenSetTo(temperatureFahrenheit: Int, timeMinutes: Int) {
// Verification
assertEquals(temperatureFahrenheit, tempF)
assertEquals(timeMinutes, timer)
}
}
Now, when we execute our test, we’re verifying the external result of baking a cake instead of verifying the internal implementation details of which methods are called in which order.
When I’m writing fakes, I usually forgo the setup and verification methods, since those are test specific and easy to implement with access to the instance variables. Not only does that make my Fakes even simpler, but IntelliJ is able to generate most of the code for me.
Conclusion⌗
This slight difference in Fake classes allows us to have more robust tests that are less prone to failure when we change implementation details of our classes. It also promotes good coding practices by making it harder to test the wrong things, and encouraging us to write our code to interfaces, defining a clear public API and adding flexibility.
Additionally, this Fake implementation is even more reusable than the first, since it matches the public API of the class it’s faking and can easily be used anywhere as a drop in replacement.