How TDD is More Than Simply Unit Testing
I was recently asked about the difference between unit-testing and Test-Driven Development (TDD). Specifically, why—if the end results are the same—would I recommend TDD over writing unit tests after coding?
The difference between TDD and unit-testing is subtle but important. TDD is much more than writing the unit test first.
A Way of Thinking
Despite its name, TDD is not initially a testing practice.
Of course we end up with a thorough safety-net of highly-specific unit tests. But TDD is first and foremost a powerful way of thinking about our code, and writing that code without introducing defects into the repository. We ask ourselves “what exactly do I want my next few lines of code to do for me, and how can I know that I got it just right without breaking other stuff?”
We’re all smart enough to write the code without the test, but when we write the test first, we don’t have to worry about getting each bit of logic correct (beforehand, in our heads). Instead, we have built our own very fast feedback loop of recent tests, and we can write code far more rapidly and confidently.
So while it’s true that—if your code is well-designed—you can write the tests afterward and get the later benefit from having the unit tests. But you’ll be missing the benefits of that early ease and confidence.
Plus, let’s face it, writing unit tests (the chore) after the code (the fun part) is akin to eating cold vegetables after your dessert.
TDD doesn’t just change the order of tasks: it makes writing the unit test an integral part of writing the code. It’s actually part of the fun of writing code.
Recording Your Thoughts
Objects and classes never live independently of each other. Instead, they combine and interact to create all the required behaviors. A “unit test” is a test of one tiny “unit” of that larger “business model” behavior.
The thought-process for TDD is not “Oh, let me write a test for my code…well how can I do that if I haven’t written the code?” It’s closer to “I need this particular object to provide this next new bit of behavior. When I give the object this state, then ask it to act on that state, here are results I expect.”
Note that this internal dialog naturally occurs from a viewpoint external to the object. You’re recording a mini-specification for that new behavior in a simple, developer-readable, automated test.
If the required class or methods don’t exist yet, then we’re effectively deciding the names and designing the interface to that object through the tests. The unit tests are the first “clients” of that behavior. In this way, our interfaces (i.e., what objects are designed to do for other objects) are designed from the correct perspective: Each from the caller’s perspective.
Three Types of Behavior
All code does one of three things:
- Gives the caller an answer (as a return value)
- Alters the state of the object (or system)
- Delegates to another object (or system)
You’ll find these three basic types in all forms of functional software testing.
The first type is easy to test. The second is usually easy, as well, because there has to be some way to query for the state-change, otherwise the behavior is pointless. The third may require the use of a “test-double” to replace the dependency. Thankfully, creating test-doubles has become easier in all modern programming languages. (We may cover that in a later newsletter.)
The more testable the code, the easier it is to distinguish one type from another. By using TDD, we address issues of testability early and often. At all levels of the system, the ability to test a behavior in isolation helps us think clearly about the implementation, helps us avoid combinatorial concerns (by decoupling concerns using simple object-oriented design guidelines), helps us identify and resolve defects quickly, and—perhaps most importantly—helps us enhance functionality later without compromising the quality of earlier functionality.
Have you ever looked at a block of a mere dozen lines of old code (that you now have to enhance), and struggled to figure out all the permutations of what it does, and what could go wrong? If your team is writing unit tests afterwards, what if they missed an important scenario for that object? You could then mistakenly introduce a defect that would slip past the test suite.
When a team reassures me that they’re adding tests after the fact, we often discover they have perhaps 20-30% behavioral coverage, or the tests don’t sufficiently check expectations. That leaves a lot of untested behavior that could break without anyone knowing!
With TDD, we don’t add functionality without a failing test, so we know we are much less likely to miss something. We’re actually “thinking in tests.” TDD requires the developer to keep a short, temporary list of as-yet-untested scenarios, and we usually discover important new scenarios while writing the tests and implementation for the more obvious scenarios.
“Unit testing” is writing many small tests that each test one very simple function or object behavior. TDD is a thinking process that results in unit tests, and “thinking in tests” tends to result in more fine-grained and comprehensive testing, and an easier-to-extend software design.Please keep the questions coming! It’s a great way to keep this newsletter timely and helpful.