On writing ALL tests first
A while ago, I had a conversation with a friend who shared how their team switched from a test after to a test first approach. They began with training and a workshop, using their actual codebase for real-life examples. My friend was pleased—they were now practicing Test Driven Development (TDD). So far, it sounds like a success story.
However, one detail bothered me: the method they adopted involved writing all the tests upfront. For experienced TDD practitioners accustomed to the red/green/refactor loop and writing one test at a time, this approach seems odd. I quickly learned that the “why” behind the TDD cycle isn’t always immediately clear or easy to comprehend. And that there are good arguments for an all tests first approach:
Arguments for Writing All Tests Upfront
- Early Design Insight: Writing all tests upfront might reveal whether later, more complex test cases require a different design.
- Progress Tracking: Having all tests in place makes it easier to track progress (e.g., “5 out of 12 tests are green!”).
- Tester Involvement: Test cases could be written by a dedicated tester.
- Comprehensive Testing: Ensures the solution is well-tested.
Perceived Risks with Pure TDD
- Repetitive Implementation: Constantly revisiting and rewriting the same functions might seem redundant, especially if you think you already know what the final solution should look like.
- Late Discovery of Breaking Requirements: Without a full picture, you might design a solution that doesn’t account for all requirements, leading to wasted effort when breaking requirements emerge too late.
- Skipping Important Tests: You might unintentionally overlook critical test cases because the solution appears to work.
So, why should you bother with the red/green/refactor style, writing one test at a time?
The Case for TDD
Obvious Implementation
Before diving into the benefits of TDD, let’s acknowledge that some implementations are trivial or obvious. In such cases, it’s fine to skip TDD—perhaps even use a test after approach. However, the boundary for when TDD is needed is often blurrier than you might think, especially for less experienced developers. The following points apply to non-trivial cases:The main premise of TDD seems counterintuitive: “How can you achieve a better design of your solution when you plan ahead less?”
From my perspective, similar to techniques like CI/CD or an agile approach, the key differentiator is the fast feedback loop. One fallacy is assuming TDD means no thinking or planning, just blind iteration. In reality, it’s an intentional, thoughtful approach where understanding and assumptions are constantly challenged.
Don't Play Dumb
It’s important to note that the iterative TDD approach is not about playing dumb. If you’re aware of a requirement that might significantly influence the solution design, nothing stops you from choosing test cases that guide you toward a good solution.The incremental approach provides continuous feedback on your implementation and how easy it is to test. When you encounter cumbersome boilerplate, you can analyse whether it is accidental and a consequence of the current design. Or whether you can refactor towards a better abstraction or encapsulation. In the long run, this helps making the code less rigid and brittle. By writing only the test cases that initially fail and just enough code to make them pass, you achieve a minimal implementation. This avoids adding unnecessary functionality that isn’t warranted by tests (YAGNI).
By iterating quickly, you increase the likelihood of encountering breaking requirements early. Touching a code location multiple times, enhancing and refactoring it, provides valuable feedback on how adaptable and maintainable it is. The refactor step is crucial in this process. Regularly improving and refactoring the code ensures a good, maintainable solution. For organizations that struggle to find time for refactoring and code quality improvements, this step can help by reducing the need for expliced code maintenance activities.
When you write all tests upfront, you lock in your assumptions about the final implementation. While the code might pass these tests, the implementation is no longer being shaped and guided by the tests themselves. By writing the next test case iteratively, the tests truly guide the solution design. The test coverage you gain becomes more a byproduct of the design process. With TDD, you have a minimal set of test cases that define the implementation, making it easier to change in the future.
Benefits of a Red/Green/Refactor Approach
- Adapts to Change: Each test shapes the design, making it easier to adapt as new requirements emerge.
- Avoids Over-Engineering: Encourages writing only the code necessary to pass the current test, preventing unnecessary complexity.
- Early Problem Detection: Helps identify breaking requirements early, minimizing wasted effort.
- Improves Design Quality: Frequent refactoring ensures your code remains clean, flexible, and easy to maintain.
Conclusion
It is important to not be too dogmatic about testing styles - use the one that works best for you. Instead of being pure and only ever use a single approach, I tend to use the one that works best for me in a given situation. (and yes, sometimes this even is test after). My default approach is TDD, writing a single test at a time, and I highly recommend giving it a try.