Skip to content

Learn

What is test-driven development (TDD)?

Learn what test-driven development (TDD) is, how it works, and how it improves code quality, speed, and reliability in modern software teams.

test driven development

TL;DR

  • Test-driven development (TDD) is a software development technique where the writing of automated unit tests drives the creation of production code.
  • When using TDD, developers work in the red-green-refactor cycle, which consists of writing a failing test, making it pass with the simplest possible approach, and then refactoring the code.
  • Kent Beck created TDD in the 1990s, and the technique gained more popularity starting in the 2000s.
  • The benefits of TDD are primarily the automated testing suite that it generates, as well as a simpler and modularized design due to the intentional crafting of the interface when designing.
  • Despite its benefits, there are challenges when adopting TDD, such as trying to apply it to legacy/brownfield projects, lack of unit testing knowledge, and cultural resistance.

In this post, we’re going to learn what test-driven development is, its usage, what its benefits are, and whether you should adopt it.

It might be surprising for you to learn that test-driven development (TDD) isn’t a software testing technique, but that’s the truth. What is TDD, then?

TDD is a software development methodology in which automated testing plays a core role. But what does that mean in practice? What is TDD used for, what are its benefits, and should you adopt it?

Learn the answers to these questions and more in our guide.

Test-Driven Development (TDD) is a technique for building software that guides software development by writing tests

What is test-driven development (TDD)?

Definition

Over the years, people have been defining TDD in a lot of ways, including some wrong ones. Let’s start our guide with a clear, succinct, and, most importantly, correct definition:

“Test-Driven Development (TDD) is a technique for building software that guides software development by writing tests. It was developed by Kent Beck in the late 1990’s as part of Extreme Programming. In essence we follow three simple steps repeatedly:

  • Write a test for the next bit of functionality you want to add.
  • Write the functional code until the test passes.
  • Refactor both new and old code to make it well structured.”

– Martin Fowler,
Test-Driven Development

Fowler’s definition clearly states that TDD “is a technique for building software” rather than being a technique for testing software.

Yes, as a result of doing TDD, you’ll end up with a comprehensive suite of automated tests—and that is, in itself, one advantage of adopting the practice. More on that later.

But the real goal of TDD is to use tests to guide your development in ways that result in clean, maintainable code, as well as a high degree of confidence in the correctness of the developed features.

History

From Martin Fowler’s definition above, we can see that Kent Beck created TDD in the 1990s. In 2002, he launched the book Test-Driven Development: By Example, which is a practical manual of how to apply the technique.

Other authors released more books and articles, expanding and building upon the foundation Kent Beck created. Different styles and schools of thought emerged for TDD, and it even spawned related methodologies, such as BDD (behavior-driven development) and ATDD (acceptance-test-driven development).

TDD has since enjoyed significant adoption across the industry, and to this day is an important topic of discussion in conferences and developer communities.

How does TDD fit into Agile development?

Agile is an umbrella term that covers many software development methodologies, of which one of the main ones is XP, or Extreme Programming. Kent Beck developed Extreme Programming while working as project lead for the Chrysler Comprehensive Compensation System (C3) payroll project.

XP is different from many other Agile flavors in the sense that it’s concerned about software engineering best practices. It dictates how software developers must do their work, advocating for practices like pair programming, refactoring, and TDD.

If you’re using a compiled language such as C# or Java, the code from your test won’t even compile

Core principles of test-driven development

How does TDD work in practice? From Fowler’s definition that we covered earlier, it’s possible to see that TDD requires writing tests before the functionality exists. Here’s the same workflow, with a bit more detail, as written by Kent Beck himself:

  1. Write a list of the test scenarios you want to cover
  2. Turn exactly one item on the list into an actual, concrete, runnable test
  3. Change the code to make the test (& all previous tests) pass (adding items to the list as you discover them)
  4. Optionally refactor to improve the implementation design
  5. Until the list is empty, go back to #2

Here’s a visual representation of the cycle:

TDD workflow

As you can see, development starts by writing a test. Of course, the test will fail because the desired feature doesn’t exist.

If you’re using a compiled language such as C# or Java, the code from your test won’t even compile. You then write just the minimum amount of code you need to make the test pass, ensuring previous tests (if any) continue to pass.

