An introduction to TDD benefits, risks and examples
Programmers, looking to implement -- or improve -- your team's TDD practices? Review the fundamentals and examples below to remember how TDD can be a benefit and not a burden.
Programmers should base decisions about test-driven development on how covered the code base currently is and what level of test coverage it needs. Teams should weigh the same factors when they think about unit testing.
Most programmers can usually provide some code with test coverage. All the major IDEs come with unit testing tools that are as easy to start as a File > New Test action. However, despite this major change over the past two decades, the focus has shifted even more from TDD to unit testing. Code complexity associated with TDD can affect some organization's decisions, but TDD shouldn't be too difficult to implement.
In this introduction to TDD, we explore the approach's design upsides, its overall risks and benefits, and how thinking around TDD has evolved.
How TDD works
TDD separates function from business goals or intent. The process assesses if the software does what is wanted without working out the how or why.
Programs take input, perform transformations and create output. At the unit level -- such as a function or method -- a program takes a specific input and has a return value. Design by contract would design the test in advance, which is to create tests that reliably deliver known results for particular input conditions. For example, when this function is called with this input, expect this output. Consider how a process like contract testing assesses software at the more macro system level -- i.e., if two different systems can communicate with each other.
Test-driven development works at a much lower level, the level of the individual line of code. The "pure" version of TDD is a simple mantra: Failing test, make test pass, refactor. That means the programmer starts any change by writing a test that will fail. The change has not happened yet after all, so the new expectation is not satisfied. Then, the programmer writes the simplest possible line of code to make that test pass. Once the code passes, the programmer can improve the code or refactor it and then check for side effects by rerunning the test suite.
In TDD, programmers adhere to the following mantra:
- red bar (write a failing test)
- green bar (make the test pass)
- refactor
- repeat
With this approach, a programmer can check in code after every green bar and every refactor in less than a minute in small or well-isolated code bases. The tests create a regression test framework and enable the programmer to make large changes to the code base.
A simple TDD example
Imagine an insurance application with inputs such as a driver's age, the vehicle's value, the deductible on collision and any possible discounts. In this example, we'll write a simple test (shown in Java with JUnit 4):
public void test_underage() {
int fee = get_insurance_quote(15);
assertEquals("15 year olds cannot get insurance", -1, fee);
}
In this example, you'll notice the function get_insurance_quote that returns a value of an integer that represents the monthly fee. This rule establishes that a 15-year-old can't get insurance, and -1 is going to be a smart-coded error message. The next step is to write code to make the test pass, which can be done in pure TDD fashion:
public static int get_car_insurance_quote(int age) {
return -1;
}
While this code makes the test pass, it doesn't implement requirements in any meaningful way. The next example might look more like this:
public void test_sixteen() {
int fee = get_insurance_quote(16);
assertEquals("16 year olds get insurance at $100/mo", 100, fee);
}
The programmer can add an if statement to return $100 per month if the input age is over 16 and then move on to the next test. The red/green/refactor loop continues until the code is complete.
Most units are nowhere near as clean as the example above. Instead, they have dependencies, glue systems together and deal with data that can be in any state outside the application. If code contains data in a global state, it can make any TDD-style tests invalid.
All these realities can make TDD challenging, but the process remains uniquely suited to finding and eliminating flaws in code.
Design benefits of TDD
Imagine a function that takes an integer and prints hello world that same number of times. The function might look something like this:
private static void print_hello(integer n) { … }
A function like the example above is difficult to test. Perhaps a programmer might redirect the standard output () function of the OS and capture the output. More likely, the programmer would separate the printing function from the calculating function, making the code look like this:
private static string print_hello(integer n) { … }
The method now returns a string that contains hello repeated n number of times. A JUnit test for the function can look something like this:
public void test_five_hellos() {
string s = print_hello(5);
string compare = "hello\nhello\nhello\nhello\nhello";
assertEquals("hello repeats five times with CRLF", compare , s);
}
Second, the final hello does not contain a Carriage Return/Line Feed (CRLF) symbol, which is \n. If the function should include that at the end is ambiguous. With the test in place, these kinds of decisions -- which might be based on personal preference -- are now explicit. If a new programmer changes the code to no longer perform this way, an error will trip, at least forcing a second thought, if not an entire conversation.
The newer design also separates the concerns of printing from the business logic. When programmers focus on test-driven development during the design phase, it forces this separation and alleviates these concerns.
How TDD has evolved
One great advantage of unit tests is speed, but speed opens the door to trouble -- especially when a unit test checks an object that hits a network, file system or database. Programmers can implement mocks and stubs to handle these issues by testing the object in isolation. Test only the calls to the external world.
Around 2010, the design of systems changed again to make these external-world connections swappable for mocks. In his article "TDD is dead. Long live testing." David Heinemeier Hansson, the creator of Ruby on Rails, wrote: "Test-first units leads to an overly complex web of intermediary objects and indirection to avoid doing anything that's 'slow'. Like hitting the database. Or file IO. Or going through the browser to test the whole system. It's given birth to some truly horrendous monstrosities of architecture. A dense jungle of service objects, command patterns, and worse."
Hansson was not alone. Sean McMillan, a programmer based in Kalamazoo, Michigan, and I had made the same argument in a Google Tech Talk. Unlike Hansson, we did not want to bury TDD, but instead to reform it. Our idea was to use larger tests, to be careful with mocking and, perhaps, not to obsess over religiously creating a new test for every single change in the code. Kent Beck, co-creator of Extreme Programming, commented in this same vein, saying: "I get paid for code that works, not for tests." But only experienced programmers may be able to successfully take that approach.
It is too easy to confuse pragmatism for laziness and to try to find virtue in skipping tests. The lesson here is to at least understand what the proponents of TDD were trying to accomplish, to understand how well covered the code base is and to push it in the right direction. Doing so will make the objects easier to use as a client, while reducing risk.
The author would like to thank Danny Fast, who contributed to the peer review of this article.