Get Insightful Test Reports of Your CI/CD Pipelines. Learn more →

Test-Driven Development (TDD): A Time-Tested Recipe for Quality Software

test driven development

Test-Driven Development (TDD) is an established practice that has been favored by many developers for years. This article helps you understand the history behind TDD, its purpose, its relationship to testing in general, and the benefits you can gain from its use.

Table of contents:

How TDD came into being

TDD is one of the technical practices of eXtreme Programming. The invention of TDD is usually attributed to Kent Beck; one of the first “extreme programmers”.

This practice shook the common sentiment of programmers at the time. The practice of testing was already widespread, but no one before had ever suggested writing tests before writing the actual code that needs testing. This act is itself counterintuitive if one thinks of TDD as a testing practice. As the author has repeatedly pointed out (and with him many other distinguished programmers), TDD was not born as a testing practice but as a software design practice.

The mantra of the early extreme programmers was to take things that worked well and “exert them to the extreme”. Pair Programming, i.e. the practice of two people writing code collaboratively on the same computer, also arose from this programming movement.

In the same way, TDD was born: “If I test the code I write, I get better quality code: what would happen if I took the process to the extreme: writing tests before the code itself?

If I test the code I write, I get better quality code. What would happen if I took the process to the extreme: writing tests before the code itself?

Kent Beck answered this by developing TDD, and starting a small revolution within the eXtreme Programming revolution.

TDD as a design practice

TDD thus began as a practice related to testing, but it soon turned out that the resulting tests were just a nice side-effect. The point of writing tests before code had much more to do with the design of the code itself than its testing.

Writing the test before the code helps the programmer put himself in the shoes of the user, making it easier to create clear software APIs. Using TDD helps make you more comfortable with circumscribing the scope of your code, writing shorter but more focused code, and producing easily-composable modules.

TDD diagram

The act of thinking about how to test code early in the process helps with imagining concrete examples. In addition, this allows the developer to focus on concrete cases, avoiding premature generalizations and optimizations.

“The simplest thing that could possibly work” is a phrase you often hear from long-time XP programmers.

Another benefit you get from TDD is rapid feedback on what you produce. Extensive testing is no longer necessary to determine if the code works correctly because there are already tests in place to ensure just that.

eXtreme Programming puts a lot of emphasis on feedback loops. And, among these, shorter cycles that allow you to get quick confirmation are preferable. Of all the XP practices, TDD has the second-fastest feedback loop (second only to Pair Programming), as it provides feedback in a matter of minutes.

Another exciting feature of TDD is the more or less veiled constraint that leads programmers to take smaller and smaller steps. Those who have been doing TDD for a long time surely know the 3 laws of TDD, by Robert C. Martin,, also known as “Uncle Bob”.

In one of his famous articles, Uncle Bob reinforces the TDD process by formulating 3 simple laws:

  • You must write a failing test before you write any production code.
  • You must not write more of a test than is sufficient to fail, or fail to compile.
  • You must not write more production code than is sufficient to make the currently failing test pass.

If you follow this approach, it is clear that you favor incremental development, i.e. writing one test at a time. In this continuous cycle of very short iterations, there is space built in for refactoring. This term is often used as a synonym for “reengineering”, but it has a different meaning, at least in the TDD approach.

In this context, in fact, it represents the most important phase of the whole cycle, where the emphasis is on code quality. In eXtreme Programming, there’s the concept of Simple Design, i.e. the continuous effort to make the code produced simple to evolve.

It is in the refactoring phase that the programmer concentrates on modifying limited portions of code to remove duplication or increase efficiency without changing behavior, which is strengthened by tests that guarantee adequate security. This can be done both in purely practical terms (e.g. introducing a more efficient version of an algorithm), in terms of design, or by modifying or introducing a new abstraction.

TDD was created as a tool for thinking, to increase the focus on small, well-defined portions of code. It helps to proceed in very small steps, adding functionality and value to the software in very small increments in a safe and consistent way. Finally, it enables constant refactoring, one of the most effective practices for keeping software under development in good shape.

TDD as a well-established engineering practice

Today TDD is no longer a novelty. There are many teams and developers who rely on this practice to ensure a sustainable pace in product development. Over time, there have also been several studies that support its validity.

An interesting aspect that frequently emerges is related to the adoption of this practice. The initial learning curve may be more or less steep, but it cannot be ignored. Just as the investment of time in test writing, although several studies shows positive ROI.

