A few thoughts on unit testing
Separating the value of unit tests into three effects — regression protection, documentation, and pressure on internal structure — and asking how much to write in practice.
- Published
This article is also published elsewhere. https://iganin.hatenablog.com/entry/2019/12/26/232531
Originally written in Japanese. This is a translation of the same piece.
Introduction
Several of the books I read this year had a lot to say about where unit tests sit in a design — Beyond Legacy Code, Working Effectively with Legacy Code, Soft Skills, Clean Architecture and others. Using them at work and thinking them over, I worked out my own view of what unit tests are good for and how to use them sensibly in practice.
What follows is where that leaves me at the moment. It may well diverge from the industry standard, and I am writing it mainly as a way of organising my own thinking.
What unit tests are good for
Broadly, there are probably three effects.
- Preventing regressions
- Acting as documentation
- Refining internal structure
Preventing regressions
Writing unit tests lets you notice when a regression occurs. Say you change an existing class, and something
that should return true under a particular condition now returns false because of that change — the change
has damaged existing behaviour, and a unit test over that code is what tells you.
Once unit tests are protecting you from regressions, refactoring also becomes easier. Take refactoring to mean
making the internals clearer or more efficient without changing behaviour. The point is precisely that
behaviour does not change.
Unit tests test the behaviour of the code in question. So if you refactor while tests are in place and they still pass, you have a guarantee that the refactoring did not change behaviour, and you can work efficiently without worrying.
Preventing regressions, and thereby making refactoring more efficient — that is the first thing unit tests are good for.
Acting as documentation
A unit test functions as documentation of the specified behaviour of the method it covers. How it behaves in
edge cases, what branches can occur — the tests tell you.
Documentation that lives apart from the code tends to drift away from the real implementation over time. A unit test, written and amended alongside the code, is structurally unlikely to drift, and so works as documentation that stays useful.
Refining internal structure
The act of writing code so that it can be unit tested refines its internal structure. To make code testable, classes must be loosely coupled and their blast radius limited. Where an interface does not need to be public, the behaviour and state have to be closed off inside. Necessary dependencies have to be injectable so they can be swapped for mocks — because otherwise the test depends on the implementation of what it depends on, and stops being a unit test.
There are the ideas of CLEAN and the SOLID principles. CLEAN is a code-level guide, an acronym for Condensed, Loosely Coupled, Encapsulated, Assertive, Not redundant. (My memory here is hazy, so some of those words may be wrong.) The gist, as I recall it, was that code which is condensed, loosely coupled, encapsulated, non-redundant and assertive is code that is operable and maintainable.
The SOLID principles are architecture-level: SRP (Single Responsibility Principle), OCP (Open Closed Principle), LSP (Liskov Substitution Principle), ISP (Interface Segregation Principle) and DIP (Dependency Inversion Principle).
Building an application so that unit tests are easy to write tends, I think, to produce code that is CLEAN and follows SOLID — because to make tests easy to write, each unit needs fewer dependencies, fewer interfaces that require testing, and less redundancy.
Writing production code while writing unit tests makes it easier to end up with CLEAN, SOLID code.
How to apply this in app development
How to get all of those effects in app development. The approach probably differs between a new app and applying tests to an existing one; this piece focuses on new development.
To get every one of the effects, I think it is better to write the unit tests alongside the app code rather than after it. Concretely: define the behaviour as an interface on the app side, implement the behaviour you expect of that interface as a unit test, then fill in the app code until the test passes. Taking the TDD approach is, I think, the best way to get the full value of unit tests. (There is also the term BDD, Behavior Driven Development, which I am less familiar with.)
Doing all of that by hand every time is a lot of work, though, so it is worth using a code generation tool such as Generamba to scaffold the unit test alongside the app code. That means writing templates that produce the set of code you need for an architecture such as MVVM, Viper or Redux.
As long as the generator is used, this semi-enforces writing the implementation and the unit test at the same time, and you can get the full value of unit tests out of it.
That said
The above is how I currently think about getting the benefits of unit tests, but it is also, admittedly, hard work. I do not think every piece of development should be done this way. For a proof-of-concept app that is not meant to be built on afterwards, throwing something together with NoCode and watching the market react is better, and the available approaches also change with the level of the team.
For new development the above is fine, but introducing unit tests to a large existing codebase does not work like that. (Beyond Legacy Code was very helpful here.) In the end you have to think it through and pick the best answer for each situation as it comes.
Finally
I have set down what I currently think. It is what I think now, and it may change with more experience and more knowledge. Nor do I believe it is absolutely correct — correctness varies with what it is applied to and under what circumstances. I wrote this to organise what I have recently learned, and I would be glad to hear it if you think otherwise.