‹ Blogs

Making TDD Work for You, Part 1: When to Invest and Essential Practices

Featured Image
Published on June 10, 2025
Author Houssem El Fekih

Software development has changed a lot over the last few decades. Now, methods like agile, continuous delivery, and shifting left for security are common practices. However, the practice of Test-Driven Development (TDD) is still not widely used, even though it comes with clear benefits. This post explains why TDD is a key tool for building reliable and maintainable software.

Part of the reason TDD is not widely used is that the resources and examples on TDD often only cover the basics and leave many important questions unanswered. We hope sharing our experience will help those who haven’t tried TDD or who have tried and failed. I’ve written this series of blog posts to provide some day 2 guidance to make the TDD work for you and avoid some of the pitfalls I faced when learning it over the years. In this introductory article of the series we will focus the trade-offs of TDD adoption and cover high level good practices for TDD.

Developers often overlook that TDD is mainly a technique to help them, TDD allows you to focus on one thing at a time, whether it’s a high-level requirement or a sub-task. It creates an automatically self-testing codebase that allows you to have confidence in refactoring your code.

It does not help that some pundits can be overzealous. This likely makes TDD feel more stressful for developers than helpful. To move past this zealotry, I challenge the idea that TDD must always be used. As with any tool or technique, it may or not present the right benefits, and depending on your situation, TDD may not be the appropriate choice.

Cost and benefit of TDD

TDD brings these benefits, in my view:

  • Creates self-testing code that increases code reliability.
  • Once mastered, it makes writing software easier by helping to focus on one thing at a time.
  • It allows for confident refactors in small steps with the confidence the tests give
  • It provides early feedback about your public APIs/interfaces as you use them in your test, which improves user experience design
  • If you write acceptance tests for each feature, you would focus as an engineer on customer and business needs.

What you risk/lose:

  • Time for the first implementation will go up. A rough estimate of 50% seems fair.
  • When you are still learning the technique, you can spend a lot of time setting up the first tests.
  • In one style of TDD, you must always create a “walking skeleton” first, which is a bare-bones, high-level end-to-end design. This may disrupt the flow for some people.
  • If TDD is done poorly, which we will cover in-depth, you will create obstacles to refactoring.
  • When there are tight deadlines for a big bang release, the test overhead will increase stress instead of reducing it.

Based on the above risks/benefits, here are some examples where it might not make sense:

  • PoC for a product or one off project that will not be maintained long term
  • Domain where writing tests is too difficult due to lack of maturity of test tooling, or no much margin in terms of development time, like gaming or parts of firmware testing.
  • Side project where you are focused on learning
  • You are very new to the practice and working on a critical project with a tight deadline, where I would encourage partial, gradual adoption.
  • “Spike code” if you’re confused about the domain.
  • If you don’t know where to start, where a “spike solution” without much tests is better, which you could stabilize with tests or, ideally, rewrite it from scratch using TDD style later.

I should note that even though I stated that some domains like gaming or embedded systems are harder to TDD, this is mainly due to cultural reasons, complexity of game/hardware states and the fact that a lot of game codebases for example are single release or few patches, but that does not mean that most of the libraries and game engine algorithm could and should be developed TDD style and smoke tests should definitely be developed if the time constraints allow. These industries cover this by having late testing with abundant game QA testers but they could benefit from higher flow when TDD is adopted.

Despite these cons, TDD is essential for continuous delivery of value to customers. Since it enables shipping new features faster with fewer defects. This is a key pillar that enables CI/CD! Any software that will endure and undergo modifications for a long time after the initial launch would gain substantial advantages from the practice.

Furthermore, TDD helps with reliability where it is paramount, like, for instance, in:

  • Critical banking/financial software, where the financial cost of defect is too high
  • Library code that will be leveraged by many consumers.
  • Any customer-facing product where the cost of a defect is too high
  • Open-source software where tests serve as a method to clearly show intent and scale while maintaining quality.

Great, so you may be in the situation that TDD is the right choice for a particular project, what’s next? You may have tried to get some guidance, but the resources online actually make you more confused. What’s going on here?

TDD good practices demystified

Why TDD did not cross the chasm

The resources online around TDD are confusing with many people arguing and repeating slogans like “TDD is not about testing but design” or “mocks are mocking you” or maybe even that “TDD is dead”.
Even for common terminology around what unit and integration tests mean, often people are not exactly on the same page about what they are referring to exactly.
This chaos is what happens when the internet meets a complex topic like TDD, especially since there is a true schism or rift between different pioneers in TDD. This is not helping the situation with newcomers.
If you go down this rabbit hole like I did you will end up learning about different schools of TDD and the reference books on them: Kent Beck’s: TDD by example book and the GooS book by Steve Freeman and Nat Pryce referenced in appendix.
I cover the nuances about the variations in further series of this blogpost and instead focus on the key tenets to getting things right regardless in my view.

