BT

InfoQ Homepage Articles Test-Driven Development: Really, It’s a Design Technique

Test-Driven Development: Really, It’s a Design Technique

Bookmarks

Key Takeaways

  • It is commonly accepted that any software we create must be tested to ensure it meets the functional requirements, but we must also test non-functional requirements, like security, usability, and -- critically -- maintainability.
  • Test-driven development (TDD) is an established technique for delivering better software, more rapidly, and more sustainably over time.
  • TDD is based on a simple idea: write a failing test before you write production code itself. However, this “simple” idea takes skill and judgment to do well. 
  • TDD is really a technique for design. The foundations of TDD are focused on using small tests to design systems from the ground up in an emergent manner, and to rapidly get value while building confidence in the system. A better name for this technique might really be Test-Driven Design. 
  • Often the first step in developing a solution to a given problem, regardless of its complexity, is to analyze it and then break it up into smaller components. These components can be implemented in a series of steps, where input scenarios are consider alongside what should be the output for the next step. 

We need software testing to be sure that the software meets the requirements, that it responds correctly to input (input validation), that it performs in an acceptable time (performance testing), that users can install and run it (deployment testing), and that it meets the goals of the stakeholders. These goals could be business results or functions like security, usability, maintainability, and other kinds of -ilities.

Test types include:

  • Smoke and sanity tests check if the software will run even in basic form.
  • Continuous testing runs in every iteration, such as when we run Maven.
  • We use regression tests when we add new programming code or alter existing code. We want to be sure that the other code keeps working.
  • Performance tests measure the time it takes to run the software.
  • Acceptance tests check if the stakeholders are happy with the software and that they are willing to pay the bill.

Unit tests are the smallest building blocks of a set of tests. Every class in the programming code has a companion unit-test class. The test is isolated from the other class by mocking the method call.

Integration tests are easier to implement. We'll test a class with all the dependencies. Here, we can ensure that the path through the software is working, but when it fails, we do not know which class is failing. A system test checks the complete system, including hardware operating system, web service, and so on.

Tests should be readable because non-programmers should be able to read or change a test. In an agile team, programmers work together with testers and analysts, and the tests and specifications are the common ground, so everybody should be able to read the test and even alter tests when necessary.

TDD: All about design and productivity

Test-driven development (TDD) is an established technique for sustainably delivering better software faster. TDD is based on a simple idea: write a failing test before you write production code itself. Need new behavior? Write a failing test. However, this deceptively simple idea takes skill and judgment to do well.

TDD is really a technique for design. The foundation of TDD is using small tests to design bottom-up in an emergent manner and rapidly get to some value while building confidence in the system. A better name might test-driven design.

As a design method, it’s about focus and simplicity. The goal is to prevent developers from writing superfluous code that’s unnecessary for the delivery of value. It’s about writing the least amount of code needed to solve the problem.

Many articles boast of all the advantages of doing TDD and a lot of tech conferences talks tell us to do the tests and how cool it is doing them. They are right (not necessarily about the cool part, but about the useful part). Tests are a must! The typically listed advantages of TDD are real:

  • You write better software.
  • You avoid over-engineering.
  • You have protection from breaking the world when introducing new features.
  • Your software is self-documented.

Even if I’ve always agreed with these advantages, there was a time when I thought that I didn’t need TDD to write good and maintainable software. Now, of course, I know I was wrong, but why did I have this idea despite the shiny magical benefits? The cost!

TDD costs a lot! Anyone thinking that it costs even more if we don’t do the tests is right, but those costs come at a different time. If we do TDD, we have an immediate cost. If we don’t do TDD, our cost comes in the future.

The most effective way to get something done is by doing it as naturally as possible. The nature of people is to be lazy (software developers may be the best performers at this) and greedy, so we have to find a way to reduce costs now. It’s easy to say, but so hard to do!

There are many theories, dimensions, and points of view surrounding TDD, but I prefer to show how we do TDD in practice. And finally, we will see from where we started and going forward until the final piece of art we are going to have, which will be achieved only by using TDD.

Here is a link for my presentation "Unit testing & TDD concepts with best practice guidelines", which contains the topics:

  • why we need testing,
  • types of testing,
  • how and when to use each type,
  • coverage by test level,
  • testing strategies, and
  • TDD in practice.

