Testing each production class in isolation kills code evolution
Illustrated with a simple example
It’s something we see and hear over and over again. To achieve high quality code, some devs propose to write a test class for every production class in isolation. Every collaborator of the “SUT” (system under test) should then be replaced with a “mock” or test double to achieve this isolation.
In this article, I want to briefly outline with a simple example that you can try for yourself why this idea can destroy your code’s ability to change effectively and cheaply.
A simple example
Test code should only change if the behaviour of the production code changes. We should not need to adapt test code just because we restructured the internals of our public API.
Let’s imagine the following simple codebase. A service “AService” has one collaborator “BService” which is called in AService’s method “invoke()”, as shown below.
public class AService {
private final BService bService;
public AService(BService bService) {
this.bService = bService;
}
public String invoke() {
// here is where the magic happens
return this.bService.getHelloWorld();
}
}
public class BService {
private int callCounter = 0;
public String getHelloWorld() {
if (callCounter++ == 0) {
return "Hello World";
}
return "World Hello";
}
}
Let’s write a test for AService where we use Mockito to stub BService’s getHelloWorld() call, and a test for BService’s getHelloWorld() method.
public class AServiceTest {
@Test
void test1() {
var mock = mock(BService.class);
when(mock.getHelloWorld()).thenReturn("Hello World");
var aService = new AService(mock);
String result = aService.invoke();
assertEquals("Hello World", result);
}
@Test
void test2() {
var mock = mock(BService.class);
when(mock.getHelloWorld()).thenReturn("World Hello");
var aService = new AService(mock);
aService.invoke();
String result = aService.invoke();
assertEquals("World Hello", result);
}
}
public class BServiceTest {
@Test
void test1() {
var bService = new BService();
var helloWorld = bService.getHelloWorld();
assertEquals("Hello World", helloWorld);
}
@Test
void test2() {
var bService = new BService();
bService.getHelloWorld();
var helloWorld = bService.getHelloWorld();
assertEquals("World Hello", helloWorld);
}
}
So far, so bad. What if at some point we want to inline getHelloWorld() into AService because the call to AService.invoke() actually does nothing except delegating to BService.getHelloWorld()? Both our test classes break!
public class AServiceTest {
@Test
void test1() {
var mock = mock(BService.class);
// --------------------------------
// automatic inline adds this
// --------------------------------
String result1 = "World Hello";
if (mock.callCounter++ == 0) {
result1 = "Hello World";
}
when(result1).thenReturn("Hello World");
// --------------------------------
var aService = new AService(mock);
String result = aService.invoke();
assertEquals("Hello World", result);
}
@Test
void test2() {
var mock = mock(BService.class);
// --------------------------------
// automatic inline adds this
// --------------------------------
String result1 = "World Hello";
if (mock.callCounter++ == 0) {
result1 = "Hello World";
}
when(result1).thenReturn("World Hello");
// --------------------------------
var aService = new AService(mock);
aService.invoke();
String result = aService.invoke();
assertEquals("World Hello", result);
}
}
Of course, BServiceTest’s test methods for getHelloWorld() can now be deleted as they aren’t used anymore. But AServiceTest’s test double for BService needs to be adapted. We need to touch test code even though we haven’t changed the behaviour of the invoke() method.
Now let’s look at an alternative scenario where we have no test class for BService but only a test class for the public API of AService that does not rely on test doubles but instead injects the actual BService.
public class AServiceBetterTest {
@Test
void test() {
var aService = new AService(new BService());
String result = aService.invoke();
assertEquals("Hello World", result);
}
@Test
void test2() {
var aService = new AService(new BService());
aService.invoke();
String result = aService.invoke();
assertEquals("World Hello", result);
}
}
If we inline the BService.getHelloWorld() method, the test code looks as follows:
public class AServiceBetterTest {
@Test
void test() {
var aService = new AService(new BService());
String result = aService.invoke();
assertEquals("Hello World", result);
}
@Test
void test2() {
var aService = new AService(new BService());
aService.invoke();
String result = aService.invoke();
assertEquals("World Hello", result);
}
}
You may have noticed: nothing changed! Only if we adapt the public interface of AService’s constructor to remove BService, we need to touch the test code. We can mitigate this problem by hiding away on what internal collaborators AService depends using e.g. a creation method on AService.
public class AServiceBetterTest {
@Test
void test() {
var aService = AService.create();
String result = aService.invoke();
assertEquals("Hello World", result);
}
@Test
void test2() {
var aService = AService.create();
aService.invoke();
String result = aService.invoke();
assertEquals("World Hello", result);
}
}
public class AService {
...
public static AService create() {
// hide internal collaborators with creation methods
return new AService(new BService());
}
}
With this in place, we can change the collaborators in the create() method however we like.
Conclusion
Even this extremely small codebase demonstrates why we should avoid testing every “public” (or even protected or private) method of collaborators that are only used internally by other services in isolation, and “mock away” collaborators. If you want to call these “sociable unit tests” or “larger unit tests”, the idea behind it is that you mainly test the public, deep interface of the service that actually accomplishes something for the external caller and avoid coupling test code to production code.
You may think of your production code as a library. Which classes and methods are exposed to the public (meaning, are out of your control once they’re released because you don’t know who the caller are going to be?) and which only contain the “public
” keyword because you need to access these methods by another method of another class?
In our experience, the most flexible test cases target only the publicly exposed API. We want to test any possible input we can receive by a public (out of our control) caller. If an input combination is theoretically possible in an internal method but cannot be executed from the public API, there may actually be no need to write a test for it.
In our tests, we should try to avoid creating test doubles for our own production classes whenever possible. We should reserve test doubles for awkward dependencies. These are dependencies that are slow, hard to instantiate, or nondeterministic, like file-system-, I/O- and network access, databases, random number generators, time, etc. Any other class we have control over should be instantiated directly so that we can freely refactor the internals of our code without having to adapt any tests! Only if the actual behaviour changes should we need to touch a test.
As always, this is only a rule of thumb and there are many exceptions.
Sometimes it is not possible to test without test doubles, and sometimes our internal class is complex and has many edge cases that need to be covered by an own test class file.
But it should always be a deliberate decision to create test doubles or to test “internal” “public” methods.
If you want to test it yourself, you can find the code examples used in the this repository.
What do you think? Do you agree or disagree? Do you have other experiences to share that show the importance of mocking collaborators even if they are not awkward? Write a comment and we can discuss it further!
If you like content like that, don’t forget to subscribe to stay up to date!
Want to learn how to write evolvable code? Visit our website www.codeartify.com and get in touch with us! We provide worldwide remote and on-site workshops for you and your team!
New to Domain-Driven Design, Clean Architecture, EventStorming, and TDD? Check out our comprehensive O’Reilly course on that topic.