TDD is achievable without tests
For almost two years, I worked at a company that used Test-Driven Development as a mandatory practice — every line of code had to be written only after a corresponding test case. Coming from a background where tests weren’t even prioritized and were considered more of a burden than a blessing, it took me a while to adapt.
What I noticed there, though, how exceptionally well-maintained the codebase was. New features could be easily integrated. Coupling was loose. Test cases covered the entire application, providing incredible assurance during development. Because of all this, mistakes were rare. We didn’t have serious bugs or technical debt that could hinder upcoming plans. Everything seemed perfect. I really loved it.
It took me about six months before I started encountering the first problems with the methodology (or at least with how we approached it). I noticed that small but important changes could take an enormous amount of time to develop and ship. This often happened when an action was isolated within a component, leaving no entry point for a testing assertion. Since these small changes were considered important, we had to come up with a “smarter” assertion, which always took time. Testing an action, or, in other words, a message, trapped in such circumstances was nearly impossible or required mocking or spying close to implementation details (which we often ended up doing).
For this very reason, they rightly advise never to test internal messages. The burden those tests impose on the codebase outweighs any hypothetical value. It’s painful and time-consuming, so why bother? The smartest move here is to not write the test at all. It might seem like a violation of TDD, but I consider it an exception to achieve maintainable software. Anyway, the point is — not everything should be tested.
The next stumble happened during a fairly large refactoring. On the word “refactoring,” I should make a little detour. Refactoring never implies new behavior. The structure, implementation, variables, messages — any of these can change — but the program’s behavior, the output, must remain the same. Refactoring is about changing the algorithm, not its result. With that in mind, TDD promises that refactoring will never require changes to your test files. But for me, that didn’t happen. Every single file I modified required corresponding changes in its test file, otherwise, everything was red.
In the beginning, I considered this a fault of TDD. The practice seemed so brittle that it couldn’t even keep its own promise. Of course, this wasn’t true. There was nothing wrong with TDD itself, the problem was coming from us — from how we interpreted public contracts (which are supposed to be tested) and implementation details (which aren’t supposed to be tested) in our UI framework. Even with all our precision and love for TDD, we couldn’t get it right and ended up following a quasi version instead of the canonical one. That’s why I can easily understand people out there who “don’t get it” — it’s tricky to do it right.
This made me realize something else about TDD. Even with daily use, all the books, and conferences — you’re still gonna mess up. It isn’t about experience, you can have plenty. The problem feels deeper, in the practice, not something the user is doing wrong.
Just before leaving the company, I experienced the last issue. My attention was caught by a few components that could’ve been written more procedurally but ended up unnecessarily flexible for their function. Ruling things out, I realized there were a few similar components in the codebase, and these new ones silently inherited their “way.” It wasn’t about consistency, which definitely matters, the issue lay clearly in unnecessary design decisions. The developer, while writing a test, already had a conceptual vision of how the future component was going to look. So, the implementation wasn’t driven by tests but by their familiarity with the codebase.
This baffled me for a while. On the one hand, it’s the most common mistake while learning TDD (to envision the upfront implementation). On the other hand, why would this happen to a developer who had clearly been using the system for years?
I couldn’t answer this question directly, but this and previous problems led me to a realization of a few other things mainly related to a conceptual misunderstanding that happens to all of us:
First, TDD is exceptionally good at one thing — leaving everyone confused. No matter how experienced or inexperienced you are with the approach, there will always be room for misunderstanding.
Second, TDD implicitly makes you believe in a false premise: the idea that a specific sequence of actions (Red-Green-Refactor) automatically shapes application design. The sequence as the savior, so to speak. Unfortunately, that’s a misconception. Following the cycle with this mindset creates either a false sense of security (Test-Driven Theater) or total confusion, as if the methodology itself can resolve problems without personal effort. For some, it devolves into a cargo cult, for others, it leads to outright rejection. Either way, it ends up as a complete misinterpretation of the practice and brings no value.
Third, many benefits that TDD takes credit for actually arise from another reason. It might sound controversial, but imagine seeing an impressive codebase with only the knowledge “it was created using TDD.” Intuitively, you link the two, and now, in your belief system, TDD equals great design. That’s exactly what happened to me in the beginning. What I missed, though, that every feature had to pass through extensive code reviews, pair programming sessions, tech plannings, refactorings. I wasn’t aware of these collaborative elements, but they were the ones that largely contributed to the final state of the production code, than Red-Green-Refactor (as I realized later). Since the belief system was already established, I couldn’t see otherwise for a long time. This widespread misinterpretation led to claiming benefits that weren’t even related to the practice.
I’m not trying to diminish all the advantages TDD is supposed to bring. I still find it quite helpful, but for me, it excels in other areas than software design: keeping attention on a single task at a time, building reliable test coverage, and getting satisfaction from incremental changes. Not building the design.
If one is stubborn enough to continue relying on TDD as a “design” tool, eventually, they’re going to face another issue. When should this design actually happen? Generally, what I see are two different camps with completely opposing beliefs. One camp advocates that design happens at the very first step of the cycle — Red — and the rest of the steps are merely implementing details that aren’t publicly exposed. The other camp, though, says the first step only embraces the “ugly solution” to make the test pass, and the actual design emerges at the last stage of the cycle — Refactoring.
I believe neither of these makes sense because they’re both right and wrong. Design decisions will always depend on the environment in which one is writing the code: language, framework, library. It’s so easy to pass the initial step while creating a JSX component. It doesn’t require you to consider any pros and cons of design. Everything is inlined within the same render tree. Everything is reachable. Conversely, writing functionality in an MVC application forces you to acknowledge design at the very initial step. The callee (not a particular method, but an invocation to achieve a state which evaluates behavior) cannot be changed when the test is done, therefore, the Red phase will be the most important one.
Since every environment is different, I see no point in searching for a theory that accommodates every possible scenario. Therefore, TDD will never be equal to design (automatically) — it’s always going to be up to you.
Nevertheless, the methodology still teaches a valuable lesson. The lesson of taming desires, deterring abstractions, and acknowledgment of the difference between outside and inside. The whole Red-Green-Refactor cycle is simply an illusion, but it works as a gateway to something deeper. At its core, TDD has always been about the separation of steps. The distinction between shaping an external interface and crafting the internal solution in isolation from each other. It’s about having the freedom to change one without impacting the other. Establishing clear boundaries by separating concerns. Test cases serve only as feedback to you, the programmer, that the external interface and internal details aren’t dependent on each other, or, in other words, are loosely coupled (which only helps to create a good design).
Reframing the mental model as external/internal separation brings back the true value of the practice. Practically speaking, you could even follow the process without writing any tests. You’re certainly demolishing the assurance system, but the core idea would remain intact. The real key to TDD isn’t about tests or three stages anyway.
I really blame the terminology for the confusion. Borrowing colors from a test runner to represent the avoidance of premature abstraction wasn’t a great idea. It buried the real message. And, by the way, refactoring isn’t even a color, and it might not even happen during a cycle. So why include it in the core overview? They failed to communicate the concept clearly, and that’s their biggest mistake.
Simplify your life and call it simpler — external, internal, repeat. Ditch the dogmatic cycle which creates more confusion than clarity. Let’s make the knowledge explicit and maybe more people will embrace the method.