Last time, I started talking about abstraction. Why it is important in general, and specifically in the context of testing. If we consider what we’re testing a black box behind an abstraction, we’ll probably have the wrong expectations for when tests pass and fail. Today we’ll talk about about breaking abstraction – on purpose.
The Mocks
We use mocks in all kinds of situations, where we want to isolate our code, or component, or a sub-system from a problematic dependency. This could be something we don’t control, or don’t trust. The idea behind mocking is gaining trust in the code that we isolated, by having tests around it.
The problem is where we identify the border line – where mocking occurs.
Here’s a very simple example. This is a Driver class, that gets a Car object as a dependency:
public boolean canStartDriving() { return !car.isRunning(); }
We want to check the canStartDriving method logic, which depends on the Car‘s isRunning method, which explodes. The method, not the car.
So we write something like this:
@Test public void cannot_start_driving_with_a_running_car() { Car mockCar = mock(Car.class); when(mockCar.isRunning()).thenReturn(true); Driver driver = new Driver(mockCar); assertFalse(driver.canStartDriving()); }
We mock the Car, set a behavior on isRunning and we can check the Driver‘s method.
Success! But At What Cost?
Well, we needed to know an implementation detail. We knew that internally, the Driver‘s method calls the Car‘s isRunning method. Without this knowledge, we wouldn’t be able set the behavior of that method to return the value we want, and run the code scenario we wanted.
Using big words: The Driver class is coupled to the Car dependency. By knowing the relationship between the two classes, we broke the abstraction of the Driver class.
Ok, nice big words. But what’s the big deal?
At this point, not much. Apart from the already Driver–Car coupling, we’ve introduce a new coupling into the relationship – the test-Car coupling. Coupling (if needed or not), carries a potential maintenance cost. If we change the logic to call another method on Car, the test would fail. And we would need to change the test back to working state.
If we just renamed the isRunning method to isEngineRunning, we would need to change the the test too. Although, if we’re smart, we’d let our tools do that for us, with no apparent cost.
So basically, this kind of abstraction breaking is really cheap.
Or is it?
Abstraction Costs Are Too Damn High
Another part of mocking is the ability to verify method calls on the dependency. Let’s look at our Driver’s new method:
public void drive() { car.start(); }
How can we check it works? Remember our Car still explodes. The method, not the car.
We can write this test:
@Test public void when_driving_start_car() { Car mockCar = mock(Car.class); Driver driver = new Driver(mockCar); driver.drive(); Mockito.verify(mockCar).start(); }
The test of course works. The Car’s start is mocked, and as long as it gets called, the Driver’s drive method works ok.
What is the difference in terms of abstraction?
Let’s say we change the method in first test. If the original method is called, it will crash the test. But if it doesn’t, although we set behavior on it, the test will still pass.
On the other hand, the second test relies on the method being called. If we changed it to call something else, or another object entirely, the test will fail.
Mocking breaks abstraction. It does so in many levels. On the lowest impact range, is setting a behavior (like in the first test). But then you can add run-time dependency: e.g. change the behavior of this method, only if it’s called with empty strings. When we verify method calls, like in the second tests, X times with specific arguments, we continue to do that.
That foreknowledge of implementation breaks the abstraction even more, leading to higher maintenance costs when the code changes.
I’ve used regular mocks for this purpose, but that’s true for any mocking situation (like API calls or simulators) in any testing scenario. The more the tests know how the code works, we will pay more in test changes later.
It doesn’t mean we shouldn’t mock. It does mean we need to understand the cost.
Next time, we’ll continue with abstractions in testing.
Meanwhile, check out my “Unit Testing and TDD” workshop where we handle mocks like pros.
0 Comments