What is Test Driven Development (TDD) and Why It Matters

What is Test Driven Development (TDD) and Why It Matters

Plus simple example in Java

The first time I heard about Test Driven Development (TDD), it sounds weird to me. Many questions came to my mind :

  • How can we develop a code based on the test scenarios?

  • How can be possible to code your tests before your actual functionality codes?

  • Is it effective?

  • So on and so forth.

Perhaps, It happens all the time to developers the first time they overcome TDD. But, as time passes and you do a lot of implementation of TDD, you will find how beneficial it is. In this short article, I will introduce you to what TDD is, how we can implement it, and what benefits it gives to us. Additionally, I will show you a simple example of how we implement it in Java.

What is Test Driven Development?

Test Driven Development (TDD) is one of the lists of famous software development processes that are often used in the software development life cycle (SDLC).

The point of Test Driven is the development process is rely on software requirements being converted to test cases before it is fully developed. Then, we can track our development process based on the test cases that we have described previously.

An expert like Kent Beck once said about TDD :

“If you're happy slamming some code together that more or less works and you're happy never looking at the result again, TDD is not for you. TDD rests on a charmingly naïve geekoid assumption that if you write better code, you'll be more successful. TDD helps you to pay attention to the right issues at the right time so you can make your designs cleaner, you can refine your designs as you learn.”

Using TDD means you will build the building block of your logic iteratively. That means you won't skip every detail of your logic and also won't add unnecessary features. At the end, you get a clean code and design.

Red, Green, Refactor (RGR)

To apply TDD in our project, we follow the red, green, refactor (RGR) approach.

RGR cycle

This approach will help developers to split their focus in the development processes. The meaning for every phase of the RGR approach are :

  • Red

    • Define the test cases of the method that going to be tested

    • Define the behaviors/expectations of the method

    • In this phase, we will get failed on the test cases we defined earlier. It's normal in the TDD process because we fix it later.

  • Green

    • Implement the necessary logic to make your method pass the test.

    • The goal is just to pass the test! Optimization and code efficiency come later.

  • Refactor

    • Improve your implementation!

    • In this phase, don't add new functionality to your code, just optimize it.

Additionally, there are three simple rules to apply TDD from Uncle Bob :

  1. You are not allowed to write any production code unless it is to make a failing unit test pass.

  2. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.

  3. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

Benefits of TDD

By implementing TDD, you will get benefits such as :

High code coverage

The most beneficial point of TDD is code coverage. Our development is driven by test cases, so it means we declared all behavior that happens in our code and that means we already know how our codes will behave before we code it.

Well-defined logic for your codes

In TDD, we improve the logic iteratively. Start from the most simple implementation to the more complex (the final goal of our method implementation). It also makes us easier to manage the logic as we split the implementation into smaller pieces.

Early prevention of bugs in the development process

By providing tests, you can ensure that all your code works as expected. Adding new functionality or making updates sometimes leads to violations of your existing codes. This violation can be early detected through tests. So, you will get a warning before pushing flawed code to production.

Improved quality of your code

By driving with tests, you already know the specifications you aim in the beginning of the process so every unnecessary part can be eliminated and your code is clean.

Simple Example

FizzBuzz Problem

In this example, we will make a FizzBuzz problem. I believe some of you are already familiar with this problem, FizzBuzz problem is one of the famous coding problems for code interviews. The criteria for the FizzBuzz problem are :

  • if the input number is divisible by 3, print "Fizz"

  • if the input number is divisible by 5, print "Buzz"

  • if the input number is divisible by both 3 and 5, print "FizzBuzz"

  • other than that, print the input as a string.

Initialization

Following the rule of TDD, we must declare our test case first before our actual class. To initialize, we write the test code like below.

import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {

    // First cycle test
    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();
    }
}

Notice that you will get an error with this code. It's expected as we haven't declare FizzBuzzConverter class. You can declare it in your resource (commonly src) folder. If you are using an IDE or kind of "smart" text editor, you can leverage its auto-generate feature.

This is the initialization part of our TDD. We create a minimal class. As you can see, we initialize it's an empty class without any methods.

public class FizzBuzzConverter {
}

First Cycle

Now, we enter the first cycle of our TDD implementation. We do a slight modification to our code test like below by adding two assertions. Assertion is an important part of code testing. we will do a comparison between the expected output and the actual output of the method. If the actual method outputs the same value as the expected value, the assertion will be passed. Otherwise, won't pass.

import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {

    // First cycle test
    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("1", converter.convertNumberToFizzBuzz(1));
        Assert.assertEquals("2", converter.convertNumberToFizzBuzz(2));
    }
}

