DEV Community

Cover image for How Programming to an Interface Affects Testability
Anthony Fung
Anthony Fung

Posted on • Originally published at webdeveloperdiary.substack.com

How Programming to an Interface Affects Testability

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:

  1. 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.

  2. 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.

  3. 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.


Thanks for reading!

This article is from my newsletter. If you found it useful, please consider subscribing. You’ll get more articles like this delivered straight to your inbox (once per week), plus bonus developer tips too!

Top comments (0)