InformIT

Thinking Test-Driven

By

Date: Feb 25, 2026

Sample Chapter is provided courtesy of Addison-Wesley.

Return to the article

Those who want really reliable software will discover that they must find means of avoiding the majority of bugs to start with, and as a result the programming process will become cheaper. If you want more effective programmers, you will discover that they should not waste their time debugging, they should not introduce the bugs to start with.

—Edsger W. Dijkstra, The Humble Programmer (1972)1

The mechanics of Test-Driven Development (TDD) are simple, but TDD is not an invitation to disengage your intellect, nor does it require you to forget your own previous experiences and education.

People naturally talk about software behaviors using examples, though they aren’t always aware that they’re doing so. While working with a team, I will often hear someone offer a simple example in a verbal sentence or two. By capturing that example in a small bit of automated, executable specification (a test), the team can be sure of the following:

For developers, many examples will occur to them while they are writing code, but that dialog is often internal: “What do I want my next few lines of code to do?” Before a developer writes even the tiniest bit of logic, they already know what they expect that code to do. TDD asks the developer to record that thought by turning it into a concrete description of the example and its expected results.

This book encourages a deeper dialog: “What if a caller does this? What do I want the software to do? How can I be sure I got it right, and that I don’t break something else?”

Writing that thought in a familiar language and having it instantly added to an automated test suite costs the developer very little extra time and could easily save them hours upon hours of debugging later.

The Safety Net

The team’s collection of fast, automated, and comprehensive tests that grow from a diligent TDD practice is much more than a typical suite of tests. For the test-driven team, the test suite is a powerful safety net.

Although each tiny individual unit test might seem quite simple by itself, in the aggregate that safety net protects the team’s entire previous efforts and the business’s investment in the software produced. Whereas individual tests instantly catch common mistakes in implementation, the comprehensive safety net also protects against mistakes during refactoring.

Units of Behavior

The following specification (in Ruby’s rSpec) describes software that emulates a simple box. We can add items to the box, remove them, and verify whether the box contains a specific item. In each scenario, the tested behavior is emphasized.

it 'knows what has been added' do
  @box = Box.new
  @box.add("red pen")
  expect(@box.has("red pen")).to be true
end

it 'knows what it does not have' do
  @box = Box.new
  @box.add("red pen")
  expect(@box.has("purple smartphone")).to be false
end

it 'can remove something it has' do
    @box = Box.new
    @box.add("red pen")
    @box.remove("red pen")
    expect(@box.has("red pen")).to be false
end

Notice that add() cannot be tested without has(), and vice versa. That is, the behavior is spread out over two or three method calls on the same object. The implementations of these behaviors are encapsulated by the Box object, and the specification of each unit of behavior is clearly described by a single scenario. In other words, behavior is most clearly described in the tests.

The next example demonstrates how a single line of code can be part of multiple distinct behaviors. Imagine we are designing a retro-style volume control dial for a smartphone (Figure 1-1). The control can be set to only integer values, and our user interface (UI) developer tells us the dial will graphically “snap” to the next value as the user rotates the dial, perhaps with haptic feedback to emulate that memorable “click.”

FIGURE 1.1

Figure 1-1 A stereo system’s volume dial circa 1980. (Photo: atm2003/123rf).

In a brief whiteboard discussion, we decide we need a volume-control application programming interface (API) that accepts an integer from 0 to 10. Zero is effectively “off” and 10 is the maximum volume (not 11!2).

The UI developer assures us that we will not receive an out-of-range integer. However, we agree that if this somehow happens, our code will throw an IllegalArgumentException. In Java, this could look like the following:

if (volumeZeroToTen > 10 || volumeZeroToTen < 0)
    throw new IllegalArgumentException("Volume cannot be " +
                                        valueZeroToTen);

audioManager.setStreamVolume(volumeZeroToTen);

There are three scenarios:

  1. When volumeZeroToTen is greater than 10

  2. When volumeZeroToTen is less than 0

  3. When volumeZeroToTen is within the acceptable range