Also, we will get an error with the code above because we haven't created convertNumberToFizzBuzz method in FizzBuzzConverter class yet. To fix this, you must declare convertNumberToFizzBuzz method.

public class FizzBuzzConverter {
    public String convertNumberToFizzBuzz(int number) {
        return null;
    }
}

Now we can test our code. In this phase, we expect convertNumberToFizzBuzz method to return the string of each integer input. But in fact, it returns an ERROR instead. That's why we need to modify convertNumberToFizzBuzz method like the code below.

public class FizzBuzzConverter {
    public String convertNumberToFizzBuzz(int number) {
        return String.valueOf(number);
    }
}

This refactor will give us a pass on the first cycle test scenario.

Second Cycle

We iteratively add necessary test scenarios. Now, we must test the condition when the input number is divisible by 3. So, we should update our test class with the code below.

import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {

    // First cycle test
    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("1", converter.convertNumberToFizzBuzz(1));
        Assert.assertEquals("2", converter.convertNumberToFizzBuzz(2));
    }

    // Second cycle test
    @Test
    public void givenFizz_whenDivisibleByThree() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("Fizz", converter.convertNumberToFizzBuzz(3));
        Assert.assertEquals("Fizz", converter.convertNumberToFizzBuzz(6));
    }
}

Like the first cycle, it returns an ERROR because we haven't implemented this scenario yet in our code. So, we should update our code to get the GREEN code for this cycle. The update is like this below.

public class FizzBuzzConverter {
    public String convertNumberToFizzBuzz(int number) {
        if (number % 3 == 0)
            return "Fizz";
        return String.valueOf(number);
    }
}

Third Cycle

Like the second cycle, now we will make the test for the next scenario. We should add a new test method to test the condition when the input number is divisible by 5.

import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {

    // First cycle test
    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("1", converter.convertNumberToFizzBuzz(1));
        Assert.assertEquals("2", converter.convertNumberToFizzBuzz(2));
    }

    // Second cycle test
    @Test
    public void givenFizz_whenDivisibleByThree() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("Fizz", converter.convertNumberToFizzBuzz(3));
        Assert.assertEquals("Fizz", converter.convertNumberToFizzBuzz(6));
    }

    // Third cycle test
    @Test
    public void givenBuzz_whenDivisibleByFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("Buzz", converter.convertNumberToFizzBuzz(5));
        Assert.assertEquals("Buzz", converter.convertNumberToFizzBuzz(10));
    }
}

ERROR again! it's time to extend the implementation to cover the scenario.

public class FizzBuzzConverter {
    public String convertNumberToFizzBuzz(int number) {
        if (number % 3 == 0)
            return "Fizz";
        if (number % 5 == 0)
            return "Buzz";
        return String.valueOf(number);
    }
}

This code above should let you pass all three scenarios.

Fourth Cycle

Don't forget, we still have 1 scenario left. We must test if the method will return "FizzBuzz" if the input is divisible both by 3 and 5.

import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {

    // First cycle test
    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("1", converter.convertNumberToFizzBuzz(1));
        Assert.assertEquals("2", converter.convertNumberToFizzBuzz(2));
    }

    // Second cycle test
    @Test
    public void givenFizz_whenDivisibleByThree() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("Fizz", converter.convertNumberToFizzBuzz(3));
        Assert.assertEquals("Fizz", converter.convertNumberToFizzBuzz(6));
    }

    // Third cycle test
    @Test
    public void givenBuzz_whenDivisibleByFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("Buzz", converter.convertNumberToFizzBuzz(5));
        Assert.assertEquals("Buzz", converter.convertNumberToFizzBuzz(10));
    }

    // Fourth cycle test
    @Test
    public void givenBuzz_whenDivisibleByThreeANdFive() {
        FizzBuzzConverter converter = new FizzBuzzConverter();

        Assert.assertEquals("FizzBuzz", converter.convertNumberToFizzBuzz(15));
        Assert.assertEquals("FizzBuzz", converter.convertNumberToFizzBuzz(30));
    }
}

As usual before an ERROR again. So, we must apply an enhancement to our code.

public class FizzBuzzConverter {
    public String convertNumberToFizzBuzz(int number) {
        if (number % 15 == 0)
            return "FizzBuzz";
        if (number % 3 == 0)
            return "Fizz";
        if (number % 5 == 0)
            return "Buzz";
        return String.valueOf(number);
    }
}

As divisible by 3 and 5 also means divisible by 3x5, we can apply the improvement above.

Final Refactorization

public class FizzBuzzConverter {
    public String convertNumberToFizzBuzz(int number) {
        StringBuilder result = new StringBuilder();

        if (number % 3 == 0)
            result.append("Fizz");
        if (number % 5 == 0)
            result.append("Buzz");

        return result.length() > 0 ? result.toString() : String.valueOf(number);
    }
}

