Steven E. Newton
Crater Moon Development
Unit testing is more than a design tool, not just a practice that enhances quality, it is a tool for managing complexity.
A proper, well-written unit test isolates a particular piece of behavior, and the code that implements it, from all the unnecessary surrounding context of the running system. The unit test focuses on an aspect of the code in a way that frees the programmer from the need to build and maintain a complex mental picture of the running system. A programmer doesn’t have to “hold it all in her head”. Where before a programmer might have had to slowly build up a detailed mental model of the executing logic in order to understand the complex behavior sufficiently to (manually) evaluate and compare the results of a test run with the expected behavior, a good unit test narrows the scope of what the programmer must comprehend to something so simple that evaluating it can be done quickly and repeatedly, as often as necessary, automatically.
This narrow and simple focus makes it easer to achieve and recover “flow”, and less costly to lose it because of interruption. It becomes so easy to maintain flow that it is possible for two programmers to work together in a combined state of flow.
The unit defined by a test represents a locally self-contained element of simple behavior. If something is too hard to test, that is a strong indication that it is not simple enough. The programmer needs to remove excess behavior from the context until the code being tested represents a unified conceptual whole.
In my early days as a programmer, the usual process consisted of
- thinking a while about the problem until I had a notion about how an implementation might look.
- writing a bunch of code that seemed like a correct solution at a high level
- fixing syntax errors until the code compiled
- fixing bugs until the code produced the correct output.
- dropping down to a lower level and repeating.
I called this “top-down design”.
As the code grew and became more detailed, starting up the system and running through the steps to verify the latest changes meant keeping more and more context in my head. This context includes things like what had just been done, what this run was supposed to have working, and what might break. It was a lot of work to get into flow, it was not easy to maintain, and it was easy for distractions to break flow.
Test-driven development breaks the work down into tiny chunks of independent function that don’t require a complex mental context of logic to be in the programmer’s head.
Before TDD: A programmer had to keep a complex logical context in a mental model. With TDD: The programmer only keeps a simple context of making the next test pass.
Before TDD: Build and run the entire system, manually check the behavior, set debug breakpoints, and try to remember what was being worked on. With TDD: Build the piece being worked on, run the one test that is failing, have the behavior checked automatically, understand why the test fails. Do something simple to make the test pass. Repeat.