What kind of tests are we talking about here, anyway? Though this can vary, tests in the context of TDD almost always refer to unit testing.

But going back to TDD’s flow: What’s the point of working in this seemingly backward way?

Benefits of TDD

In short, the TDD rules, when applied, force you to think of the interface and not the implementation when first developing a feature, ensuring design is simple and less coupled. Here’s Fowler again:

“Writing the test first […] provides two main benefits. Most obviously it’s a way to get SelfTestingCode, since we can only write some functional code in response to making a test pass. The second benefit is that thinking about the test first forces us to think about the interface to the code first. This focus on interface and how you use a class helps us separate interface from implementation, a key element of good design that many programmers struggle with.”

– Martin Fowler,
Test-Driven Development

In his book Test-Driven Development: By Example, Kent Beck expands on the benefits:

“Assuming for the moment that such a programming style is possible, it might be possible to dramatically reduce the defect density of code and make the subject of work crystal clear to all involved. If so, then writing only that code which is demanded by failing tests also has social implications.

If the defect density can be reduced enough, then quality assurance (QA) can shift from reactive work to proactive work.

If the number of nasty surprises can be reduced enough, then project managers can estimate accurately enough to involve real customers in daily development.

If the topics of technical conversations can be made clear enough, then software engineers can work in minute-by-minute collaboration instead of daily or weekly collaboration.

Again, if the defect density can be reduced enough, then we can have shippable software with new functionality every day, leading to new business relationships with customers.”

TDD challenges

For skeptics, TDD is one of those things that sound too good to be true. The truth is that applying TDD is a skill that takes time and effort to master. If you intend to give TDD a try, expect to face some common challenges:

1. Lack of unit testing knowledge

Remember, TDD isn’t synonymous with unit testing. If team members lack unit testing skills, including knowing how to use the unit testing framework and test runner for their language, adopting TDD will be tough.

Don’t attempt to learn unit testing and TDD at the same time; first, make sure everyone is comfortable writing tests. Then, start introducing TDD.

If a team only works with brownfield projects—that is, existing legacy projects—applying TDD is going to be difficult

2. Only brownfield projects

If a team only works with brownfield projects—that is, existing legacy projects—applying TDD is going to be difficult. Those projects most likely weren’t built with testability in mind, meaning that you won’t even have the infrastructure in place to start writing tests.

For projects like this, you must carefully refactor the code and start adding tests. Working Effectively With Legacy Code, by Michael Feathers, is a great book that can help in these scenarios.

3. Difficulty testing certain types of code

UI code and database code are examples of areas of a codebase that might be difficult or awkward to use TDD for.

4. Test maintenance burden

Over time, the size of a test suite tends to grow. Poorly written tests become brittle and break frequently as the codebase evolves, especially tests that are tightly coupled to implementation details of the tested code.

5. Cultural resistance

At the end of the day, the biggest challenge is simply resistance to change. Consciously or not, many developers will reject the shift in mindset that is necessary to work in this radically new way. The learning and adapting curve might be steep, as well.

TDD approaches

There are different styles or approaches to TDD. The main categorization is between two main styles: classic TDD and mockist TDD.

Understanding mocks

Let’s follow a quick diversion before continuing: There is a rich taxonomy of types of fake objects used in unit testing, including fakes, stubs, dummies, spies, and so on. They differ by the intent and way in which you use them.

The industry at large mostly ignores these distinctions and calls every type of fake implementation a “mock.” For simplicity’s sake, this is the definition I’ll use here. But if you want to learn more, read this great article by Martin Fowler.

To define classic and mockist TDD, let’s quickly cover mocking and what problem it solves.

It’s crucial for unit tests to be deterministic

That means that if a given unit test passes and you run it again, it should still pass.

If you run it again a thousand times or a million times, it should still keep passing. It should only fail if you change either the system under test (SUT) or the test itself in a way that breaks things. Of course, the same applies if the test was already failing.

A big challenge for achieving this determinism is handling external, non-deterministic dependencies you can’t control from inside the code.

Such dependencies include database calls, HTTP requests, file operations, reading from a scanner or other device, and even getting the system time. They can be slow, unreliable, and you don’t control their response, which leads to tests that are fragile.

