TDD and Dependency Injection
How Two simple techniques can make a big difference in your coding life
TLDR;
Test Driven Development (TDD) and Dependency Injection, or the Dependency Inversion Principle, go hand in hand. They seem to complement themselves. Getting proficient in their use is not hard: you just have to see an example and apply it. These techniques will make your code more modular and easier to test. On top of that, your dependencies will be visible, design smells will be clearer and your test pyramid will be an actual pyramid. In this article, we will see a simple example you can build upon in your daily work.
Introduction
If you have been coding for some time, chances are you have come across Test Driven Development (TDD) and Dependency Injection, or the Dependency Inversion Principle. It might seem like a lot of effort to employ it, but with our IDEs or enhanced code editors, today it doesn’t make sense not to employ it. The best way to look at it is to see a real example of how we can take advantage of a simple change to make our tests and future lives easier.
Test Driven Development is more than just writing unit tests; it is about designing our systems to be testable. By designing our systems to be testable, we are also making them more modular and with better separation of concerns. Just look at the history of computer systems.
In the early days, when you wanted to read a file, you needed to know the underlying hardware being used. You would need to move the head of the disk to the proper cylinder, wait for the proper sector to pass by, etc. Then came the abstraction of a file system, provided by the operating system. Suddenly, your program could read files from disk, network and even virtual files that show the state of the system when they are read (for instance, you have a file with the amount of charge your battery has). All of this was done through the process of abstraction and leveraging the principle of Dependency Inversion: we don’t care about the details of reading a file, if it is a real file, or where it is stored; we just want to read the file. This same principle can and should be applied when doing TDD. Let’s look at an example.
Code Without Dependency Injection
Imagine you have a software system that has several configuration parameters that control its behaviour (for instance, feature toggles). Usually the beginning point for any feature toggle is to simply use an environment variable to do it, but how do you test it?
The naive approach is to read the environment variables directly:
if (Boolean.parseBoolean(System.getenv("ENABLE_FEATURE_A"))) {
System.out.println("Feature A enabled...");
//..
}The problem with this approach is that our code and tests depend on environment variables. Sure, we can work our way around this by using a mocking framework, but do we really want to? Does our production code need to be tightly coupled to environment variables? Isn’t there a better way?
Code With Dependency Injection
Let’s do our tests again, but this time let’s ensure that our production code does not depend on environment variables. Let’s invert that dependency by introducing a configuration provider.
public class FeatureToggle {
public FeatureToggle(ConfigurationProvider provider) {
if (Boolean.parseBoolean(provider.getParameter("ENABLE_FEATURE_A"))) {
System.out.println("Feature A enabled...");
//..The configuration provider is a simple functional interface:
public interface ConfigurationProvider {
String getParameter(String paramID);
}In our unit tests, we will be leveraging a Test Configuration Provider that we can control:
@Test
void testFeatureAEnabled() {
TestConfigurationProvider provider = new TestConfigurationProvider();
provider.setParameter("ENABLE_FEATURE_A", "true");
FeatureToggle toggle = new FeatureToggle(provider);
//...Here we are using a test configuration provider to make our tests easier to write and clearer than having to use a mocking framework to inject environment variables.
class TestConfigurationProvider implements ConfigurationProvider {
private final Map<String, String> parameters = new HashMap<>();
void setParameter(String paramID, String value) {
parameters.put(paramID, value);
}
@Override
public String getParameter(String paramID) {
return parameters.get(paramID);
}
}How would we add support for environment variables? We would write an implementation of a configuration provider that fetches the values from environment variables. This is similar to writing a driver for a new storage device in an operating system.
public class EnvironmentConfigurationProvider implements ConfigurationProvider {
@Override
public String getParameter(String paramID) {
return System.getenv(paramID);
}
}Other Benefits
There are other benefits to this approach. One of them is the possibility of having faster tests. Imagine the code you are developing depends on some kind of storage mechanism, like a database or the file system. Having a database running or using the file system will cause your tests to slow down. Having an interface that abstracts the storage mechanism allows us to use an in memory storage mechanism. This will make the tests run as fast as possible without having a database running, for instance.
Another benefit that might not be apparent immediately is the ability for our tests to point out design smells faster. When you use dependency injection, the constructor of your classes needs all the dependencies. If your constructor starts to have too many arguments, too many dependencies, then it is time to think about your architecture.
Finally, having the dependencies inverted means that at any time you can use a different implementation for a use case you never thought of when you started. For instance, you could fetch the configuration settings from a database, or a configuration management system, allowing you a better control than having to set environment variables.
A Word About Integration Tests
I can’t end this article without having a word regarding integration tests. We tend to think of TDD as simply writing unit tests, but it is a lot more than that: it is a change in philosophy, in our way of thinking and working. The change in philosophy is to ask “How am I going to test this?”, before asking “How am I going to implement this”. Integration Tests are a part of that, they build your test pyramid. We want to have many fast unit tests, because they provide fast feedback, but we also need integration tests where we test what we will be running in production, with all the real dependencies in place. In the integration tests, we do not want to test all scenarios, specially all error scenarios: those are to be tested in unit tests. What we want to ensure is that all the protocols/APIs/interfaces that we defined using TDD, actually work when everything is together.
Conclusion
I hope this article has given you a glimpse of what TDD and the Dependency Inversion Principle can give you: the freedom to write tests more effectively, to focus on the problem at hand, while not sacrificing the possibility of extending the system in the future. It is a simple mind shift that will make our code better. With today’s IDEs, the excuse of having to type more code is a non-issue because our IDEs will generate most of the interface code for us.
Have you ever seen someone doing TDD this way? Would you like too? Leave a comment if you would!