Finally, we can refactor our after all of the test cases are passed. We do a refactoring to make convertNumberToFizzBuzz method is more elegant. Note that we shouldn't add new features or functionalities in this phase as this act will possibly impact our established tests.

But, it doesn't mean you can't do any modifications in the future. You can adjust the method by doing another TDD procedure in the future.

Improve Your Test Code

There are several things we can do to improve our test code, such as :

Structured your code with the Arrange-Act-Assert (AAA) pattern

The AAA pattern is a descriptive and intention-revealing way to structure the test cases. It describes an order of operations inside each of the test functions :

  • Arrange: contains the set-up logic for the tests. We do such as object initialization and prepare the execution for the system under test (SUT).

  • Act: invokes the things (method or API) that we are about to test.

  • Assert: verifies that action of the SUT behaves as expected.

Here is an example of improvement for our test code :

import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {

    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        // Arrange
        FizzBuzzConverter converter = new FizzBuzzConverter();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(1);
        String result2 = converter.convertNumberToFizzBuzz(2);

        // Assert
        Assert.assertEquals("1", result1);
        Assert.assertEquals("2", result2);
    }

    @Test
    public void givenFizz_whenDivisibleByThree() {
        // Arrange
        FizzBuzzConverter converter = new FizzBuzzConverter();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(3);
        String result2 = converter.convertNumberToFizzBuzz(6);

        // Assert
        Assert.assertEquals("Fizz", result1);
        Assert.assertEquals("Fizz", result2);
    }

    @Test
    public void givenBuzz_whenDivisibleByFive() {
        // Arrange
        FizzBuzzConverter converter = new FizzBuzzConverter();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(5);
        String result2 = converter.convertNumberToFizzBuzz(10);

        // Assert
        Assert.assertEquals("Buzz", result1);
        Assert.assertEquals("Buzz", result2);
    }

    @Test
    public void givenBuzz_whenDivisibleByThreeAndFive() {
        // Arrange
        FizzBuzzConverter converter = new FizzBuzzConverter();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(15);
        String result2 = converter.convertNumberToFizzBuzz(30);

        // Assert
        Assert.assertEquals("FizzBuzz", result1);
        Assert.assertEquals("FizzBuzz", result2);
    }
}

Teardown your test code

Use test properties such as beforeEach, beforeAll, afterEach, or afterAll to your test code. You don't need to use all of them at a single time, just choose which are necessary.

import org.junit.After;
import org.junit.Assert;
import org.junit.Test;

public class FizzBuzzConverterTest {
    private FizzBuzzConverter converter;

    // Setup method
    public void setup() {
        converter = new FizzBuzzConverter();
    }

    @After
    public void teardown() {
        converter = null;
    }

    @Test
    public void givenNumberString_whenNotDivisibleByThreeOrFive() {
        // Arrange
        setup();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(1);
        String result2 = converter.convertNumberToFizzBuzz(2);

        // Assert
        Assert.assertEquals("1", result1);
        Assert.assertEquals("2", result2);
    }

    @Test
    public void givenFizz_whenDivisibleByThree() {
        // Arrange
        setup();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(3);
        String result2 = converter.convertNumberToFizzBuzz(6);

        // Assert
        Assert.assertEquals("Fizz", result1);
        Assert.assertEquals("Fizz", result2);
    }

    @Test
    public void givenBuzz_whenDivisibleByFive() {
        // Arrange
        setup();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(5);
        String result2 = converter.convertNumberToFizzBuzz(10);

        // Assert
        Assert.assertEquals("Buzz", result1);
        Assert.assertEquals("Buzz", result2);
    }

    @Test
    public void givenBuzz_whenDivisibleByThreeAndFive() {
        // Arrange
        setup();

        // Act
        String result1 = converter.convertNumberToFizzBuzz(15);
        String result2 = converter.convertNumberToFizzBuzz(30);

        // Assert
        Assert.assertEquals("FizzBuzz", result1);
        Assert.assertEquals("FizzBuzz", result2);
    }
}

Use code coverage tools

Code coverage is an important point in the development process. Having a high code coverage means you also have high protection for your code over misleading changes. You can also inspect the logic of your code according to the percentage of your code coverage. If you use Java you can use Jacoco or Serenity. Each programming languages or frameworks have its code coverage tools.

Conclusions

There are numerous benefits to implementing Test-Driven Development (TDD) in your project. Many companies incorporate TDD into their development cycles, and by gaining proficiency in its implementation, you can enhance your value proposition significantly.

Additional Resources