Categories
Blog Software Development

Test Driven Development – A practical Example

Step Twelve: Review and Outlook

Our calculator application is now complete. Using TDD and many best practices, we have developed an application that can calculate a mathematical expression that is given as a string.

We started by making a list of all the aspects that our program should cover. As we grew our program over time, we crossed these items off. Sometimes we found new aspects that we wanted to do later. And we added them to our list.

The seed for our program was the simplest test that we could think of. We have made that test pass with an even simpler implementation. That was the point when our program put down roots and started to grow. After several iterations of the TDD cycle, we noticed that the code was becoming too big for just one class. We decided to branch our program into several classes, each of which had its distinct responsibility. We devoted a set of unit tests to each of these classes. We also wanted to make sure that all the classes work well together. This is why we used integration tests for all objects.

With this setup, our program grew bigger and stronger in every iteration. We noticed that the approach we took made it easier to extend the program. Repeated refactorings produced a structure of the code that made further extensions easy. We never had to throw it all overboard. With every extension, we could easily see when we broke exisiting functionality and where the error was coming from.

We ended up with a program that not only fulfills all requirements from our list, but could also be extended easily because of the structure that we chose.

With this foundation, you should be able to extend the program easily. Maybe you want to add the Modulo function or exponentials. You can try this out as an exercise.

Now that you have experienced Test Driven Development in a real example, let’s have a look at some questions that might come to your mind.

Is Our Program Bug Free?

Does Test Driven Development produce code without bugs? No, it does not. It is not possible to prove that a program has no bugs. So it is also not possible to prove that TDD produces code without bugs. But TDD helps you to produce code with less bugs. Also, it is a tool to find and fix bugs quicker.

Practicing TDD, you have to think about what every piece of your code is supposed to do before you actually write it. Writing a test first forces you to make assumptions about the code clear. This aspect alone can prevent many bugs.

By making sure that every new test fails, you improve the quality of your tests. You can be sure that this test is meaningful and that it will show you when you introduce an error again. This provides you with a firewall against introducing new bugs when extending or changing your program.

This firewall is not only for you, but also for your colleagues. Imagine how difficult it is having to change somebody else’s code. How do you know you don’t break anything when changing the code? If you have a set of tests at hand, you can do changes more confidently. Whenever you break a test, you will see the assertion in the test that failed. Then you know why your changes were not good, and you can adapt or undo them. You can also debug a broken test to see which of your changes causes the error.

There is another reason that TDD cannot cover all bugs. There may be subtle bugs hidden even in TDD code, like concurrency problems. And it also does not prevent you from simply implementing the wrong thing. So it is always necessary that somebody else checks the correct working of the program.

If you receive a bug report, it shows you that the tests don’t cover every possible path through the code. Don’t be ashamed, because it is not possible to cover every possible combination of inputs and program states. As your existing tests do not cover that particular situation, you can find the bug with another test. After you know how it could have happened, you design a test case to prove your theory. This works the same way as for the normal TDD cycle. The test case has to be red first. Otherwise, your theory was wrong and you have to search for the bug somewhere else. Then you fix the test case as quickly as you can. You run your test suite again to make sure you did not break anything else. And afterwards you do a refactoring, if necessary. After fixing the bug, you will also have a test case that will prevent reintroducing the bug later on. We did this kind of bugfixing in Step 11.

How Many Tests Do I Need?

This question is hard to answer. You can never test everything, as the possible number of combinations is growing exponentially with the size of the software. This means you have to use heuristics to find out how many tests you should write.

For every class that you write, you should test all its public methods. Then you should test the most important combinations of parameters. If it is a numerical parameter, choose two or three significant values. Zero should be among these values, as this is often a special case in calculations. If a parameter has a specific range, then use a value that is just inside the range and another one that is just outside the range. All the other values inside the range can normally be left out in the test. If there are special values for a parameter, test them, too. If the parameter is a reference, try to pass a None value (or the equivalent from your programm language). If it is a string, try to pass an empty string. If the class has a state, add a test that calls multiple methods to bring the class into different states.

In bigger projects, you should consider measuring the code coverage of your tests. The coverage is a measure that tells you which paths through your software are executed in your tests. There are different measures of code coverage. For instance, you can measure the statement coverage, which tells you which percentage of all statements of the program are executed during the tests. A more extensive measure is the branch coverage, which checks if every possible path in a branch is executed. By measuring the code coverage, you can design new tests in a way that the code coverage is increased and no code lines are left out in the tests. This is a very powerful tool, but it still cannot prove that there are no bugs in the code. For instance, it does not force you to check the outcome of all the executed code lines. It just tells you that the lines were executed.