Each of those three scenarios is different because of the first if conditional, and each deserves its own distinct unit test. The behavior is more clearly described by expressly writing tests for those three examples.

Behavioral Boundaries

A behavioral boundary is the conceptual boundary separating two (or more) related scenarios. In practice, it is often the boundary between two similar input values that give different results.

Let’s return to the volume dial example. Suppose that rather than having the dial’s API set the actual phone volume (as previously shown), we decide to take an event-driven approach instead. An observer of a Dial object will need to query the setting whenever the graphical dial is turned. What the observer wants to know is not which value was set on the dial, but what percent of the maximum volume the setting represents.

A developer might be tempted to aim for the “happy path” (the scenario that typically avoids all edge cases and error conditions), write a simple implementation (for example, return currentSettingFromZeroToTen * 10; ), and be done with it:

@Test
public void levelAsPercent_HappyPath_RightDownTheMiddle() {
    Dial dial = new Dial("Volume");
    dial.setTo(5);
    assertThat(dial.levelAsPercent()).isEqualTo(50);
}

This is a good start, but there is another way to approach this kind of problem, particularly when you are working with something more sophisticated than a volume dial.

TDD is first and foremost a practice that facilitates and supports a developer’s thinking and coding. Writing one test scenario often leads to an awareness of other related scenarios. Writing tests for those related scenarios is known as the “triangulation” technique; it is covered in detail in later chapters. In its most refined form, triangulation helps the developer identify behavioral boundaries by asking, “What value(s) will cause us to write smarter code?”

Let’s review what we know about Dial:

There is a behavioral boundary between –1 (an error) and 0 (off), and another behavioral boundary between 10 (maximum) and 11 (another error). However, there are no behavioral boundaries between any two of the integers between 1 and 10. The behavior—the complete code path—is the same for all: 1, 2, 5, 6, 9, and 10 must all exhibit the same behavior (in this case, a simple calculation) resulting in different answers.

Because 0 (off) could also be represented by 0 percent, is there a behavioral boundary between a dial setting of 0 and 1? It’s impossible to say without learning more about the product specification. If we are asked to have the software turn on a little green light whenever the sound system is turned on, that would call for a behavioral boundary between 0 and 1 (though not necessarily implemented by Dial). Until that behavior is requested, however, the test-driven developer would not build it.

When you spot a behavioral boundary, you can write your tests for the values that represent the “edges” of that boundary:

@BeforeEach
public void initializeVolumeDial() {
    dial = new Dial("Volume");
}

@Test
public void dialThrowsExceptionForValueUnderMin() {
    assertThatIllegalArgumentException().isThrownBy(() -> {
        dial.setTo(-1);
    });
}

@Test
public void levelAsPercent_WhenOff() {
    dial.setTo(0);
    assertThat(dial.levelAsPercent()).isEqualTo(0);
}

@Test
public void levelAsPercent_AtMaximum() {
    dial.setTo(10);
    assertThat(dial.levelAsPercent()).isEqualTo(100);
}

@Test
public void dialThrowsExceptionForValueOverMax() {
    assertThatIllegalArgumentException().isThrownBy(() -> {
        dial.setTo(11);
    });
}

What if you hadn’t noticed the boundaries before writing your tests, and you had written that earlier happy path test for the dial setting of 5? You could either leave the original test within the suite or delete it. If the happy path test provides even the tiniest additional clarity in the specification, leave it. It would take perhaps one additional microsecond for each run. Alternatively, you could adjust the happy path test to be right at the edge of the boundary by changing the 5 to a 0 or a 10 and by changing the expected outcome to 0 or 100, respectively.

If you don’t test the edges of the behavioral boundaries, your team’s safety net will not be as clear and thorough as possible. For example, if we use 42 instead of 11 as the erroneous input greater than 10, and later another developer mistakenly increases the maximum value to 30, our test suite wouldn’t catch this problem. Our team might not learn about the defect until some unlucky consumer receives a very unpleasant (and very loud) surprise.

Because thinking test-driven requires human thought, mistakes will still happen, but far less frequently. When TDD is used diligently, every software defect is either a missing test or a vaguely specified test (that is, a misunderstanding). In either case, the mistake is a gap in the team’s safety net. It often takes just one more test to close the gap.