The solution unit testing practitioners found is mocking. Mocking refers to replacing external dependencies with “fake” implementations you control, so they are always available and they always give a response you determine.

You can create those fake implementations by hand, but it’s also possible to use specialized libraries for that, which reduce the overhead of coding those mocks.

Besides letting you define what response they give you so you know what to assert against during your tests, mocks can also allow you to verify the interactions of the SUT against the mocked object.

For instance, you can verify that the GetProducts() method on your mock object was called exactly two times by the SUT.

From this, two different styles of unit testing emerge: state verification and interaction verification.

State verification vs. interaction verification

State verification unit tests are the ones in which you assert against the result that the method from the SUT gives you. This way of unit testing is more of a black-box approach to unit testing, since it doesn’t care as much about implementation details of the method under test.

Interaction verification (or behavior verification) tests are ones in which you verify the interaction between the SUT and the mock object, in addition to or in place of asserting against the result. 

Behavior verification is more akin to white-box testing, because it does concern itself with implementation details.

An important thing to understand is that using state verification testing doesn’t mean you can’t use mocking. You can, and should, substitute your external dependencies with test-only implementations so you ensure your tests are fast and deterministic.

Examples

Let’s now look at a brief example of these two styles of testing. These examples are going to be in C#. First, consider the following class:

public sealed class OrderService(IOrderRepository repository, IEmailSender emailSender)
{
    private readonly IOrderRepository _repository = repository;
    private readonly IEmailSender _emailSender = emailSender;

    public async Task<int> PlaceOrder(Order order)
    {
        var newOrderId = await _repository.PlaceOrder(order);

        await _emailSender.Send(
            to: order.CustomerEmail,
            subject: "Thank you for your order!",
            body: $"Your order was placed successfully with id {newOrderId}."
        );

        return newOrderId;
    }
}

As you can see, the class depends on two interfaces: IOrderRepository, which is responsible for saving the order to the database, and IEmailSender, which sends an email notifying the user about the order.

Yes, this is an extremely simplified example, not representative of how such operations are done in production-grade software, but since this is an example, I have to be pedagogical rather than realistic.

Anyway, the method returns a value, which is the ID of the newly created order. Let’s write a test that asserts against that value, which would be an example of a state verification test:

[Fact]
public async Task PlaceOrder_ValidOrderRequest_ReturnsIdOfNewOrder()
{
    // Arrange
    var repo = new Mock<IOrderRepository>();
    repo.Setup(x => x.PlaceOrder(It.IsAny<Order>()))
        .ReturnsAsync(42);

    var emailSender = new Mock<IEmailSender>();

    var sut = new OrderService(repo.Object, emailSender.Object);

    var order = new Order
    {
        CustomerEmail = "example@example.com"
    };

    // Act
    var actual = await sut.PlaceOrder(order);

    // Assert
    Assert.Equal(42, actual);
}

As you can see, the test starts by setting up mocks for both the IOrderRepository and IEmailSender interfaces. For the repository, the test sets up a response for the PlaceOrder method, so regardless of what it gets, it always returns 42.

Next, the test creates an instance of the SUT (the OrderService class), passing the two mock objects as arguments. Then it acts by calling the PlaceOrder method on the SUT.

Finally, the test asserts that the actual result from the PlaceOrder method call is equal to 42.

Now, we’ll look at another test. This time, it’s an example of behavior verification:

public async Task PlaceOrder_ValidOrderRequest_EmailSenderIsCalledOnce()
{
    // Arrange
    var repo = new Mock<IOrderRepository>();
    repo.Setup(x => x.PlaceOrder(It.IsAny<Order>()))
        .ReturnsAsync(42);

    var emailSender = new Mock<IEmailSender>();

    var sut = new OrderService(repo.Object, emailSender.Object);

    var order = new Order
    {
        CustomerEmail = "example@example.com"
    };

    // Act
    _ = await sut.PlaceOrder(order);

    // Assert
    emailSender.Verify(x => x.Send(
            "example@example.com",
            "Thank you for your order!",
            "Your order was placed successfully with id 42."
        ),
        Times.Once());
}

The Arrange and Act sections are similar, but the Assert is different. As you can see, in the assertion here, the test uses the mocking library syntax to verify that the Send method from the mock IEmailSender instance was called, with those values, exactly once.