Can I Have too Many Tests?

Every test that you write will increase the execution time of your test suite. If the tests run in a matter of seconds, then you might not notice this. But as a program gets bigger and it has thousands of tests, the execution time might prevent you from running the tests frequently.

Check if you have too many tests. Often, there will be many tests that are redundant and test the same thing. Take for example the integration tests of CalculatorApplication. We started with tests for the addition of two numbers. Later, we added tests for expressions with more numbers. If these new tests work, we can assume that they also cover the addition of two numbers. This means that the old tests have become redundant and we can remove them.

If you discover such a test, don’t hesitate to remove it despite your effort writing it. Removing it will speed up your test execution. You should also remember that every test that you keep will need maintenance in the future.

It does not matter how many tests you have, as long as you have the right ones.

When Should I Use Test Driven Development?

TDD is a valuable tool whenever you have to develop a software that you want to release to other people. Even if you are working alone on a project, you will appreciate having a set of tests to cover your steps as your program grows. And when you start collaborating with other people, they will be thankful to have this safety net when they start working with the code base.

If you have to work on a legacy code base without tests, it is a good idea to start writing tests for this code base, too. Not only will it provide you with a safety net, but it will also help you learn how the program works internally. Writing tests for a legacy system forces you to find out what it does. And the tests will tell you if your assumptions are true.

On the other hand, if you are working on a prototype that you intend to throw away, then you should not do TDD. It will only slow you down, and the benefits of your test suite will not be as important.

What Can I do With Our Application?

We have developed a set of classes that is coordinated by the class CalculatorApplication. We can use this class whenever we need to compute a mathematical expression. Because CalculatorApplication encapsulates all necessary steps, we can use it in different kinds of application. You can use it for desktop calculator, in a Python based web service or on a command line. To try out the program, you can use the following code for a command line program:

from CalculatorApplication import CalculatorApplication
from Tokenizer import Tokenizer
from ExpressionValidator import ExpressionValidator
from Calculator import Calculator

def main():
    print("Command Line Calculator")

    # Initialize application objects
    tokenizer = Tokenizer()
    expression_validator = ExpressionValidator()
    calculator = Calculator()
    app = CalculatorApplication(tokenizer, expression_validator, calculator)

    # Read from command line
    user_input = input("What do you want to calculate? ")

    # Calculate
    result = app.calculate(user_input)
    print("Result = " + result)

if __name__ == '__main__':
    main()

This program simply reads a single expression from the command line, prints the result and then terminates. You should notice our design decision to use dependency injection to improve testability. This means that to construct an instance of CalculatorApplication, our code has to construct all the other classes first and pass it into the initializer.

You can play around with our algorithms and see if you find any bugs.

Where Can I Find More Information?

The book that lay the foundation for TDD is Kent Beck’s “Test Driven Development: By Example”. It contains brilliant examples and theoretical background for TDD. It is also very easy and fun to read. I consider it a must-read for every software developer.

Another very important book for me was “Growing Object-Oriented Software, Guided by Tests” by Steve Freeman and Nat Pryce. This book features and example of an application that is developed test driven. They start with an “acceptance test” that tests the application from the inside out, beginning with the user interface. Then you develop the classes of the program test driven. This approach is very similar to the one we used in this example, but with more emphasis on the outward connections of the application.

In case you are working on a legacy code base, I recommend reading “Working Effectively with Legacy Code” by Michael Feathers. It presents the technique of exploratory tests mentioned above.

Parting Words

In my professional life, I have benefited a lot from applying this technique, which I learned from the books I mentioned and from many tests that I have written. By applying these techniques, my code became more reliable. My TDD code usually worked as intended the first time I applied it on the target. Everything that did not work could be traced back to either incorrectly interpreted requirements or wrong assumptions about the interfaces to other software, but very rarely to implementation errors. This made my life much easier, as every error that was found in my code could be traced back to either a missing or a wrong test. By adapting or adding tests accordingly, I can be very confident that my fix is correct even before I try it out on the target.

Applying TDD properly is a skill that grows through practicing. I recommend to do it, even when the pressure is on in your project. It will save you a lot of headaches.

I hope this example has provided you with enough information and inspiration to get you started.

Leave a Reply

Your email address will not be published. Required fields are marked *