A Taste of TDD

Let’s return to the Box software and give it the ability to indicate whether it is empty or not.

This example is written in JavaScript using Jasmine. In case you want to follow along, you won’t need anything except a text editor, a browser, and a download of the Jasmine library.

  1. Write a single test for a tiny bit of behavior:

    describe("box", function() {
        it ("starts out empty", function() {
            var box = new Box();
            expect(box.isEmpty()).toBeTruthy();
        });
    });
  2. Write just enough code so your test fails due to an assertion (also known as an expectation):

    Box = function() {
    };
    
    Box.prototype = {
        isEmpty: function() {
            return false;
        },
    
    };
  3. Run all of the tests—that is, develop the habit of running the whole suite each time. Make sure the new test fails with an informative message, as illustrated in Figure 1-2.

    FIGURE 1.2

    Figure 1-2 Jasmine’s SpecRunner.html page showing the clean test failure.

    (Source: Jasmine Software. Screenshot of testing, 2025.)

    Note that Jasmine highlights the name of the test that has failed. The test (“spec”) name and failure message together describe the expected behavior. Seeing the test fail and reading the failure message is how you “test the test”: You know you’ve asked for behavior that has not yet been implemented.

  4. Write just enough code to get the test to pass as quickly as possible:

        isEmpty: function() {
            return true;
        },

    All we did was change false to true. This technique, called Fake It, will be covered in detail in Chapter 2, “Basic Moves.”

  5. Run all of the tests again. They should all pass now, as shown in Figure 1-3.

    FIGURE 1.3

    Figure 1-3 Jasmine’s test-results browser page showing the tests passing.

    (Source: Jasmine Software. Screenshot of testing, 2025.)

    We’re not done with this behavior, but this seemingly trivial code is sufficient to pass all existing tests.

  6. Refactor diligently!

    Because this is our very first test, there’s nothing to refactor here. Nevertheless, you should always take a moment to review both the tests and the implementation to see if you can reduce any duplication or improve the clarity.

Back to step 1!

  1. Write a single test …

    Where there’s a true, there’s usually a false—for example, when a Box isn’t empty. We’ll give Box an add() method:

    describe("box", function() {
        it ("isn’t empty after adding", function() {
            var box = new Box();
            box.add("red pen");
            expect(box.isEmpty()).toBeFalsy();
        });
    
        it ("starts out empty", function() {
            var box = new Box();
            expect(box.isEmpty()).toBeTruthy();
        });
    });
  2. Write just enough code to make your test fail cleanly. It won’t fail cleanly until it has a stub for add(). Resist the temptation to write an implementation for add() before you see the test fail:

    Box = function() {
    };
    
    Box.prototype = {
        add: function(item) {
        },
        isEmpty: function() {
            return true;
        },
    };
  3. Run all of the tests.

    Of course, the new test fails, as illustrated in Figure 1-4.

    FIGURE 1.4

    Figure 1-4 A new failing test.

    (Source: Jasmine Software. Screenshot of testing, 2025.)

  4. Write just enough code to get both tests to pass:

    Box = function() {
        this.items = [];
    };
    
    Box.prototype = {
        add: function(item) {
            this.items.push(item);
        },
        isEmpty: function() {
            return this.items.length === 0;
        },
    };
  5. Run all of the tests to see them pass, as shown in Figure 1-5.

    FIGURE 1.5

    Figure 1-5 Both tests passing.

    (Source: Jasmine Software. Screenshot of testing, 2025.)

  6. Refactor diligently!

    Refactoring the tests is as important as refactoring the implementation. Doing so often helps you write later tests.

    There is a tiny bit of duplication between the two tests, and that’s enough. The duplicated code is highlighted in the following code:

    describe("box", function() {
        it ("isn’t empty after adding", function() {
            var box = new Box();
            box.add("red pen");
            expect(box.isEmpty()).toBeFalsy();
        });
    
        it ("starts out empty", function() {
            var box = new Box();
            expect(box.isEmpty()).toBeTruthy();
        });
    });

    All unit-testing frameworks have a way to perform common setup for each test in the suite. In Jasmine, it’s called beforeEach(), and it runs once before each test within the containing describe block:

    describe("box", function() {
        var box;
        beforeEach(function() {
            box = new Box();
        });
    
        it ("isn’t empty after adding", function() {
            box.add("red pen");
            expect(box.isEmpty()).toBeFalsy();
        });
    
        it ("starts out empty", function() {
            expect(box.isEmpty()).toBeTruthy();
        });
    });

    Another run of the tests confirms that the changes made did not break any tests (Figure 1-6).

    FIGURE 1.6

    Figure 1-6 Checking the refactoring.

    (Source: Jasmine Software. Screenshot of testing, 2025.)