It also includes guidelines and best practices that to guide what to do and not to do while testing.

The "TDD in practice" section and the concepts introduced generally apply to any language, but I use Java for the demonstration. The goal is to show how we should think when we design and to create exciting art, not just write code.

Analyze the problem

The first step to solving any problem regardless of its complexity is to analyze it and then break it up into small continuous and complete steps, considering input scenarios and what the output should be. We review these steps to be sure that we have no gaps relative to the original requirements - no more and no less - from a business perspective without going deeply into implementation details.

This is a critical step; one of the most important steps is to be able to identify all the requirements of the given problem at hand to streamline the implementation phase to come. By having these smaller steps, we will have a clean, easily implemented, testable code.

TDD is key to developing and maintaining such steps until we cover all the cases of the problem at hand.

Let’s imagine that we have been asked to develop a conversion-tool library to convert any Roman number to its equivalent Arabic number. As a developer, I will do the following:

  1. Create a library project.
  2. Create the class.
  3. Likely dive in to create the conversion method.
  4. Think of the possible problem scenarios and what could be done.
  5. Write a test case for the task, to make sure that I’ve written boring test cases (which tend to lead to not writing any test cases) while having already almost tested the task in the main method as usual.

This kind of development sucks.

To correctly start the process and put TDD into action while we develop our code, follow these practical steps to a successful final project, with a suite of test cases that shields time and cost for future development.

The code for this example may be cloned from my GitHub repository. Fire up your terminal, point to your favorite location, and run this command:

$ git clone https://github.com/mohamed-taman/TDD.git

I have managed the project to have a commit for each TTD red/green/blue change, so when navigating commits we can notice the changes and the refactoring done toward final project requirements.

I am using the Maven build tool, Java SE 12, and JUnit 5.

TDD in practice

In order to develop our converter, our first step is to have a test case that converts the Roman I to the Arabic numeral 1.

Let’s create the converter class and the method implementation in order to have the test case satisfy our first requirement.

But wait, wait, and wait! Hold on a second! As practical advice, it is better to start with this rule in mind: do not create the source code first, but start by creating the class and the method in the test case. This is called programming by intention, which means that naming the new class and the new method where it is going to be used will force us to think about the usage of the piece of code we are writing and how it will be used, which definitely leads to a better and cleaner API design.

Step 1

Start with creating a test package, class, method, and the test implementation:

Package: rs.com.tm.siriusxi.tdd.roman
Class: RomanConverterTest
Method: convertI()
Implementation: assertEquals(1, new RomanConverter().convertRomanToArabicNumber("I"));

Step 2

There is no test case to fail here: it is a compilation error. So, let’s first create the package, class, and method in the Java source folder using IDE hints.

Package: rs.com.tm.siriusxi.tdd.roman
Class: RomanConverter
Method: public int convertRomanToArabicNumber(String roman)

Step 3 (Red state)

We need to make sure that our designated class and method are correct and that the tests cases run.

By implementing the convertRomanToArabicNumber method to throw IllegalArgumentException, we are sure that we have reached the red state.

public int convertRomanToArabicNumber(String roman) {
        throw new IllegalArgumentException();
 }

Run the test case. We should see the red bar.

Step 4 (Green state)

In this step, we need to run the test case again but this time to see a green bar. We will implement the method with the minimal amount of code to satisfy the test case to go green. So, the method should return 1.

public int convertRomanToArabicNumber(String roman) {
        return 1;
 }

Run the test case. We should see the green bar.

Step 5 (Refactoring state)

Now it is time for refactoring (if any). I would like to emphasize that the refactoring process does not only involve the production code but also testing code.

Remove the unused imports from the test class.

Run the test case again to see the blue bar. Hehehe - I’m kidding, there is no blue bar. We should see the green bar again if everything still works as intended after refactoring.

Removing unused code is one of the primary, simple refactoring methods, which increases code readability and size of the class, thereby the project size, too.

Step 6

From this point on, we will follow the consistent process of red to green to blue. We pass the first point of TDD, the red state, by writing a new requirement or a step of a problem as a failing test case, and follow on until we complete the whole functionality.