This is a behavior verification test, and it makes perfect sense here, since for testing the email sending, there is no value we can assert against.

Mockist TDD vs. classic TDD

Mockist TDD is a style of TDD that uses mostly behavior verification. Classic TDD uses mostly state verification.

Mockist TDD is also known as the London school, while classic TDD is sometimes called the Chicago or Detroit school.

Classic TDD practitioners will use a mock object without a problem when they need to replace problematic external dependencies, but mockist TDD practitioners will resort to behavior verification even in situations where state verification would work fine.

Which approach is better? There are pros and cons to each. It’s important to keep in mind that any approach that relies too much on interaction verification can result in tests that are more coupled to implementation details.

This can produce fragile tests that break when internal implementation details of the SUT change, even if the code is still functionally correct.

However, there are situations where you just can’t use the classic approach. A good example is the email notification example from before. There’s simply no state to verify, so you have to resort to interaction verification.

In short, a good rule of thumb is to mostly follow the classic approach but reach for mockist TDD in situations when state verification isn’t possible or practical.

TDD example

Let’s now look at a TDD example and walk through the steps to implement two features.This will be kept deliberately simple. Our example will be a Calculator class, and the language will be Python.

1. Write a list of tests

According to the canonical definition of TDD by Kent Beck, the first step consists of writing down the tests you mean to write for your functionality. So, let’s start with testing that:

  • returns the correct sum of two positive numbers
  • returns the correct sum when one argument is negative
  • returns the correct sum when both arguments are negative
  • returns zero when both arguments are zero
  • returns the current sum when one argument is zero

The next step is to take one test from this list and run a red-green-refactor cycle for it. For brevity’s sake, I’ll be doing this only for the first two tests.

2. Write a failing test

Using Python and pytest as the unit testing framework, I wrote the first test:

def test_add_positive_numbers():
    calc = Calculator()

    result = calc.add(2, 2)

    assert result == 4

It instantiates the Calculator class, calls the add method, and asserts its result. Of course, the test fails, because the Calculator class doesn’t even exist yet:

Write a failing test

3. Make the failing test pass

In order to pass the test, I need a Calculator class. I start by creating an empty class:

class Calculator:

    pass

And I’ll add the following line at the top of my test file:

from Calculator import Calculator

Running the test now, I still see a failure, saying Calculator doesn’t have an add method:

Make the failing test pass

Let’s create the add method and have it return a dummy value:

class Calculator:
    def add(self, a, b):
        return 0

The test finally fails in the expected way. That is, the comparison is not correct:

Test pass for real

Let’s now make the test pass for real. The simplest possible code to make the test pass now is to simply return a hardcoded value:

class Calculator:
    def add(self, a, b):
        return 4

This often confuses or enrages newcomers to TDD. It doesn’t appear to make sense. However, the so-called “baby steps” are an essential part of the technique.

Always take the smallest possible step to make the current test pass. If you need to write more production code to finish your implementation, think of what test you can write in order to drive that implementation.

The test finally passes:

The test finally passes

Let’s move to the second test.

4. The second test

First, let’s cross the first test off our list:

  • returns the correct sum when one argument is negative
  • returns the correct sum when both arguments are negative
  • returns zero when both arguments are zero
  • returns the current sum when one argument is zero

Now, for the second test:

def test_add_one_negative_argument():
    calc = Calculator()

    result = calc.add(1, -1)

    assert result == 0

The test fails, of course. Here’s a simple implementation that makes the test pass:

def add(self, a, b):
    if a < 0 or b < 0:
        return 0
    return 4

As you can see, I’m deliberately avoiding writing the correct implementation. I keep writing code that is obviously incorrect but makes the tests pass. This is a technique Mark Seemann calls “the devil’s advocate,” which helps you critique your tests and come up with strong test cases.

5. Refactor

The third and often overlooked step in a red-green-refactor cycle is the refactoring step. It’s so important because this is what helps drive the code toward a real implementation in the future.

In the case of the example above, there is a clear duplication in the add method. Two paths return hardcoded values. It’s crucial to avoid that duplication.

This refactoring could be done in smaller steps, but for brevity’s sake, I’ll jump directly to the correct implementation:

class Calculator:
    def add(self, a, b):
        return a + b

