You likely often hear that test-driven development (TDD) or just writing tests can make your code better. It’s hard to say whether this is true unless you have seen the impact of writing unit tests on code before. Let’s take a look at this effect with a simple example: moving an internal state of a class to a dependency.
Lack of internal state and testability
The most straightforward way of keeping a state is to add a private variable and put aside the values you need for later use. This will do the job, but it makes testing more difficult. For complete test coverage, you would need to:
- Bring the instance to the state you want to test
- Check its behaviors
As you add more internal variables, achieving the expected state becomes exponentially more complicated: besides simple states, you will need to include combinations between them. There can be invalid combinations that are impossible to achieve if everything works as expected—but you could be interested in testing whether the code gracefully degrades if it reaches this impossible condition.
There is also a temptation to access the class’s private variables from the test, but this approach feels wrong. It depends on knowing the implementation of the class, and it ignores the interface we are defining for the type.
Moving the state out
To make the code more testable, we can define a separate class that will keep the state. This new class introduces a layer with a new interface surface that we can use to describe a relationship between objects. You can mock methods used to set and retrieve the state changes, making testing easier.
Low testability example
As an example with low testability, we will have an alarm clock class:
We can expect certain behaviors from this clock:
- the clock rings when the current time matches the programmed alarm time
- user can set the alarm time
If you wanted to test this behavior, you would need one of two approaches:
- wait until the hardcoded alarm time comes, and see if the alarm rings
- set the time just a few moments from the alarm time to see if it rings as expected
Approach 1 is wrong; it could require hours of waiting.
Approach 2 has downsides, too, they’re just more subtle. It would require your tests to read the current time and add seconds of waiting for results. Setting the wait time would be a trade-off between stable tests, where we wait long enough to avoid missing the point on slow machines, and waiting too long and slowing down the testing.
More testable example
We can make this code more testable by moving the state outside:
So, in this case, introduce a dependency— AlarmTimeStore—which keeps the value set outside the AlarmClock class.
How this makes testing easier
As we moved the state outside, we introduced a dependency to facilitate testing. When we run the class in the test, we replace the actual dependencies with mocks. Mocks are a drop-in replacement for other instances that provides the same interface but allows for setting expectations. You can provide a value that that function calls will return.
Mocking allows you to run the class or function in isolation from the other code—you can thus control what values are returned to your unit and check whether it’s behaving as expected.
How this makes code better
By moving the state outside, we define a clear separation between those two classes. As the persistence layer gets a clearly defined interface, it will be easier in the future to:
- make it more advanced: for example, instead of storing the value in the runtime memory, save it to the browser or a file, or
- reuse it in other parts of the application: as the application becomes more complicated, we can find different use cases that could be covered by generalizing a solution we build here.
Common critique: so many layers of abstraction
Some people criticize this approach for introducing too many layers of abstraction to implement features. I got feedback like this for the code I wrote when trying to keep it very testable. Most likely, it’s a matter of personal taste and beliefs about unit tests: I like my code to be covered by tests, and I’m happy with subtle changes to the code design to make sure it’s easily testable. If somebody doesn’t care about tests, I’m not sure if there are strong arguments to make that this approach is objectively better than the alternatives. The value of the flexibilities I’ve listed above depends on how likely we are to actually need them at some point. If it’s probable that you won’t need them, you could argue that it’s a premature act of preparation for a use case we might never need.
How do writing tests impact your code?
Are you writing tests regularly? Please share how it impacts your code—I would love to hear stories from you guys!