The evidence is that once developers have obtained sufficient and necessary preparation to enable the practice, projects end up with good results most of the time. This indicates that they are not successful products that enable TDD, but rather that TDD contributes to the success of projects, products and teams.

Another common objection to TDD is about writing a test first. The usual claim is that there are scenarios where writing tests first doesn’t make sense, others where it is very difficult, if not impossible. On the first statement, there is no debate: TDD is not a one-size-fits-all tool, but it has a specific purpose: to support the developer in the design of the software solution.

On the fact that it is difficult -sometimes very difficult- this is also true: oftentimes the defendants here are the underlying code, too rigid to allow the introduction of tests, or the lack of tools. However, both are solvable problems: with a little work, it is possible to facilitate the adoption of the practice with the consequent benefits.

A small example of TDD

How do you get started in TDD? The answer alone would deserve an in-depth study. It is, however, possible to get a brief taste.

Let’s assume that we need to write the software for a cash register. The first functionality requested is related to the item scanner: when a specific item is passed (scanned), e.g. an apple, the system must charge a certain amount, e.g. 50 cents.

The following could be a first User Story:

As a cashier, 
I want a basic checkout system
so I can let my customers pay for apples

And these are the related acceptance criteria:

* When I scan an apple, the system charges 50 cents
* When I scan 3 apples, the system charges 150 cents

Using the TDD approach, the first thing we need to do is write a test that shows that, currently, we do not have a checkout system that can checkout an apple:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CashRegisterTest {

  @Test
  public void When_I_add_an_apple_the_system_charges_50_cents() {
    //ARRANGE
    CashRegister register = new CashRegister();
    long expectedCost = 50;

    //ACT
    register.add("apple");
    long actualCost = register.getAmount();

    //ASSERT
    assertEquals(expectedCost, actualCost);
  }
}

The example is in Java. It allows us to point out a few things.

Testing libraries

In the first few lines, we notice references to JUnit libraries. JUnit is a testing framework for Java projects, one of the many xUnit frameworks available.

For those approaching TDD, one of the first things to explore is the testing frameworks available. These days you have so many choices, and you can be certain that there’s at least a “xUnit-like” framework for the language in use.

Test Classes and Methods

It is vital to include tests in dedicated files. In Java, the usual practice is to create test classes in dedicated “test” packages:

Classes and test methods make up the “test suite”, which is the set of tests accompanying the software. Therefore, it is crucial to pay proper attention to the organization of the test suite. A good test suite separates tests by scope, making it easy to execute different types of tests separately, e.g. unit tests and End-To-End (E2E) tests.

This separation allows you to run the different types of tests on-demand and independently. It also makes it easy to run them via triggers or schedules in Continuous Integration systems.

The “red test” stage

Finally, we have our test:

  @Test
  public void When_I_add_an_apple_the_system_charges_50_cents() {
    //ARRANGE
    CashRegister register = new CashRegister();
    long expectedCost = 50;

    //ACT
    register.add("apple");
    long actualCost = register.getAmount();


    //ASSERT
    assertEquals(expectedCost, actualCost);
  }

Note the @Test annotation: a peculiarity that allows the JUnit framework test runner to understand which portions of code should be executed and verified.

The method name is long and unusual, but it is pretty common to use the test method signature to express intent when writing tests.

Finally, we have the “3As“, which help define a good test. The first A, Arrange, reminds us that it’s a good idea to first do the test setup, instantiating objects and any variables we’ll need to use for executing the test.

The second A, Act, focuses on the lines of code that put our System Under Test (SUT) into operation; in this case this is the CashRegister object.

Finally, the third A, Assert, represents the main point: this is where we define the expected result of the test. If the assertion turns out to be true, our test will be satisfied.

In this example, we have our first test arranged to develop the first required functionality, but there is no mention of the production code. This is not an omission: the first test (and the others to come) are written assuming the presence of production code, even if it has not yet been implemented.

This approach, quite counterintuitive at first, is one of the signature characteristics of TDD. It is, therefore, normal to write code that doesn’t even compile and to exploit this situation in order to obtain the so-called “red test”, i.e. the test that, by failing, proves the need to write code.

Only at this point, after having respected the first law of TDD, can we move on to the second, which allows us to write the minimum amount of code necessary to pass the first test.

The “green test” stage

How can we pass this first test? By implementing a CashRegister object, obviously. Below is a simple implementation:

TDD green test stage
public class CashRegister {

    public void add(String good) {
        //actually I don't need this at the moment :)
    }

    public long getAmount() {
        return 50;
    }

}