This, of course, makes the test pass.

Is a 20+ years old engineering practice still relevant today?

One interesting thing about software development is that it changes a lot, very quickly, yet at the same time, it seems to never change at all. What do I mean?

Tooling changes a lot. New languages and frameworks come in and out of fashion in a heartbeat. Practices go from cutting edge to acceptable to commonplace.

But the fundamentals don’t change, or at least not as fast. There are fundamental truths about software development that are as true today as they were when Kent Beck and other pioneers started experimenting with TDD and unit testing decades ago.

For starters, software development is still hard, it’s still easy to add a regression, and having an automated suite of regression testing keeps being as valuable today as it ever was.

Also, the value of developing software in well-designed modules that are as independent from each other as possible and communicate with each other via well-defined interfaces continues to be immense.

Finally, any non-trivial application is still hard to navigate as it was 20+ years ago. Having automated unit tests that can serve as living, executable documentation for your code is a great help.

TDD in the modern development landscape

All of the above should help you conclude that, yes, a 20+ years old engineering practice is still relevant today, because it’s based on fundamentals that haven’t changed and probably won’t change any time soon.

One could argue that TDD is more relevant than ever today, in the AI era. Since AI coding agents are becoming more widespread, it’s crucial to have reliable verification mechanisms to check their output, and automated testing definitely plays a big role in that.

TDD isn’t a testing technique, but a building one. If applied correctly, it can lead to clean, maintainable code that gives developers and users a high degree of confidence about code correctness.

Since TDD isn’t a testing technique, it doesn’t replace traditional testing approaches.

Yes, you’ll still need performance testing, end-to-end testing, contract testing, and any other forms of testing that are applicable to your context. TDD is an additional tool that can make your testing more effective and robust.

Accelerate test-driven development with AI-powered test automation. See how Tricentis helps teams scale TDD, automate testing, and deliver high-quality software faster.

Tricentis testing solutions

Learn how to supercharge your quality engineering journey with our advanced testing solutions.

Author:

Guest Contributors

Date: May. 16, 2026

Tricentis testing solutions

Learn how to supercharge your quality engineering journey with our advanced testing solutions.

Author:

Guest Contributors

Date: May. 16, 2026

FAQs

What is the difference between TDD and unit testing?

TDD is a technique that employs unit tests as a way to drive the creation of production code. Developers practicing TDD write unit tests in a very specific way, following the red-green-refactor cycle. It is possible, though, to write unit tests without following the rules of TDD.

What is the red-green-refactor cycle?
+

When following the TDD rules, developers write code following a short, iterative cycle. First, write a test that verifies a non-existing functionality (thus, the test fails). Then write the simplest possible code to make that specific test pass. Finally, if necessary, refactor the code for clarity.

Is TDD the same as BDD?
+

TDD (test-driven development) and BDD (behavior-driven development) are related but different practices.

While TDD requires creating tests that are closer to the code, BDD focuses more on higher-level descriptions of expected behavior, even to the point of enlisting customers or product people for writing plain-English definitions in languages like Gherkin.

Is TDD still relevant in 2026?
+

TDD is not only still relevant in 2026 but, arguably, is more relevant today than ever. Given the increasing adoption of AI agents to produce code, it’s crucial to have ways to verify the output they produce.

When should you not use TDD?
+

Using TDD has overhead that isn’t always worth it. Do not adopt TDD when you don’t care about the longevity or code quality of what you’re creating. For instance, when you’re building prototypes, spikes for learning, non-critical internal tools used by few people, or single-usage scripts.

How do I start practicing TDD?
+

To start learning TDD, first learn unit testing itself, so you know the basics of tooling, syntax, and so on. Then, start practicing TDD in a low-stakes, low-pressure way.

A great way to do that is by using coding katas, which are programming exercises you can perform to get used to techniques such as TDD, small-steps, and refactoring.

What are the different TDD approaches?
+

It’s possible to categorize TDD approaches in many ways, but the two main buckets are classic and mockist approaches. Classic TDD (also called Chicago or Detroit school) is an approach that favors state-based tests (i.e., tests that assert on the results of calling methods).

Mockist TDD (also called London school) favors verification-based tests, where you assert on the interactions between the system under test (SUT) and its dependencies.

You may also be interested in...