Unit tests are tests of modularity
Every once in a while, someone asks me why I focus on unit testing rather than other forms of automated testing. It’s a good question.
People often make the case that it is better to cover an application with “smoke tests” before making any changes. It seems like a good idea, but in practice, the further away we test from the place where we are making changes, the harder it is to formulate test cases that cover that particular part of code. In software, we formulate test cases by tweaking values in our tests over and over again and checking behavior. When our tests are far away from the code we are changing, it feels like trying to hit a small target with a rock from an airplane. You might have to try a few times. Testing is easier when we are closer to the place where we are making a change. It’s just unfortunate that a lot of code lacks the modularity that would make that easy.
A lot of the modularity that is a traditional part of software design (class level, function level) is seen as a little less important today. There’s a sense that services are a good unit size for software development. In one sense, they are. Services are great units for architectural discussion and planning, but when the discussion turns to "how does this code work?" and "how far away do I have to be to run this code and see what it actually does?" we often wish for finer-grained units – code that we can understand from the outside with tests and from the inside, by seeing it in isolation from the rest of our code. It may not seem like it, but these two things are related. It’s hard to write a large “unit” of code and have confidence that the internals are working just from the tests. At the same time, it is harder to write tests as our units get larger. These two things make testing a good probe of design. When testing is painful, it’s worth looking at our modularity, or lack of modularity, and seeing what can be done to make it better.
It’s interesting to consider that seeing testing as a tool in this way, sort of bypasses a lot of the debate about what is the size of the “unit” in unit testing. It can be a class, it can be a function. It can be a cluster of either - but it should be a small-ish thing that you can think of as a unit. Having said that, though, it’s good to have your sense of a "unit" align with the simplest constructs that your technology gives you to enforce modularity and encapsulation. It’s great when you don’t have to fight your language. Although it may sound circular, we could probably say the units are what units test – easily. Unit tests work against some sort of interface, give coverage with a small number of cases and don’t do an incredible amount of setup to reach obscure conditions. They document a simple understandable thing – a unit.
Among all of the other things that unit tests are, they are also tests of modularity. If it’s difficult to write a test for a code change, your code could be more modular, and the modules should be relatively small. When they are, you can see the pieces by themselves; they are distinct, understandable, and they have behavior that is discoverable. Doing this can be the first step toward developing deeper understanding of a system as a whole. You build understanding of the whole system by reasoning from a solid base of understanding about how the pieces work. Bugs are a symptom of misunderstanding. With modularity, quality follows.