I recently saw a compilation video demonstrating the mind-bending skill of a rock balancer named Pontus Jansson. Taking any assortment of oddly shaped stones, Jansson is able to perfectly position the stone’s centers of mass, piling one on top of another into true works of art.
I was mesmerized as I watched the master work, truly in awe of his amazing skill. I thought, “This guy must be a magician!”
In typical internet fashion, the auto-play feature started the next video; this time a compilation of Jansson’s structures falling over.
I quickly realized that, while beautiful, these creations are also incredibly fragile. Placing a stone a millimeter to the left, or rotated a single degree in the wrong direction would cause a chain reaction of destruction. A slight breeze would be enough to topple his tower.
It is not uncommon to write code that mirrors Jansson’s creations; skillfully executed, to be sure, but with increased risk of collapse with each additional line.
The obvious solution to increase the stability of his creations would be some scaffolding, or perhaps some mortar. Ultimately, good code shouldn’t resemble Jansson’s work at all. Good code should be flexible, not rigid. It should be adaptable and refactorable when needed, and should be able to stand up to any scenario you throw at it.
Simply put, test-driven development (TDD) is the idea that you should always write tests for your code before you write the code itself. It is an iterative process of writing one simple test for an intended case or requirement, then writing only enough of the production code to make that one test pass. Once the test passes, write another (failing) test for the next scenario, make that test pass with new production code, and so on.
This might seem odd at first, but it is incredibly powerful in practice. It ensures that you always have your bases covered with easily repeatable tests.
In a non-test-driven development paradigm, you might hammer away at a problem using a debugger or print-statements until you get your intended output, but it is likely that corner cases won’t be covered and unlikely that you will continue to robustly test older sections of code after new code is implemented.
With test-driven development, you never delete a test, so you can always be assured that a change to your code won’t have a ripple effect, since you’ll know as soon as you run your tests (which should cover all of your code) whether a small tweak breaks something down the chain.
During development, it is best to run your tests as often as possible, ideally after every appreciable code change. This is reinforced by the test-driven mindset; you should only be writing code that you have a (failing) test for, so you will naturally need to re-run the tests to see if your code changes caused the test to pass (or potentially caused a previously passing test to fail).
The test-driven mindset couples well with other clean-code best practices. Short functions with self-documenting code are easier to write based on a series of well-crafted unit tests. Short functions are easier to maintain in general, and especially so when you have unit tests to tell you when they might break. Refactoring is made much easier, too, as you can change the function’s implementation while still having tests to ensure its output.
Another advantage of the test-driven mindset is that it prevents you from writing a bunch of superfluous code. Remember, you should only be writing code in order to make a test pass, and if you have decently defined requirements or design goals from which to write your tests, you shouldn’t find yourself off in the weeds writing code that isn’t needed.
While this may all seem tedious at first glance, writing code in the test-driven paradigm is actually a lot of fun! Making small, testable changes to your code provides an immediate feedback loop with a constant stream of small rewards that helps keep you motivated.
Popular (and addictive) games like FarmVille or Candy Crush work on a similar psychological dynamic. This shortened feedback loop also helps prevent future debugging woes. Assuming you have done a good job of covering the corner cases with tests, you’ll more easily prevent bugs from creeping in, only to be discovered months later.