Test-Driven Development vs. Behavior-Driven Development

Overview

If you’ve spent any time at all reading about software development and testing methodologies, you’ve probably come across the terms Test-Driven Development (TDD) and Behavior-Driven Development (BDD). But what exactly do we mean by these? How do they differ? Can they be used together?

In this article, we’ll take a high-level look at both TDD and BDD, and hopefully, by the end, we’ll have answers to these three questions and more.

Example Problem Statement

To demonstrate these two concepts, we’ll use a relatively simple problem: input validation. We’ll establish the general goal of a specific input validation task and how we want the system to respond based on this goal. Then, we’ll implement and test a rudimentary solution in Java.

And as you may have guessed, we’ll show the TDD and BDD approaches to solving this problem, pointing out their basic philosophies and key differences along the way.

Test-Driven Development

Test-Driven Development is a lower-level, iterative, code-centric approach that uses unit tests to show the correctness of small units of code, like methods or classes.

The basic pattern of Test-Driven Development is straightforward:

  1. Optional: Stub out the desired class/method and/or test class
  2. Write a failing test
  3. Write just enough code to make the test pass
  4. Iterate steps 2 and 3 until you’re sure the code works as expected for all inputs/conditions
  5. Optional: Refactor the implementation if desired, without changing behavior, being sure to retest with every refactoring.

That sounds rather simplistic, so let’s see it in action.

Suppose we’re tasked with writing an input validation class with single method to check whether an input string is alphanumeric. We want our method to return true if our input string is alphanumeric; otherwise, it should return false.

To get started, let’s stub the test class:

class ValidatorTest {
    //...
}

Then, the first code we’ll write is a test that fails when we compile or execute it. We’ll choose the latter:

@Test
void testWithAlphanumericInput() {
    fail("not implemented yet!");
}

While we’ve technically written a failing test, there isn’t a big advantage of writing a test that simply forces a failure, other than to act as a placeholder for the actual test – we haven’t really set ourselves up for the next step. So, let’s modify our test case, imagining a class and method signature for our validation:

@Test
void testWithAlphanumericInput() {
    assertTrue(Validator.isAlphaNumeric("alpha12345"));
}

Since we haven’t written any implementation code yet (the Validator class doesn’t even exist!), we’ll get a compiler error. Great! We’ve written a failing test case. Now, let’s write enough implementation so that the test at least compiles:

class Validator {
    static boolean isAlphaNumeric(String s) {
        return false;
    }
}

When we return to our test, we’ll see that it now compiles, but if we run it, it fails. That’s because we haven’t really implemented any validation logic – the method just returns false, regardless of input. So, let’s modify our implementation so that our test passes:

static boolean isAlphaNumeric(String s) {
    for (int i = 0; i < s.length(); i++) {
        if (!Character.isLetterOrDigit(s.charAt(i))) {
            return false;
        }
    }
    return true;
}

Good, our test case passes. But what if the input isn’t alphanumeric? Let’s add a test case:

@Test
void testWithNonAlphanumericInput() {
    assertFalse(Validator.isAlphaNumeric("alpha 12345"));
}

When we run this, it’ll pass. So far, so good – our latest implementation works for this case. But what if we get a null input string? It’ll throw a NullPointerException. Let’s write the test first, and ensure that it fails:

@Test
void testWithNullInput() {
    assertFalse(Validator.isAlphaNumeric(null));
}

Sure enough, when we run the test, we get a NullPointerException. Now, let’s fix the implementation so that our test passes:

static boolean isAlphaNumeric(String s) {
    if (s == null) {
        return false;
    }
    for (int i = 0; i < s.length(); i++) {
        if (!Character.isLetterOrDigit(s.charAt(i))) {
            return false;
        }
    }
    return true;
}

All three tests should be passing now! But wait: What if our input string isn’t null but is empty? Surely, an empty string shouldn’t be considered to be alphanumeric. Yikes! Let’s add the test, first:

@Test
void testWithEmptyInput() {
    assertFalse(Validator.isAlphaNumeric(""));
}

Now, when we run our new test, we’ll see that it fails! That’s because the input string’s length is 0, causing the body of the loop too be skipped and the method to return true. Let’s fix it:

static boolean isAlphaNumeric(String s) {
    if (s == null || s.isEmpty()) {
        return false;
    }
    for (int i = 0; i < s.length(); i++) {
        if (!Character.isLetterOrDigit(s.charAt(i))) {
            return false;
        }
    }
    return true;
}

Now, our tests should all be passing! We’ve accounted for both null and empty input strings.

Of course, we can write a few additional test cases with various alphanumeric inputs and non-alphanumeric inputs to give us a high degree of confidence that our implementation is correct. For example, we may want to test with strings consisting of a single letter, digit, whitespace, or special character, and we could test strings containing combinations of letters, digits, whitespace, and special characters – including otherwise alphanumeric strings with leading or trailing whitespace.

Optionally, we can refactor our solution to improve its readability if we like – being sure to test after each refactoring. And this is a trivial example, no doubt. If we’re given this task in the real world, we’ll probably consider both null and empty strings up front. But, basically, we’ve demonstrated how to iteratively test and develop a method using the TDD approach. Very cool!

TDD is a proven approach to software development and is particularly effective when we may not have all of the requirements fleshed out up front.