If you find yourself scoffing at this implementation, you’re probably not the only one. This code allows the system to pass the test, but it’s clear that this CashRegister implementation needs to be revised: as it is, it only works if a customer buys a single apple.

But that’s the point of TDD: to do the minimum necessary. And in this situation, the minimum required to pass the first acceptance criteria is to build a CashRegister that charges 50 cents for one apple.

The refactoring stage

Once the assertion of the test is satisfied, it’s time for refactoring. In this first example, it is difficult to highlight this step, since the code is already stripped down to the bare bones.

For the sake of brevity, let’s jump ahead and skip the handful of TDD cycles that would allow us to arrive at the following situation:

  • The system can handle four products: apple, pear, pineapple, and banana
  • For some products, there is a special offer: 3 apples cost 130 cents instead of 150, 2 pears cost 45 cents instead of 60

Let’s see the code implementation:

TDD implementation

You can see the additional tests, which allow us to extend our code one bit at a time. They are all green, but the code is starting to creak: the number of conditional statements is growing, and the code is becoming “rigid”.

At this point, it is possible to refactor the code, introducing, for example, the concept of PriceRule, which determines the price for each product, taking into account any current special offers.

Take a look at the result of the refactor below:

With the green tests acting as a safety net, it is possible to refactor the code by introducing a new abstraction (PriceRule). Furthermore, the use of Java Streams has made the product filtering operations more expressive. Now the addition of new products to the catalog is greatly simplified: in essence, it is sufficient to implement a new PriceRule.

This article is not sufficient to give an in-depth explanation of all aspects of TDD, but this small example offers a glimpse of the value that it provides.

The pleasant side effects of TDD

We’ve already said it, and now it should be clearer: TDD is a code design technique, not a testing technique. The resulting tests are, in fact, “only a pleasant side effect.”

TDD is a code technique, not a testing technique.

The purpose of TDD is to give the programmer quick feedback about the code they have just written: if something is wrong, a test will signal it. Another goal of TDD is to enable refactoring, as we have just seen: you don’t postpone to the near future (maybe to the distant future…) when refactoring code, paying some of the accumulated technical debt; you act in the moment, when the mind is still fresh and refactoring is both cheaper and easier.

The tests obtained using TDD practices generally fall into the family of unit tests. These kinds of tests are fast: in the order of milliseconds. The result of this approach is hundreds, if not thousands of tests. They continuously run on the programmer’s workstation and in the Continuous Integration (and Deployment/Delivery) pipeline.

The different styles of TDD

Over time, different approaches to development have emerged, and with them, different ways of doing TDD.

There are two main TDD approaches. The first is the classical school approach (i.e. the definition comes from XP, and has three names: “Classicist,” “Chicago style,” or “Inside-out“). The second one was born in London a few years later: “Mockist,” “London style,” or “Outside-in.”

These two approaches are not in conflict, on the contrary, they are complementary. The two different approaches can coexist, as they each shine in different situations.

We have already had a taste of the classicist approach: starting from a first small test, we expanded our solution from the inside out, adding functionality from time to time.

The “outside-in” approach works on a different level and uses the TDD “red failing test” approach on more extended functionalities.

For example, look at this test written with the Gherkin syntax, in full Behavior Driven Development (BDD) style:

Feature: Supermarket Bundles        
           To increase revenues as a Supermarket Manager,
           I want to apply discounts and special offers to my customers

@Bundles
Scenario: if a customer buys 3 apples, they pay $1.00
    Given a apple costs 50 cents 
    Given the "buy 3 apples, pay 2" promotion
    When the cashier scans 3 apples
    Then the cash register charges $1.00

Returning to our previous exercise, we know that there is no “buy 3, pay 2” offers implementation. The outside-in approach makes developers think about the complete functionality, implementing it as simply as they can, even faking some parts when necessary. Inside a bigger “red failing test” like this, development continues via classic short TDD loops. Development continues until this test turns green, which confirms the correct implementation of the required functionality.

Given the larger surface of this kind of test, programmers often use “test doubles” during writing and implementation; an example of which are mocks (hence the adjective “mockist“).

Conclusions

This article underlines the main concepts behind TDD:

  • Focus on code design
  • Small steps
  • Short feedback loops
  • Continuous refactoring

It also distinguishes TDD from the other common testing techniques.

Crafting software is somewhere between a science and an art. Mastery of relevant techniques is essential to fully express your art, and TDD is a solid tool to have in your toolbox.

Have a comment? Join the discussion on the forum