Note that we start with a fundamental requirement or step then move on, step by step, until we finish the required functionality. This way, we have clear steps to complete, starting from the easiest to the complex.

It’s better to do this instead of jumping ahead and spending a lot of time thinking about the whole implementation at once, which may lead us to overthink and cover cases that we don’t even require. This introduces over-coding. And being less productive.

Our next step is to convert II to 2.

In the same test class, we create a new test method and its implementation as the following:

Method: convertII()
When we run the test case, it goes to the red bar because the convertII() method failed. The convertI() method remains green, which is good.

Step 7

Now we need to make the test cases run and result in a green test bar. Let’s implement a method to satisfy both cases. We can use a simple if/else if/else check to handle both cases, and in case of else, we throw IllegalArgumentException.

public int convertRomanToArabicNumber(String roman) {
        if (roman.equals("I")) {
            return 1;
        } else if (roman.equals("II")) {
            return 2;
        }
        throw new IllegalArgumentException();
    }

An issue to avoid here is the null pointer exception rising from a line of code such as roman.equals("I"). To fix it, simply invert the equality to "I".equals(roman).

Running the test cases again, we should see the green bar for all cases.

Step 8

Now, we have an opportunity to look for refactoring cases, and there is a code smell here. In refactoring, we typically look for code smell in the form of:

  • long methods,
  • duplicate code,
  • a lot of if/else code,
  • switch-case demons,
  • logic simplifications, and
  • design issues.

The code smell here (did you find it?) is the if/else statements and many returns.

Perhaps we should refactor here by introducing a sum variable, and a for loop to loop through the character array of Roman characters. If a character is I, it will add 1 to sum then return the sum.

But I love defensive programming so I will move the throw clause to an else statement of the if statement in order to cover any invalid characters.

public int convertRomanToArabicNumber(String roman) {
        int sum = 0;
        for (char ch : roman.toCharArray()) {
            if (ch == 'I') {
                sum += 1;
            } else {
                throw new IllegalArgumentException();
            }
        }  
        return 0;
    }

In Java 10 and later, we can use var for defining the variable, giving us var sum = 0;  instead of int sum = 0;.

We run the tests again to make sure that our refactoring didn’t change any code functionality.

Ouch - we see a red bar. Oh! All test cases return 0, and we mistakenly returned 0 instead of sum.

We fix that and now we see beautiful green.

This demonstrates that no matter how trivial a change is, we need to run tests after it. There is always an opportunity to introduce bugs while refactoring, and our helpers here are the test cases, which we run to catch the bugs. This shows the power of regression testing.
Looking at the code again, there is another smell (do you see it?). The exception here is not descriptive, so we must provide a meaningful error

message of Illegal roman character %s, ch.

throw new IllegalArgumentException(String.format("Illegal roman character %s", ch));

Running the test cases again, we should see the green bar for all cases.

Step 9

We add another test case. Let’s convert III to 3.

In the same test class, we create a new test method and its implementation:

Method: convertIII()

Run the test cases again, and we see the green bar for all cases. Our implementation handles this case.

Step 10

Now we need to convert V to 5.

In the same test class, we create a new test method and its implementation:

Method: convertV()

Running the test case goes to the red bar because convertV() fails while others are green.

Implement the method by adding else/if to the main if statement and check that if char = 'v' then sum+=5;.

for (char ch : roman.toCharArray()) {
            if (ch == 'I') {
                sum += 1;
            } else if (ch == 'V') {
                sum += 5;
            } else {
                throw new IllegalArgumentException(String.format("Illegal roman character %s", ch));
  } }

We have an opportunity to refactor here but we will not take it in this step. In the implementation phase, our only goal is to make the test pass and give us a green bar. We are currently not paying attention to simple design, refactoring, or having good code. When the code passes, we can come back and refactor.

In the refactoring state, we focus on refactoring only. Focus on one thing at a time to avoid distractions becoming less productive.

The test case should go to the green state.

We sometimes need a chain of if/else statements; to optimize it, order the if statement test cases from the most visited case to the least visited case. Even better, if applicable, is to change to a switch-case statement to avoid testing cases and directly reach the case.

Step 11

