Unit testing is an essential instrument in the toolbox of any software developer. Writing unit tests is relatively easy when dealing with a codebase that follows best software design practices, patterns, and principles. The real problem arises when trying to unit test poorly designed, untestable code.
This blog will discuss how to write more unit-testable code and which patterns and bad practices to avoid to improve testability.
Testable & Untestable code
When working on large-scale applications that need to be maintainable in the long run, we must rely on automated tests to keep the system’s overall quality high. Compared to integration tests, where you test multiple units as a whole, unit tests have the benefit of being fast and stable. Fast because we are instantiating, ideally, just the class under test, and stable because we usually mock out the external dependencies, e.g., database or network connection.
If you aren’t familiar with the exact difference between unit and integration tests, you can read more about this topic in our Introduction to testing blog.
Testable code can be isolated from the rest of our codebase. In other words, the smallest units can be independently tested. Untestable code is written in such a way that it is hard, or even impossible, to write a good unit test for it.
Let’s review some anti-patterns and bad practices we should avoid when writing testable code.
Examples are written in Java, but the coding conventions mentioned here apply to any object-oriented programming language and testing framework. We will use assertj, and JUnit5 for examples in this blog post.
It is one of the most important design patterns for achieving test isolation. Dependency injection is a design pattern in which one object receives other objects (dependencies) through constructor parameters or setters instead of having to construct them itself.
With dependency injection, we can easily isolate the class under test by mocking out the dependencies of an object.
Let’s look at an example without dependency injection:
Since engine dependency is being constructed in the constructor of the Car class, you can say that Car and Engine classes are tightly coupled. They are highly dependent on one another – changing one would require a change in the other.
From a testing perspective, you cannot test Car class in isolation because the above example cannot replace the concrete Engine implementation with a test double.
However, we can achieve isolation with the use of dependency injection and polymorphism:
Read Testing by example
Now we can construct multiple engine implementations and, therefore, cars with different engines:
Testing in isolation is now possible because we can create a mocking implementation of Engine abstraction and pass it to our Car class:
When dealing with objects that require other objects (dependencies), you should provide them through constructor parameters (dependency injection), ideally hidden behind some abstraction.
By following this pattern, your code becomes more readable and adaptable to change over time. Also, you should avoid doing actual work in constructors – anything more than field assignments is actual work. `new` keyword in constructors is always a warning sign of untestable code.
One thing to note here is that in some situations, tight coupling (e.g., new keywords in constructors, inner classes for logic isolation, object mappers) is not a bad practice – classes that wouldn’t make sense as a “standalone” class.
Sharing a global state can often produce flaky tests (sometimes pass, sometimes fail), especially in multithreaded environments.
Imagine a scenario where multiple objects under test share the same global state – if a method in one of the objects triggers a side effect that changes the value of the shared global state, output from a method in the other object becomes unpredictable. Avoid using impure static methods because they mutate global state in some way or are proxied to some global state.
Let’s look at this impure static method:
Essentially, this method reads the current system date and time and returns a result based on that value. It would be very difficult to write a proper state-based unit test for this method because LocalDateTime.now() static call will produce different results during the execution of our tests. Writing tests for this method is impossible without changing the system date and time.
To fix this, we will pass the date time to timeOfDay method as an argument:
timeOfDay static method is now pure – the same inputs always produce the same results. Now we can easily pass isolated dateTime objects as arguments in our tests:
Law of Demeter
The Law of Demeter, or the principle of least knowledge, states that objects should know only about objects closely related to the first object. In other words, one object should only have access to objects it needs. For example, we have a method that accepts a context object as an argument:
This method violates the Law of Demeter because it needs to walk an object graph to get the required information to do its work. Passing unnecessary information into classes and methods hurts testability.
Imagine a huge BillingContext object containing references to other objects:
As we can see, our test is bloated with inessential information. Tests creating complex object graphs are hard to read and introduce unnecessary complexity.
Let’s fix our previous example:
You should always pass direct dependencies into your classes and methods. However, passing many arguments into methods is also not a good practice – ideally, you should pass two arguments at max or wrap close-related arguments into data objects.
God object is an object that references many other distinct objects, has more than one responsibility, and has multiple reasons to change. If it is challenging to sum up what the class does, or if summing up includes the word “and”, the class likely has more than one responsibility.
God objects are hard to test since we are dealing with multiple unrelated dependencies, mixing various levels of abstractions and concerns, and they produce a lot of side effects. Consequently, it is tough to accomplish the desired state for our testing cases.
UserService has more than one responsibility – registering new users and sending emails. While testing user registration, we need to deal with email service and vice versa:
Imagine a UserService with more than two unrelated dependencies. These dependencies have their own dependencies, and so on. We would end up with a test that is unreadable, bloated with unrelated information, and very hard to understand. Therefore, every class should have only one responsibility and reason to change. A class having only one reason to change is one of the five software design principles called the Single responsibility principle.
You can read more about SOLID principles here.
A codebase that follows software design best practices makes writing unit tests much more manageable.
On the other hand, it can be very challenging, or sometimes even impossible, to write a working unit test for a codebase using the mentioned anti-patterns. Writing good testable code requires a lot of practice, discipline, and extra effort. The most significant advantage of testable code is the ease of testability and the ability to understand, maintain and extend that code.
We hope this blog helps you write testable code!