I can’t believe I’m still writing this kind of stuff, but here we are. The design & implementation of test-driven code is exactly as good as the design & implementation decisions that go into that code. Exactly the same as code that isn’t test-driven.
Here’s one strawman argument, in particular, that I want to refute—using TDD you’ll never generalize.
Thanks to today’s sponsor Unblocked.
When I was a young programmer we were berated for not writing more documentation, but that documentation always turned out to be useless by the time we needed it. That experience was my impetus for writing about communicative code (Smalltalk Best Practice Patterns & Implementation Patterns).
Unblocked meets the same need in the same moment--I have to change this code but what is going on? Unblocked answers this question with less effort on the part of the original programmer & the answers are always up-to-date.
Example: Factorial
We start with:
assert factorial(1) == 1
The implementation is easy:
function factorial(n)
return 1
Now we add:
assert factorial(2) == 2
If we want to get to green as quickly as possible we have a couple of options. We’ll pick this one:
function factorial(n)
if n == 1 return 1
return 2
In the naive application of TDD strawmanned above, we’d just keep doing this, adding line after line after line to the factorial function. We don’t do this.
Generalize
Almost nobody does this (I could tell some stories…) Instead, most everybody eventually realizes that there’s structure implicit in the code above. That “2” in factorial()
isn’t just an integer, it’s actually the product of two terms:
function factorial(n)
if n == 1 return 1
return 2 * 1
The “2
” in “2 * 1
” isn’t really a constant, it’s really the parameter n
. If we generalize, the same tests will pass (“observationally equivalent” but that’s 2 chewy words).
function factorial(n)
if n == 1 return 1
return n * 1
The “1
” in “n * 1
” isn’t a constant either, it’s really the result of a recursive call.
function factorial(n)
if n == 1 return 1
return n * factorial(n - 1)
And there we have our perfectly general factorial function, test-driven, & generalized in teensy-tiny steps.
Challenges
If all generalizations were this simple, programming would be easy. It isn’t easy, so all generalizations must not be this simple. The difficulties I encounter in practice are:
I don’t have enough tests to constrain the code to only “good” states. I can have a naive simplifying assumption in my code for a while before I encounter a case where it doesn’t work.
I may not know how to generalize. I can 2 or 3 or 4 or 13 special cases in my code, sometimes for years, before I see how to generalize them. That’s okay, as long as the code works for the cases we care about.
The thing that never happens is that I copypasta endlessly. I suppose this is one of the benefits of ADHD (or “boredom sensitivity” as I prefer to call it). By the time I get to return 4
there’s no way I’m not going to generalize.
“Golfing” is reducing code to the fewest possible tokens (the result is generally unreadable, but it’s a good skill in small doses). There’s an equivalent to golfing in TDD—what are the fewest tests & the earliest generalization that results in code with the desired behavior? Maybe we should have speedrun TDD contests?
Coda: Coupling
The naive implementation above is coupled, in an Empirical Software Design sense, to the test. Every time we add a new assertion we have to change the function. Generalizing eliminates test/implementation coupling. We’re free to add tests (if we feel like it) without changing the code, just as we are free to change the code without changing the tests. (Exercise for the reader—incrementally replace recursion with iteration.)
I might summarize this as "Naive developers will write naive code, TDD or not."
I have this in a quick note. It has the "3 laws of TDD" plus this below.
If I remember correctly, this comes from some Uncle Bob lecture:
---
"As tests get more specific, the code becomes more generic."
TDD Transformation List (High to Low Priority)
1 - Null
2 - Null to Constant
3 - Constant to Variable
4 - Add Computation
5 - Split Flow (not yet a switch)
6 - Variable to Array
7 - Array to Container (lists, trees, queues...)
8 - If to While
9 - Recurse (recursion, but careful with tail call optimization)
10 - Iterate (for when recursion isn't a solution)
11 - Assign (changing value of variables)
12 - Add Case (switch, else ifs...)
---
Even in this simple factorial example, this shows. And I keep it because I found it helps when I need to find where/how to generalize the code.
So, while there's a lot of practice involved, there should be other "cheat sheets" one can use to "speed run TDD".