Test Driven Development 101

Posted by Berin Loritsch Thu, 03 Jul 2008 11:47:00 GMT

I’m back after a long hiatus, and I’m probably talking to the air right now, but that’s OK. For the two or three of you actually listening to me, listen on. We have a number of projects of different sorts at the company I work for, and you’d be surprised at how few do test driven development—and how few even write unit tests. In this day and age, not writing tests that can be automated at all is inexcusable. At the very least, the tricky stuff should be thoroughly tested. This article is for anyone who is either skeptical about or interested in Test Driven Development. That includes managers as well as developers.

What’s the Problem, Man?

We all ( at least should ) want to write quality software, and we will take this as the “given” in geometry. Since we want to write quality software, how do we do it? In the early cowboy days of software engineering, it was painful to write code. First you did all your planning, wrote what you hoped would work, printed the stacks of punch cards, and then loaded it on the main frame. Which explains all the texts on the huge design up front methodology. However, that was before my time. When I first started, we wrote a little, ran the software, and debugged. Which explains why debuggers were so important at that time. However, times have changed. We now have a plethora of unit testing frameworks and maintainable build scripts to incorporate the tests into the build process.

As soon as we ran into some hard problems that really couldn’t be completely designed up front (like TCP/IP stacks), we developers had to figure out a way to make sure things worked properly. I mean, the spec is pretty complete, but there are a lot of details and practical limitations imposed by hardware that you won’t find in the spec. If you attempted to alter the spec to include all these corner cases, you would also lose any way of understanding how it is supposed to work. This is how the automated unit test was born. All these corner cases had to be accounted for so that future changes to the code won’t break the existing functionality. I think everyone recognizes the importance of testing. It’s just how, when, and where testing happens that people disagree on.

Another problem that is often brushed under the table is code slipping in that doesn’t need to be there. How often have you tracked down a problem only to discover code that was never supposed to exist be the culprit. You remove the offending code, and violá it works! The code could have been there from old legacy requirements that no longer apply, or it could be a developer without experience over thinking a problem. Nevertheless the code is there and it is causing problems.

If you adopt a clean as you go philosophy to developing software, you had better make sure you don’t accidentally breaking anything as you refactor your code. Even with “safe” refactoring, there is an inherent risk that you can inadvertently break something you didn’t think you would. If only there was a way to make sure all the important functionality keeps working…. Oh yeah, you do have a full test suite don’t you? Oh, it didn’t get written because you were under the gun and you were on a roll? Too bad.

Test Driven Development (TDD) was designed to not only address these issues, but to instill a discipline so that your unit tests would get written. Let’s face it, all people are lazy. It’s in our nature to not do anything we think is a waste of time. Sure we’ll do a little work now to avoid a bunch of work later, but we need to know it really is going to avoid a bunch of work later. What some people fail to realize about TDD is that the time will be spent somewhere. Either you write your tests up front or you spend time in a debugger later. Either you prove that your approach will work now, or you do it later. Either you break up your work into testable chunks now, or you attempt to do it later and fail at it.

OK, So How Does It Work?

At its heart, TDD is pretty simple—you simply perform a prove, fix, prove cycle. First you prove it doesn’t work . Then you fix the problem. Finally, you prove your fix worked . If you are lucky, your first “prove” step will prove the code already handles what you were thinking about. Write the test anyway, because you need to make sure that any changes will still support that test case. It’s fairly easy to see how to perform the mechanics of TDD, but less about it’s design implications without a little more explanation.

One of the side-effects of writing your tests before you write code is that it makes your code easier to test. Remember, people are lazy? Since a proper unit test sets up the environment and checks the effect of the call after, we make it easy to set up the environment. In fact, the less we rely on network connectivity or environment variables, the better. If a bit of code can be tested just by passing objects into it and examining the return value, then you aren’t going to be overly clever in your implementation. Easily testable code, also happens to be more modular. If you can pass in a mock object for something that would normally talk to the network so that you can have the mock imitate the situations you would encounter there, you can more easily test just the small unit you are working on.

Unit Tests vs. Integration Tests

In the best of all worlds, a project would have both. Unit tests make sure that the smallest unit (such as a method on a class) is doing what we expect it to do. A proper unit test also tests only one aspect of that method. It’s not uncommon to have several tests for the same method to make sure that all the corner cases are taken care of. Integration tests make sure all the different units work together like we thought they would. A unit test uses mock objects to isolate the thing we are testing from everything else. An integration test sets up the complete environment and runs the test against that environment. They are different tools for different problems. To do TDD, unit tests are required, but additional testing is a plus.