We have a green state and we are in a refactoring phase. Returning to the method, we can do something about the if/else that I don’t like.
Perhaps instead of using this if/else, we can introduce a lookup table and store the Roman characters as keys and the Arabic equivalents as the values.

Let’s delete the if statement and write sum += symbols.get(chr); in its place. Right-click on the bulb and click to introduce an instance variable.

private final Hashtable<Character, Integer> romanSymbols = new Hashtable<Character, Integer>() {
        {
            put('I', 1);
            put('V', 5);
        }
    };


We need to check for invalid symbols as before, so we have the code determine if romanSymbols contains a specific key and if not, throw the exception.

    public int convertRomanToArabicNumber(String roman) {
        int sum = 0;
        for (char ch : roman.toCharArray()) {
            if (romanSymbols.containsKey(ch)) {
                sum += romanSymbols.get(ch);
            } else {
                throw new IllegalArgumentException(
                        String.format("Illegal roman character %s", ch));
            }
        }
        return sum;
    }

Run the test case and it should go to the green state.

Here is another code smell but for design, performance, and clean code. It is better to use HashMap instead of Hashtable because HashMap implementation is unsynchronized compared to Hashtable. A high number of calls to such a method will harm performance.

A design tip is to always use the general interface as target type, as this is cleaner code that is more easily maintained. It will be easy to change the implementation of details without affecting its usage in the code. In our case, we will use a Map.

private static Map<Character, Integer> romanSymbols = new HashMap<Character, Integer>() {
        private static final long serialVersionUID = 1L;
        {
            put('I', 1);
            put('V', 5);
        }
    };

If you use Java 9+, you can replace new HashMap<Character, Integer>() with new HashMap<>() as the diamond operator works with anonymous inner classes as of Java 9.

Or you can use the simpler Map.of().

Map<Character, Integer> romanSymbols = Map.of('I', 1, 'V', 5,'X', 10, 'L', 50,'C', 100, 'D', 500,'M', 1000);

java.util.Vector and java.util.Hashtable are obsolete. While still supported, these classes were made obsolete by the JDK 1.2 collection classes and should probably not be used in new development.

After this refactoring, we need to check that everything is okay and that we didn’t break anything. Yahoo, the code is green!

Step 12

Let’s add some more interesting values to convert. We get back to our test class to implement converting the Roman VI to 6.

Method: convertVI()

We run the test cases and see it passes. It looks like the logic we have put in allows us to automatically cover this case. We again got this for free, without implementation.

Step 13

Now we need to convert IV to 4 and it probably won’t go as smoothly as VI to 6.

Method: convertIV()

We run the test cases and the result is red as expected.

We need to make it pass our test case. We recognize that in Roman representation that a smaller-value character (such as I) is pre-appended to a greater-value character (such as V) reduces the total numeric value - IV is equal to 4 whereas VI equals 6.

We have built our code to always sum the values but to pass the test, we need to create subtraction. We should have a condition make the decision: if the previous character’s value is greater than or equal to the current character’s value, then it is a summation, else it is subtraction.

It’s productive to write the logic that fulfills the problem as it comes to mind without worrying about declarations of the variables. Just finish the logic and then create the variables that satisfy your implementation. This is programming by intention, as I have described before. This way, we will always introduce the least code in a faster way, without thinking of everything up front - the MVP concept.

Our current implementation is:

public int convertRomanToArabicNumber (String roman) {
        roman = roman.toUpperCase();
        int sum = 0;
        for (char chr : roman.toCharArray()) {
            if (romanSymbols.containsKey(chr))
                sum += romanSymbols.get(chr);
            else
                 throw new IllegalArgumentException(
                        String.format("Invalid roman character %s ", chr));
        }
        return sum;
    }

