How Programming to an Interface Affects Testability
Create concept boundaries within your code to make it easier to test and maintain
Hi Friends.
We’ve previously explored how a slight change in mindset can make our code more flexible. Thinking in terms of what we’re doing rather than how can help us to architect our software to be more modular. We benefit from this by producing code that’s potentially less brittle in the face of requirements changes; reusable; and adaptable for various environments. This week, we’ll look at the implications of programming to an interface when it comes to testing.
The Problem with Holding All Responsibilities
It’s worth noting that not all forms of testing are affected. The main advantage of having a modular design lies in the ability to swap a component’s dependencies freely and easily for others. However, some tests focus on the bigger picture. End-to-end and manual tests typically verify the workflows of a finished product. And as such, the way that it’s put together is irrelevant for these types of tests; they are only concerned with how the product behaves as a whole.
However, writing unit and small integration tests becomes much easier. When attempting this for code that wasn’t written with testing in mind, we sometimes encounter two characteristics:
Dependencies are presented as concrete class types.
A class contains multiple tightly coupled responsibilities.
This in turn presents three challenges:
Tests become more complex to set up: we need to create and configure real instances of objects that are merely dependencies of the actual test subject.
We need to design our tests more carefully: we must factor in the additional processing these dependencies will perform, which may not be directly related to (but also cannot be isolated from) the code we want to test.
The code can become impractical to test.
To further explain point (3), let’s consider an example. Imagine we have a method that:
Accepts data via a parameter.
Converts it from one type to another.
Writes it to a database.
We’d like to test the conversion logic, and we could theoretically provide a database to write to. But we’d prefer not to have to persist the results of a unit test, especially as doing so is outside the scope of what we’re testing. And it’d slow down something we’d run both repeatedly and regularly.
Alternatively, consider a method for logging errors, which:
Takes a message as a parameter.
Adds a timestamp.
Writes it to storage.
While the code itself is relatively straightforward, it becomes difficult to test if the timestamp is obtained by calling DateTime.UtcNow
directly. By doing this, we’d need to know the exact time every time the test was run to determine whether the logged message is correct.
Modularity to the Rescue
Both examples of how code can become impractical to test suffer from the same problem: it’s trying to do too many things.
In the first example, logic for writing to a database is coupled with that for mapping data types. It’s important that we have both for the system to work correctly, but we can separate the two concerns. By introducing an interface for a data repository, we can say we want to store data. But we don’t say how, nor do we need to at this level. It also means we can bypass writing to a database in our unit tests. Instead, we can provide an implementation that stores data passed to it in a variable, which can be used later if necessary.
Again, we can break apart the two distinct responsibilities in our second example. By introducing an interface representing a timing service, we can say we want the current time and date without specifying how we obtain it. In unit tests, we can use an implementation (whether in the form of a mock, stub, or fake) that returns a constant time value. This gives us the power to manipulate time – at least as far as the logging service is concerned.
Summary
Writing tests is difficult when multiple responsibilities are tightly coupled within the same body of code. Programming to an interface can help separate them.
Without doing so, tests could be unnecessarily cumbersome to set up due to their subjects needing concrete implementations of their dependencies. Furthermore, they may need more careful design to cater for the additional moving parts. This can lead to code that’s impractical to test.
However, you can prevent this from happening by identifying boundaries between conceptually separate systems within your code. By introducing interfaces at these points, you loosen the coupling between them. In doing so, your code becomes cleaner and more modular. Its subsystems can be swapped for simpler and more predictable substitutes while testing. Ultimately, you’ll produce code that’s easier to both test and maintain.
Bonus Developer Tip
If you work in VS Code and want to compare two text files (we’ll call them A and B), you don’t need any additional apps. Open file A, select all, and copy to the clipboard. Then open file B, press F1
to open the command bar, and choose the Compare Active File with Clipboard option.
These tips are exclusively for my Substack subscribers and readers, as a thank-you for being part of this journey. Some may be expanded into fuller articles in future, depending on their depth.
But you get to see them here, first.
A Small Update
I’ll be taking a short break for the holidays, so don’t worry if you don’t see any newsletters over the next few weeks.
I plan to be back again in January.
Thanks again for joining me on this journey. I hope you’ll have a safe and happy holiday season, and wish you all the best for the New Year!
Anthony