More often than not, code that is properly unit tested will work just fine. However, there are some issues that only crop up when the whole system is put together. Perhaps there is some race condition, or some complicated event loop that only appears in certain conditions. Once you track down the cause of the problem, you can write a unit test to reproduce the condition that caused the mess in the first place. With the new unit test, you fix the code, and everything should work in integration again.

Many times, the integration tests are all done manually. The challenge is that it is hard to set up a complete environment, test the user interface or system messaging, and evaluate the results automatically. The problem is that over time the number of tests that people have to do for a release becomes daunting. People are less picky than machines. If there is a tool to support integration testing like Selenium or some other testing framework, use it to at least catch regression issues. You know those issues where you accidentally break something that used to work when you add a new feature? Anything that was working and gets broken needs to be tested every time. Machines are good at doing rote repetition like that.

Battle Rhythm

When you sit down and start doing TDD, you’ll develop a battle rhythm. Most IDEs these days have support for running your unit tests without going through the whole build process. It’s pretty convenient, and it makes TDD a lot easier to do. So, you’ve got a new requirement and you need to get it working. It’s good to have a general idea of where you want to go, or how it is supposed to work, but don’t be a slave to that idea. We start writing the unit test at the easiest place it is to begin—whatever that may be. I personally find it is best to start with the happy path (the path where everything works as expected). For example, look at the pseudo code below:

Test String is a URL
--------------------
1. Use the string "http://bloritsch.d-haven.net" 
2. Pass the string to the String Utility "isURL" method
3. Assert the response is "true" 

Of course, it’s pretty easy to make this one test pass. All we have to do is write the StringUtility.isURL() method to simply return true. No evaluation or anything. When we run the test, we prove that solution is good enough for now. So we need to start thinking about the next test. What if the string is not a URL? So we add the next test:

Test String is NOT a URL
------------------------
1. Use the string "I'm not a URL, ignoramus!" 
2. Pass the string to the String Utility "isURL" method
3. Assert the response is "false" 

Now we’ve proven we have some work to do. So we change StringUtility to return true if the string starts with “http:”. It’s the simplest thing, right? But what if we want to include SSL encrypted URLs, or mailto URLs? So you add tests for them, and make them pass—without breaking the other tests you’ve written. All these tests are cumulative, so while they may have been extra work at the beginning, they can save your bacon later. Don’t forget about those corner cases, what if the string is null ? Etc.

By the time you are done with this method, you’ll have done the following things without even realizing it:

  • Documented what you consider a URL and what is not a URL (design documentation side-effect)
  • Proven the design works (design proof side-effect)
  • Tested the implementation (implementation proof)
  • Provided a safety net to do refactoring (supporting implementation malleability)

Sure the method is just a part of the overall design, but you’ve thought about and decided how the method is going to be used from the perspective of someone using the method. It’s a shift in thinking from being the “implementer” of the method, which usually results in code that is easier to use elsewhere. You’ve also introduced a level of trust in this method that you wouldn’t have if you just reached for that regular expression you found on the net to determine if something is a URL or not. You’ve also introduced a boundary where the implementation can be as simple or complex as you want—but code that uses the method won’t care about those details.

The battle rhythm of proving, fixing, and proving actually improves your development speed. It may not seem like it at first, but by testing all along the way we’ve minimized the time we will have to spend in a debugger. The ramp up time is a little slower, but as you get your battle rhythm going, you stay at a constant pace. Without TDD, I find myself working with bursts of productivity interrupted by long periods of finding out exactly where I went wrong in a debugger. With TDD, I find myself working at a steady pace, and those occasions where I missed something I spend much less time in the debugger. Once I’ve discovered the culprit, I add the test case that reproduces the error condition and then make it work. Now, when I refactor code I can make sure I don’t reintroduce the problem accidentally.

Silver Bullet?

There is no silver bullet, and no golden hammer that will make things work perfectly the first time. There are only tools that help you get closer to the ideal. TDD is a tool that helps improve quality from the start. Most detractors of TDD look at the claims of documenting your design as being false—or at least unreadable to normal people. I may give them that argument, but TDD isn’t about documenting design, it’s about building a better quality product with the minimal amount of investment. It’s about improving your productivity over the course of a software project. It’s about reducing the number of “doh!” bugs to virtually none saving your brain cells for the more complex problems. Finally, it’s about minimizing the risk involved in refactoring or even rewriting your software.

All of these benefits are things that the text books say are a good thing. It’s also done in a way that is less painful to developers. Writing documentation is a pain in the butt, however, writing test cases is something that directly benefits the developer. It benefits the project over it’s period of performance. Bottom line? More bang for the buck.