Here’s what we’ve accomplished with this tiny example:

In Chapter 2, we’ll walk through a richer example and explore more test-driven thinking and design choices.

The Future of Test-Driven Development

One of my favorite personal mottoes is “Never an absolutist.”3 Do I believe TDD is a practice that will never change and will never disappear?

Of course not: It’s already changing. What follows are some of those changes, along with their implications for the future of TDD. In each case, the TDD game is slightly modified, but the thought processes, techniques, and human activities remain essentially the same.

As far as TDD disappearing, well, that won’t happen as long as humans are needed to explain to a computer what it is that we want it to do for us.

TDD and Behavior-Driven Development

Behavior-Driven Development (BDD) is like TDD in many ways. It is the whole-team practice of building software by writing brief, human-readable scenarios, and getting them to work one at a time. BDD is defined as the practice of “exploring desired system behaviors with examples in conversations and formalizing those examples into automated tests to guide development.”4

BDD isn’t merely a renaming of TDD. As Matt Wynne wrote in The Cucumber Book (Pragmatic Bookshelf, 2017), “BDD builds upon TDD by formalizing the good habits of the best TDD practitioners.”

In practice, BDD differs from TDD in the following ways:

  1. The tests are “business-facing.” That is, they express business rules and requests. The suite of tests (“scenarios” or “examples”) represents a runnable product specification, whereas the emphasis of TDD is often on a runnable engineering specification. To keep the scenarios readable to the entire team—whether technically inclined or otherwise—BDD examples are written in a ubiquitous domain language and use a handful of keywords.

  2. BDD is a whole-team activity and is best implemented with nearly continuous collaboration between the product and development specialists. Although this approach might seem burdensome on the surface, BDD can reduce the need for many traditional meetings and hand-offs. The team works closely together to plan, specify, build, test, and demonstrate working software within hours or even minutes.

  3. TDD is included as a practice within the BDD cycle. There are often situations where finer-grained, “infrastructural” testing is required to complete a BDD scenario without cluttering the product specification with engineering details. Ergo, there’s no need to choose between BDD or TDD. They go together nicely, like chocolate and peanut butter.

Many of the test-driven techniques described in this book apply equally to a team’s BDD scenarios: They favor developing more, smaller tests with fewer assertions; setting up just enough to test the business rule; reducing duplication (for example, using Cucumber’s Gherkin Background keyword); making the scenarios readable; strategically replacing challenging dependencies with test doubles; and taking small, quick, and safe steps toward the team’s goal.

TDD and Functional Programming

When using Functional Programming (FP), whenever you create a type with a rigorous contract, you are effectively testing a lot of assumptions at compile-time. But there is also behavioral (runtime) code, and where there’s behavior, there’s the opportunity to get it wrong. Ergo, unit-testing FP behaviors is still beneficial, and building those behaviors by using a test-driven approach is as critical to quality FP as it is to object-oriented development.

FP unit tests contain all the attributes of good tests, as described in Chapter 5, “Sustaining a Test-Driven Practice.” The following example gives a taste of unit testing in F# (a .Net FP language):