For now you should note that, over time, different methods for testing-driven techniques appeared. These changes affect how some of your unit tests and integration tests would look like and in some cases you might call a unit test what you would otherwise call an integration test.

Again, you can ignore all that, and instead recall the universal tenet of the TDD loop:

  1. Write a failing test (red)
  2. Write minimal code to pass (green)
  3. Refactor (improve design without breaking tests).

The methodology of trying to think of the next hypothesis to test or any form of feedback is always valid and red-green-refactor is a good reflex to have as an anxiety reduction technique, allows you to keep the eye on the prize. Even when you cannot practice TDD fully in some scenarios like some game development, keeping this idea in mind would be beneficial.

In my extensive reading of a lot of the key material behind both aforementioned styles of TDD, I extracted the key ideas that allow for good TDD flow:

Common agreed practices

PracticeDescription
Core LoopMake it fail, make it pass, make it better
Testing PyramidMore unit tests than integration and end-to-end tests
Unit Test SpeedUnit tests should be relatively fast (standards have evolved to include isolated Docker DB tests)
Coverage Goal100% coverage for behaviours and APIs, not individual functions!
Regression TestingAlways write bugs as regression tests
Development ApproachFocus on small increments, ideally one commit per passing test
Developer BenefitsTests provide confidence and increase craftsmanship level
PurposeAllow fast shipping of new features; table tests decrease testing costs
Value MockingValues should never be mocked in either school, reducing dependent mock code
3rd party testsThird-party integrations, ideally, should be interfaced with an infrastructure or anti-corruption layer
Test at the right levelhandling edge cases at the wrong level of granularity or testing the framework is unnecessary

One thing I want to stress is that in your unit tests, it is fine to even spin up a real DB, as long as it is fast and isolated.
This article goes into some great depth about the concept of larger tests vs unit tests: and how you can avoid the “ice-cream cone” tests anti-pattern.
You will also find that Google uses a rarely discussed but particularly useful 3rd party api testing pattern, which can avoid cumbersome and brittle hand-written 3rd party api mocks responses or fakes, leveraging instead realistic pattern of Record/replay proxies.
This is a lightweight form of contract testing I personally use a lot in sociable unit tests, which some people may call integration tests.

Another important guiding principle in testing is that you should focus on core functionality exhibited through the public API of the module or unit. This is why unit tests are sometimes called developer tests, in the sense that they give confidence to the developers about the behaviour of a particular unit.

This key insight, shared in Ian Cooper’s brilliant talk TDD, Where Did It All Go Wrong can be summarised as:

Don’t test new functions added to implement a behaviour directly; Test them through the behaviour.

You need to focus on behaviours, not just functions or classes in a test. This truly unlocks the value of TDD. For instance, when you write a new helper function to make a test green, you don’t need to explicitly add tests to that new function, but it will be tested through the higher level API. This means that you are more agnostic to specific implementation. If you were to write very coupled tests to this specific implementation, you increase the cost of doing TDD and reduce the refactor-ability. This is where TDD really goes bad and starts to be an antipattern!

Occasionally though, you might have a particular inner function that you find particularly challenging to write or has edge cases you want to handle, you could (for example) extract it into a separate strategy module and again test it through its public behaviours.

Conclusion

I strongly advise developers to take a gradual approach to adopting TDD. Start with simple TDD Katas to build comfort with the red-green-refactor rhythm, then progress to driving regression fixes through tests, and eventually tackle new features using the technique. This incremental adoption allows the practice to become natural rather than overwhelming.

What ultimately made TDD click for me wasn’t strict adherence to a particular school, but embracing the Zen of one test at a time—using each test as a stepping stone to move forward with clarity and purpose. The beauty of TDD lies not in dogmatic adherence to rules, but in how it transforms your thinking. When you internalize the discipline of clarifying what you want before you build it, breaking complex problems into testable increments, and continuously refining your solution, you’re following the essence of TDD

It takes time for all this to sink in, but when you have picked your style and made it work for you, it can allow for true continuous delivery of value and increase your chance of success for complex projects tremendously.

In the next part of this series on TDD, we will cover in depth the precise differences in methodology between London “mockist” TDD and Chicago/Detroit “classic TDD”, stay tuned!

Resources:

Further reading (advanced topics covered in next blog):