Every team I’ve seen adopt TDD goes through the same arc: enthusiastic strict adherence for a few weeks, a crunch period where “write the test first” quietly becomes “write the test after, same day,” and eventually a steady state that looks nothing like the textbook cycle but still produces well-tested code. I used to think the steady state was a failure of discipline. It isn’t — it’s what actually survives.
What the textbook version gets right
Red-green-refactor forces you to write a failing test before the implementation exists, which sounds like ceremony but does something real: it forces you to define what “correct” means before you’re anchored to a specific implementation. Skip this step and it’s very easy to write a test that just describes what your code already does — which passes immediately and tells you nothing, because it can’t fail against a wrong implementation you haven’t written.
The other genuine benefit is that tests written this way are forced to be about behavior, not internals, because there’s no internal implementation yet to accidentally couple to. Tests retrofitted onto working code have a strong gravitational pull toward asserting on internal state, mocking things that don’t need mocking, and generally becoming brittle in ways that make refactoring harder instead of safer — which defeats the entire point of having them.
Where it breaks down in practice
Strict TDD assumes you know the shape of the interface before you write it. That’s true for a well-understood CRUD endpoint. It’s false for anything exploratory — a new integration with a third-party API whose actual response shapes you’re still discovering, a data pipeline where the transformation logic is genuinely unclear until you’ve looked at real data. Writing a failing test first for code whose shape you don’t know yet just means rewriting the test three times as your understanding changes, which is slower than sketching the implementation first and writing tests once you know what “correct” looks like.
The other break point is deadline pressure, which is not a moral failing, it’s a constraint. Under real pressure, strict TDD is the first thing that goes, and pretending otherwise just means your team lies about their process instead of adapting it.
What we settled on
Test-first for anything with a clear, known contract: API endpoints, serializers, permission checks, anything where “what does correct mean” is answerable before writing code. Test-after, same commit, for exploratory work — write the spike, understand the actual shape of the problem, then write the tests that lock in the behavior you settled on, before it goes anywhere near review. The non-negotiable rule wasn’t “test first,” it was “no PR without tests” — which sounds like a downgrade from TDD but is actually the part that mattered. The ordering was a discipline tool for engineers still building the habit of thinking in testable units; the requirement that tests exist at all was the actual quality gate.
If your team’s TDD practice looks less pure than the blog posts describe, that’s probably fine, as long as the thing it was protecting — tests that describe behavior, written close enough to the code that they’re not an afterthought — is still intact.