[<Test>]
let ``intersection of two sets should only contain common elements``
() =
    let left =
        initialSet
        |> add "Rob"
        |> add "Awesome"

    let right =
        initialSet
        |> add "Jason"
        |> add "Awesome"

    let result =
        left
        |> intersection right

    result
    |> contains "Awesome"
    |> expectsToBeTrue

    result
    |> contains "Rob"
    |> expectsToBeFalse

    result
    |> contains "Jason"
    |> expectsToBeFalse

Here is the passing implementation of intersection as another taste of FP syntax:

let intersection right left =
    left
    |> List.filter(fun element ->
        right
        |> List.contains element
    )

Daydreams of TDD, Quantum Computers, and No Implementation

We developers used to fear the day when computers could write their own code. We were worried not only because such computers might build Terminator robots (or worse, mountains of paperclips5) and take over the world, but also because we’d be out of a job.

For many years, I’ve speculated that we would eventually see a computing breakthrough that would allow the computer to write the entire implementation. At the time, I envisioned a quantum computer (QC) that could take a team’s specifications and search a “solution space” for a set of machine instructions that would pass all the tests.

If the computer were fast enough, it could perhaps rewrite the entire implementation each time the team added a new test scenario. In other words, neither the team nor the development computer would ever need to read or refactor the implementation.

Teams could then focus entirely on writing, refactoring, and maintaining the test suite. Future high-level programming languages could be limited to the syntax and structure needed for good test-writing (for example, no more loops or branching statements). Developers would write engineering specifications as unit tests, or co-author product scenarios while working side-by-side with product designers. Or perhaps the team roles of product designer, developer, and tester would blend and merge into something new. A test-driven approach would be the de facto standard for building and maintaining software.

While I was daydreaming about the impacts of this hypothetical QC, a different computing breakthrough was happening. Noting the surging power and popularity of artificial intelligence6 (AI) and large language models (LLMs), I began to wonder whether these new tools would someday make my predictions a reality.

The Reality of TDD, Artificial Intelligence, and “Vibe Coding”

Using a rudimentary OpenAI script that contained a simple prompt including all my tests (I could easily add tests with each run of the script), I explored whether OpenAI could generate code to pass all my tests. And it did so, repeatedly and successfully.

With each new run of the script, my AI agent did not have access to any previous prompt, dialog, or existing implementation. There was nothing for me or the AI to refactor, because it always started from scratch and overwrote all the code with each run.

My experiment was only that: a simple proof-of-concept. I had the script build simple bits and pieces of various classroom exercises (for example, the Salvo game described in the Exercises Appendix at the end of this book).

There are several ways that AI tools are currently assisting real developers. Some integrated development environments (IDEs) offer AI “autocomplete” options that are quite good at predicting what the developer intended to write next, and the complete code appears much faster than the developer could have typed it. Others read all the existing code and will suggest refactorings. The developer merely needs to glance over the proffered code and press a single key to accept the changes.

AI agents can even write tests for you. However, the generated tests that I saw merely confirmed that the code did what it did, not necessarily that it did what was wanted. This is the gap in the workflow where humans are still needed: Someone needs to come up with descriptive and detailed examples of what they want the software to do.

Right now, various methods for incorporating AI into the full development workflow are vying for our attention. One of those approaches is called “vibe coding,” which was described to me as allowing the AI agent to write the implementation without any review. The person receiving the code then tests it and gives the AI feedback. This is happening in a few different ways:

Some things I noted about these reported experiences:

Computers can’t read our minds, and they don’t do well with ambiguous instructions. When it comes to our safety, health, finances, and other critical domains, we will need to describe—to the computer and to each other—all desired outcomes using complete, descriptive, and detailed examples. That is exactly the practice of TDD.

Summary

The real power of TDD comes from thinking about what the software needs to do and coming up with examples to represent that behavior. The red–green–clean steps are not meant to be mere mechanics, but rather a scaffolding to allow developers to explore, guide, and preserve those thoughts.

The next two chapters will use an example application to explore the thinking process in detail. Approach this material as you would approach the rules of a new game. These chapters provide the rules and basic strategies by walking you through a sample “game.” The example app is a simple one, and the resulting implementation will be trivial, but you will come away with an understanding of the power of test-driven thinking.

800 East 96th Street, Indianapolis, Indiana 46240