A TDD Mindset
Posted on Wed 16 July 2025 in Methodology
In the beginning of my career, I discovered that having automated tests is invaluable when making changes to any non-trivial software system. Or, put differently, I realized that changing a non-trivial software system without having the support of a suite of automated tests is both risky and time consuming.
I also discovered that TDD (Test-Driven Development) is a really, really good way of writing automated tests.
However, TDD is not primarily about writing tests. It’s about encoding some knowledge or requirement as a piece of code that can verify that a system behaves as expected.1 Dan North realized a long time ago that the word “test” is a distraction (testing is an activity that you do after coding something, right?), and so he coined Behavior-Driven Development. Some unit test frameworks avoid the word “test”—for example, XUnit uses fact and theory.
Despite this, I tend to use the word “test” out of habit. Similarly, I refer to what’s being tested as “unit of test” (UT) or “system under test” (SUT), whether it is a function, class, actor, HTTP API or something else.
When you write a test using TDD, it is the first chance you have to create a client for your UT, and in doing so, to shape it so that it is easy to use in the code base. In other words, TDD acts as a design support tool. I have encountered many functions and classes that were incredibly difficult to use in isolation due to convoluted call mechanisms and high coupling, which typically means that they were not test-driven. When you start with a test, it’s unlikely that you deliberately create a Rube Goldberg machine. I mean, you could, but if you’re in the business of making things hard for yourself, I think there are better ways.
An API (and I mean it broadly, not only web API) isn’t fully usable until you have written three clients for it. I don’t know if it was Arjen Poutsma who first said it, but I first heard it from him (also, I’m paraphrasing; I don’t recall the exact wording). I consider TDD tests to be the first client.
Why TDD?
To me, TDD is just a tool, albeit an incredibly important one. It is a tool that helps me write high-quality code. I use other tools that play important roles in this endeavor as well, such as various input devices, IDEs, type checkers, static analysis tools, and so on—but TDD is special. Unlike some of the other tools, TDD allows me to create a situation where any team member and future maintainer (including future me) can be productive and make changes without being afraid of breaking things.
I think that anyone working with a code base should be able to say the following:2
and all tests on existing functionality are green,
then I didn’t break anything.”
This is an important driving factor for me, and it means that TDD is not only a tool for making me more productive, but also for enabling others to be more productive.
I think that to fully benefit from TDD, it’s important to not just view it as a mechanical activity, and definitely not as an activity for writing tests, but instead to adopt a TDD mindset.
What’s in a mindset?
When I say TDD mindset, this is what I mean:
View a test as representing a requirement or a desired outcome, rather than something that cements a particular implementation.
A common problem with writing tests after the production code is that the tests become tightly coupled to the implementation, resulting in a brittle test suite. With a TDD mindset, you end up with behavior-oriented, flexible tests that allow implementation details to change.
Many years ago, I worked on a project where a team member was tasked with implementing some requested changes to domain logic. As they did that, a number of existing test started to fail. Instead of viewing those tests as requirements that were now violated, they updated the failing tests to match the new changes. A while later, we received a bug report on broken behavior. With a TDD mindset, you recognize that tests are requirements, and investigate why they fail. In this situation, the requested changes were actually incompatible with the original requirements.
Accept the initial investment and focus on the long-term perspective.
The first test is always the hardest to write, as it carries the cost of preparatory activities. Even if you’re test-driving new code in an empty code base, you probably need to set up some initial test infrastructure and test data. However, the investment quickly pays off.
If you use TDD to write code in an existing code base with limited testability, it’s likely that you will have to spend a lot of time paving the way for the first test. However, the return on investment will be equally great, as you take the first steps towards making it possible to change the code with confidence.
Having a TDD mindset means ensuring that a code base stays healthy and can be maintained over a long period of time. This goal outweighs any short-term wins that may result from skipping TDD right now (though personally, I’m faster with TDD than without, so it’s always a win for me).
Decouple code to increase testability.
As noted, TDD is a design support tool. Using TDD inevitably leads to a decoupled, testable design. But sometimes, you’re in a situation where it’s next to impossible to write a test, let alone use TDD. Typically, this happens if you use a platform or third-party software that is not built with testability in mind. The technology becomes an obstacle.
I once tried to write tests for an old PowerBuilder application, but realized quickly that I was in over my head. The PowerBuilder version in question did not provide any mechanism whatsoever for writing tests. Thus, encouraging the developers to use TDD was simply not an option.
Having a TDD mindset means not giving up. Instead, create and maintain a clear separation between code that is tied to the platform/technology and code that is free from unwanted dependencies. The latter can be domain model code, view model code, or pure functions, for example. This way, the amount of untestable (or at least hard-to-test) code can be kept to a minimum. If your existing code base is tightly coupled to a specific platform, drain it one piece at a time to move to a situation where you can test-drive future changes.
Make sure the tool is as useful as possible.
TDD is a tool, and any tool should be used in a way that maximizes its usefulness. With TDD, this includes a number of practices such as naming tests in a good way, ensuring that tests fail for the correct reason, and making sure that running tests is really fast.
When embracing a TDD mindset, a whole bunch of common questions become irrelevant: Should I write tests for this function? How high test coverage should we have? Is it safe to make this change or will something break?
Thus, using TDD should make things easier. It should be something helpful, not an obstacle.
If it doesn’t make things easier, consider what needs to change. Do you need better test helpers? A faster test runner? Should you test-drive on a different abstraction level? I have worked with TDD for about 20 years, and I still try to find ways to improve how I work and how TDD can help me write higher-quality code.
Prototyping
Should prototypes be test-driven? No, I think that would defeat their purpose. A prototype is a mechanism for experimentation, discovery and learning. Maybe you need to write integration code, but you’re not sure how the other party behaves, so you need to write some code to find out. Maybe you should build a UI component, but you need to compare a few alternatives before deciding what is best for the end user.
That said, sometimes it is possible and helpful to write a very high-level tests to use as a guide for the prototyping activity. With my TDD mindset, I do that if I have an opportunity.
When you’re done, you can throw away the prototype and start test-driving real production code. Or you can comment out the prototype and test-drive its inclusion, though that has its risk and is more a test-first activity than real TDD. But let’s not split hairs…
Just use AI instead!?
Does TDD play a role in a future where we use generative AI to assist software development? Yes, I think so very much!
Consider the following factors:
- TDD is primarily about formulating requirements and desired behavior.
- LLMs often produce plausible-looking but incorrect code, too verbose code and code that isn’t strictly necessary.
- LLMs sometimes get derailed and lose track of the task at hand.3
Even though careful prompting, chain-of-thought, and multi-agent systems can improve the performance of an AI coding assistant, having expressed requirements using TDD is an excellent way of ensuring that the end result becomes what you intended, and that the code remains maintainable over time. Additionally, requirements-as-tests provide useful context to an LLM.
Should you let the AI coding assistant write tests? It is certainly able to (though any quality qualms you may have over production code applies to generated tests as well), but is it a good idea? It depends on how you approach the task. If you really try, you can make an AI assistant use TDD, but you need to make sure that the tests actually correspond to your requirements rather than what the LLM thinks is most probable. Using TDD is a good way of discovering what the next logical step is, and though there is research on how to use generative AI for requirements engineering,4 an LLM won’t always be able to predict what you don’t know yet.
Summary
Having a TDD mindset means to use tests as requirements for writing correct and maintainable code (rather than seeing it as a test-writing activity), to accept that the initial cost may be high but consider the long-term effects (rather than having just the short-term perspective), to decouple code to be able to test-drive as much as possible (rather than blaming a platform/framework/technology for preventing the use of TDD), and to see TDD as a tool that should be useful and helpful and strive to make it even more so (rather than accepting friction).
-
See chapter 18 in: Beck, Kent. Extreme Programming Explained: Embrace Change. 1st edition, Addison-Wesley, 1999. ↩
-
From my Swetugg presentation, “The T in TDD stands for… Team?” ↩
-
Laban, P., Hayashi, H., Zhou, Y., Neville, J. LLMs Get Lost In Multi-Turn Conversation. Preprint. arXiv, fetched 2025-07-16. ↩
-
AI for Requirements Engineering (AI4RE). Örebro University, fetched 2025-07-16. ↩