In the check for Roman character validity, we start our new logic. We write the logic normally and then create a local variable with help of IDE hints. In addition, we have a feeling for what types of variables they should be: they are either local to the method or instance/class variables.

        int sum = 0, current = 0, previous = 0;
        for (char chr : roman.toCharArray()) {
            if (romanSymbols.containsKey(chr)) {
                if (previous >= current) {
                    sum += current;
                } else {
                    sum -= previous;
                    sum += (current - previous);
                }
            } else {
                throw new IllegalArgumentException(
                        String.format("Invalid roman character %s ", chr));
            }

Now we need to change the for loop to be index based to have access to current and previous variables, and accordingly we change the implementation to satisfy the new change, in order to compile normally.

for (int index = 0; index < roman.length(); index++) {
            if (romanSymbols.containsKey(roman.charAt(index))) {
                  current = romanSymbols.get(roman.charAt(index));
                  previous = index == 0 ? 0 : romanSymbols.get(roman.charAt(index-1));
                if (previous >= current) {
                    sum += current;
                } else {
                    sum -= previous;
                    sum += (current - previous);
                }} else {
                throw new IllegalArgumentException(
                        String.format("Invalid roman character %s ", roman.charAt(index)));
            }

Now we run the test cases after adding this new functionality and we get green. Perfect.

Step 14

In a green state, we are ready for refactoring. We are going to attempt a more interesting refactor.

Our refactoring strategy is to always look to simplify the code. We can see here that the line of romanSymbols.get(roman.charAt(index)) appears twice.

Let’s extract the duplicate code to a method or class to be used here, so that any future change is in one place.

Highlight the code, right-click and choose NetBeans refactoring tool > introduce > method. Name it getSymbolValue and leave it as a private method, then hit OK.

At this point, we need to run the test cases, to see that our small refactoring didn’t introduce any bugs and our code didn’t break. We find that it still in a green state.

Step 15

We are going to do more refactoring. The statement condition romanSymbols.containsKey(roman.charAt(index)) is difficult to read and it’s not easy to figure out what it should test to pass the if statement. Let’s simplify the code to make it more readable.

While we understand what this line of code is now, I guarantee that in six months it will be difficult to understand what it is trying to do.
Readability is one of the core code qualities we should keep raising in TDD, because in agile we change code frequently and fast - and in order to make fast changes, the code must be readable. And any change should be testable, too.

Let’s extract this line of code into a method with the meaningful name of doesSymbolsContainsRomanCharacter, which describes what it does. And we will do it with the help of NetBeans refactoring tools as we did previously.

Doing this makes our code read better. The condition is: if symbols contain a Roman character then do logic, else throw an illegal-argument exception of invalid character.

We rerun all the tests again and find no new bugs.

Notice that I run the tests frequently, as soon as I introduce any small refactoring change. I don’t wait until I finish all the refactoring I intend to do, then run all the test cases again. This is essential in TDD. We want feedback quickly and running our test cases is our feedback loop. It allows us to detect any errors as early as possible and in small steps, not over long ones.

Because each of the refactoring step potentially can introduce new bugs, the shorter the duration between code change and code tests, the quicker we can analyze the code and fix any new bugs.

If we refactored a hundred lines of code then ran the test cases with a failure, we have to take time to debug in order to detect exactly where is the problem lies. It’s harder to find the line of the code that has introduced the errors when sifting through a hundred lines than when looking at five or 10 lines of code changes.

Step 16

We have duplicate code inside the two private methods we introduced and the exception message, This duplicate line is roman.charAt(index), so we will refactor it to a new method using NetBeans with a name of getCharValue(String roman, int index).

We rerun all tests and everything remains green.

Step 17

Now let’s do some refactoring to optimize our code and improve performance. We can simplify the calculation logic of the conversion method, which currently is:

int convertRomanToArabicNumber(String roman) {
        roman = roman.toUpperCase();
        int sum = 0, current = 0, previous = 0;
        for (int index = 0; index < roman.length(); index++) {
            if (doesSymbolsContainsRomanCharacter(roman, index)) {
                current = getSymboleValue(roman, index);
                previous = index == 0 ? 0 : getSymboleValue(roman, index - 1);
                if (previous >= current) {
                    sum += current;
                } else {
                    sum -= previous;
                    sum += (current - previous);
                }
            } else {
                throw new IllegalArgumentException(
                        String.format("Invalid roman character %s ",
                                getCharValue(roman, index)));
            }
        }
        return sum;
    }

We can save a couple of useless CPU cycles by improving on this line:

 previous = index == 0 ? 0 : getSymboleValue(roman, index - 1);

We don’t have to get the previous character as the previous character is simply the current character at the end of the for loop. We can delete this line and replace it with previous = current; after the calculation at the end of else condition.

Running the test case should bring us green.

Now we are going to simplify the calculations to discard another few useless computation cycles. I will revert the calculation of the if statement test case, and reverse the for loop. The final code should be:

for (int index = roman.length() - 1; index >= 0; index--) {
            if (doesSymbolsContainsRomanCharacter(roman, index)) {
                current = getSymboleValue(roman, index);
                 if (current < previous) {
                    sum -= current;
                } else {
                    sum += current;
                }
                previous = current;
            } else {
                throw new IllegalArgumentException(
                        String.format("Invalid roman character %s ",
                                getCharValue(roman, index)));
            }

Run the test case, which should again be green.

As the method doesn’t change any object state, we can make it a static method. Also, the class is a utility class so it should be closed for inheritance. While all the methods are static, we should not allow the class to be instantiated. Fix this by adding a private default constructor, and mark the class as final.

Now we will have a compilation error in the test class. Once we fix that, running all the test cases should give us green again.

Step 18

The final step is to add more test cases to make sure that our code covers all the requirements.

  1. Add a convertX() test case, which should return 10 as X = 10. If we run the test, it will fail with IllegalArgumentException until we add X = 10 to the symbols map. Run the tests again and we will get green. There is no refactoring here.
  2. Add convertIX() test case and it should return 9 as IX = 9. The test will pass.
  3. Add to the symbols map these values: L = 50, C = 100, D = 500, M = 1000.
  4. Add a convertXXXVI() test case and it should return 36 as XXXVI = 36. Run the test, which will pass. There is no refactoring here.
  5. Add a convertMMXII() test case and it should return 2012, Run the test and it will pass. There is no refactoring here.
  6. Add convertMCMXCVI() test case and it should return 1996. Run the test it will pass. There is no refactoring here.
  7. Add a convertInvalidRomanValue() test case and it should throw IllegalArgumentException. Run the test and it will pass. There is no refactoring here.
  8. Add a convertVII() test case and it should return 7 as VII = 7. But when we try it with vii in lower case, the test it will fail with IllegalArgumentException because we handle only uppercase letters. To fix this, we add the line roman = roman.toUpperCase(); at the beginning of the method. Run the test cases again and it will be green. There is no refactoring here.

At this point, we have finished our piece of art (implementation). With TDD in mind, we have needed minimal code changes to pass all the test cases and satisfy all requirements with refactoring, ensuring that we have great code quality (performance, readability, and design).

I hope you enjoyed this as much as I did, and that it encourages you to start using TDD in your next project or even the task at hand. Please help with spreading the word by sharing, liking the article, and adding a star to the code on GitHub.

About the Author

Mohamed Taman is Sr. Enterprise Architect @Comtrade digital services, a Java Champions, An Oracle Groundbreaker Ambassador, Adopts Java SE.next(), JakartaEE.next(), a JCP member, Was JCP Executive Committee member, JSR 354, 363 & 373 Expert Group member, EGJUG leader, Oracle Egypt Architects Club board member, speaks Java, love Mobile, Big Data, Cloud, Blockchain, DevOps. An International speaker, books & videos author of "JavaFX essentials", "Getting Started with Clean Code, Java SE 9", and "Hands-On Java 10 Programming with JShell", and a new book "Secrets of a Java Champions", Won Duke’s choice 2015, 2014 awards, and JCP outstanding adopt-a-jar participant 2013 awards.

 

Rate this Article

Adoption
Style

Hello stranger!

You need to Register an InfoQ account or or login to post comments. But there's so much more behind being registered.

Get the most out of the InfoQ experience.

Allowed html: a,b,br,blockquote,i,li,pre,u,ul,p

Community comments

Allowed html: a,b,br,blockquote,i,li,pre,u,ul,p

Allowed html: a,b,br,blockquote,i,li,pre,u,ul,p

BT

Is your profile up-to-date? Please take a moment to review and update.

Note: If updating/changing your email, a validation request will be sent

Company name:
Company role:
Company size:
Country/Zone:
State/Province/Region:
You will be sent an email to validate the new email address. This pop-up will close itself in a few moments.