Next, let’s have a look at the other side of the coin: BDD.

Behavior-Driven Development

As the name suggests, Behavior-Driven Development is a higher-level, scenario-based development and testing methodology that is less about testing smaller units of code and more about ensuring that our code behaves according to agreed-upon requirements or design specifications.

And even though BDD is often used at a higher level, it can also be used at the class or unit level to help us flesh out our unit tests. For the sake of demonstration, we’ll focus on the same problem as in the TDD section and employ BDD at the unit level.

Similar to TDD, we often write BDD tests before the implementation code, where each test case corresponds to a different expected behavior that has been predetermined jointly by developers, product owners, and business analysts working together.

This is slightly different from what we saw above in the TDD approach, where we didn’t list out all the expected behaviors up front. In TDD, we wrote a failing test, then implemented enough code for that test to pass, then iterated, writing additional tests and implementation details, until we were satisfied that the code did everything we wanted it to do.

You may have noticed that the test method names we used in the TDD section were descriptive, but not prescriptive. There really was no specific naming convention. That’s where BDD-style test naming conventions come to our aid.

In BDD, we generally name tests using the convention: givenX_whenY_thenZ, where ‘X’ is the precondition for the test, ‘Y’ is the code being tested, and ‘Z’ is the expected behavior or result.

We could also skip the precondition (‘X’) and just use the whenY_thenZ pattern in trivial cases, or if we want to use a more descriptive naming pattern for what’s being tested, depending on our preference.

For example, by iterating through the design in the TDD section, we’ve already identified four expected behaviors of our alphanumeric validator method:

  • Alphanumeric strings return true
  • Non-alphanumeric strings return false
  • Null input string returns false
  • Empty string input returns false

Let’s see what the test names might look like, using BDD-style naming:

  • givenAlphanumericInput_whenValidate_thenReturnsTrue
  • givenNonalphanumericInput_whenValidate_thenReturnsFalse
  • givenNullInput_whenValidate_thenReturnsFalse
  • givenEmptyInput_whenValidate_thenReturnsFalse

Here’s another example set of test names, still in the BDD style:

  • whenValidateAlphanumericInputString_thenValid
  • whenValidateNonalphanumericInputString_thenInvalid
  • whenValidateNullInputString_thenInvalid
  • whenValidateEmptyInputString_thenInvalid

As you can see, the BDD-style naming convention easily helps us to come up with a full implementation that covers all four of the required behaviors.

Of course, as we add methods to our Validator class, we may need to adjust the test names to clarify what method or behavior is being tested.

Next, let’s convert our TDD test cases to use the BDD-style naming convention. We’ll use the first set of names noted above:

@Test
void givenAlphanumericInput_whenValidate_thenReturnsTrue() {
    assertTrue(Validator.isAlphaNumeric("alpha12345"));
}

@Test
void givenNonAlphanumericInput_whenValidate_thenReturnsFalse() {
    assertFalse(Validator.isAlphaNumeric("alpha 12345"));
}

@Test
void givenNull_whenValidate_thenReturnsFalse() {
    assertFalse(Validator.isAlphaNumeric(null));
}

@Test
void givenEmptyInput_whenValidate_thenReturnsFalse() {
    assertFalse(Validator.isAlphaNumeric(""));
}

Some Key Differences

Although TDD demands and BDD emphasizes a test-first approach, BDD-style testing can also be applied retroactively after the implementation is written, given an adequate collection of expected behaviors, as is often employed by QA developers to aid in establishing and maintaining automated regression tests.

When using a test-first approach, TDD is more of an iterative, test-and-implement-as-you-go approach, whereas BDD lends itself to cases where we want to cleanly map out all the expected kinds of inputs and behaviors first, write these expected behaviors as tests, and then write the implementation code.

Can We Combine TDD and BDD?

As is often the case in software development, the answer is: “it depends“. Specifically, it depends on how strictly we define TDD. But generally speaking, it’s often a good practice to combine the two.

If we can enumerate all of the expected behaviors of our code, then we can define our BDD test cases accordingly. And if we write stubs for the implementation (or at least know its classes and method signatures), we can fully code the tests before we’ve implemented any of the desired behaviors!

Then, we implement one behavior at a time until all the tests pass! In that way, the process is similar to TDD, though we’ve written all the tests first instead of iterating through pairs of alternating tests and implementation details on-the-fly.

Alternatively, we can work through each behavior one by one in the TDD style, first implementing the test code that verifies the expected behavior, then implementing the behavior so that the test passes.

Final Thoughts

It’s worth noting that there are many TDD evangelists who say that it’s the absolute best approach to software development. And maybe they’re right. It’s certainly been shown to be effective and is a great way to map out a problem and solution iteratively, as we’ve seen in our examples.

On the other hand, BDD is a great way to map out our code’s behaviors in the form of higher-level test cases before diving into the implementation. That way, when all our tests are passing, we can be relatively confident in our implementation. And we can use it in conjunction with TDD in case we haven’t quite fleshed out all of the desired behaviors during the first pass.

That said, there are many proven approaches to software development, and the choice of which one to use may depend on personal preferences, company or customer policy, the problem at hand, and project requirements regarding quality, test coverage, and more.

See the GitHub repository for all the sample code in this article.