TDD: When It Works and When It Gets in the Way
Test-driven development divides engineers more than almost any other practice. Advocates describe it as transformative - it improves design, produces better tests, and gives you confidence to refactor. Skeptics describe it as bureaucratic overhead that slows development without proportionate benefit.
Both groups are right. TDD is genuinely valuable in some contexts and genuinely counterproductive in others. The problem is that it’s usually discussed as a universal practice, not a context-dependent tool.
What TDD Actually Is
The TDD cycle is:
- Write a failing test for behavior that doesn’t exist yet
- Write the minimal code to make the test pass
- Refactor the code (and tests) while keeping tests green
Red - Green - Refactor. The “write tests first” part is the discipline that produces the design benefit.
Note what this does: it forces you to think about the interface before the implementation. To write a test, you have to know what function you’re calling, what arguments it takes, and what it returns. You’re designing the API before you write the code.
This is the core value proposition. Not “you have more test coverage” (you might not), not “you write better tests” (tests-first can be just as bad as tests-after). The value is that it forces interface design before implementation, which often produces cleaner APIs and more decoupled code.
Where TDD Works Well
Business logic with clear specifications: if you can write the test before the code, the spec is clear. A function that calculates order totals with discount rules is a good TDD target. You know the inputs, you know the expected outputs, and the test writes itself.
def test_order_total_with_10_percent_discount():
order = Order(items=[Item(price=100, qty=2)])
discount = PercentageDiscount(rate=0.10)
assert calculate_total(order, discount) == 180.00
Writing this test first forces you to think: what’s the interface? How should calculate_total be called? What should a Discount look like? These are design decisions you’d make later otherwise, often under time pressure when you’re in the middle of implementation.
Pure functions: input in, output out, no side effects. Tests are easy to write, the cycle is fast, and the design feedback is immediate.
Parsing and validation: parsing user input, validating data structures, parsing file formats. These have clear, enumerable cases that map naturally to test cases.
Refactoring with regression protection: when you have tests from TDD, refactoring is safe. The tests tell you immediately if you’ve broken something. This is the compound benefit - you do the initial design investment once and collect the refactoring dividend repeatedly.
Where TDD Gets in the Way
Exploratory code: when you don’t yet know what you’re building. Trying to write tests first for code you haven’t conceptualized is an exercise in frustration. TDD assumes you know the interface; exploratory coding is about discovering the interface.
The pattern that works better: explore without tests, let the design emerge, then write tests once the design is stable. You’ll often write less code than if you’d been test-first throughout.
UI code: testing UI components at the unit level is possible but often produces brittle tests that break on every styling change. UI design involves iteration - what looks good often takes a few tries. TDD doesn’t fit naturally in an iterative visual design process.
Infrastructure and integration code: code that talks to databases, external APIs, and file systems is difficult to test in isolation. Mocking these dependencies produces tests that verify your test setup, not your code. For integration-heavy code, integration tests are more valuable than TDD-style unit tests.
Performance-critical code: sometimes the design is dictated by performance requirements, not by a clean API. When you’re optimizing a hot loop, the “what should the interface look like” question is less important than “what does the profiler say.”
When the domain is genuinely unclear: TDD assumes you can write a meaningful failing test. If you don’t understand the domain well enough to know what “correct behavior” looks like, you can’t write meaningful tests. This is common when working in unfamiliar domains.
The Design Feedback Loop
The most practical way to use TDD: pay attention to when writing a test is hard.
If writing a test requires constructing 5 objects, setting up 3 mocks, and configuring 2 database fixtures - the code is too coupled. The hard test is the signal. TDD practitioners call this “listening to the tests.”
This works even if you don’t do strict TDD. When you sit down to write a test for code that already exists and find it painfully difficult, that’s feedback about the design. The test isn’t just measuring coverage - it’s measuring how isolated and focused the code is.
# If your test needs to do this, the code is probably too coupled
def test_process_order():
db = MockDatabase()
payment_service = MockPaymentService()
inventory_service = MockInventoryService()
notification_service = MockNotificationService()
user = UserFactory.create(tier='premium', address=Address(...))
product = ProductFactory.create(stock=5)
order = OrderFactory.create(user=user, items=[OrderItem(product=product, qty=2)])
# ...actual test
A function that’s hard to test in isolation is doing too much. TDD forces this realization before implementation. Writing tests after forces it during testing. Either way, the test tells you something about the code.
A Practical Stance
TDD as a dogmatic requirement (“always write tests first, no exceptions”) produces the backfire that skeptics describe: forced tests for everything, including the things that don’t benefit from it.
TDD as a default for business logic, with pragmatic exceptions where it doesn’t fit, produces the benefits advocates describe without the friction.
A useful question to ask before implementing anything: “can I write a failing test for this now?” If yes, do it. If the test would be hard to write (too much setup, unclear expected behavior, unclear interface), that’s information. Either the design needs rethinking, or this isn’t a good TDD target.
The goal isn’t to do TDD. The goal is to have good tests and good design. TDD is one path to both, in the contexts where it fits.