People who’ve known me since I first started programming would be surprised (maybe even shocked!) to learn how much of a TDD evangelist I became last year. I always had that notion of test code coming second – sort of like only an afterthought once the main feature was done. And I’m not alone with that notion: It turns out many of my colleagues think the same way. I now think it’s a shame that test code often gets treated as a second-class citizen. Read on to learn why I’m convinced TDD is not an option but a must for every software product that strives to reach some level of quality and maintainability.
TDD Helps With Understanding Requirements
We all know the situation: You have a brilliant idea of something you must add to your private project. Alternatively, a client has a fantastic idea they want to have implemented, and your boss thinks so, too! Regardless of the motivation, we’re often super-excited to get started. It’s about the thrill of solving a complex problem. It’s about advancing the program and making it do something useful. But be honest: How often do we really understand a requirement right away? How often do clients really know what they actually want? Did we think about the possible implications? About input formatting? Do clients know what should happen if an error occurs?
More often than not, we believe that we understand a requirement, but as we get programming, we notice that it starts falling apart. At first, we can patch it up a bit, but at some point, it becomes painfully evident that we actually haven’t considered the possible inputs and how they could affect the program output and potential errors.
Let’s be realistic: Even if a computer can only represent a finite set of values, testing all imaginable input combinations is impossible. Similarly, expecting anyone to define how the program should behave under all circumstances is equally unrealistic.
But here’s where TDD can help you tremendously with understanding the requirements. It’s just the practice of stopping for a moment and taking some time to think it through. The mere act of typing out test cases before programming gives you an unbiased outside view of things. You don’t even know the code yet. All you know is what you put into a module and what you expect it to return. This helps you identify potential culprits in the specifications you must consider or ask the client to resolve. I’ve found that writing tests trying to break the module are the most valuable since they highlight everything you probably wouldn’t have thought about if you had just written the code.
Software Testing Reduces Development Time and Maintenance Cost
There are numerous reasons why TDD helps reduce both the time needed to add new features and the effort required to perform maintenance and debugging. I’ve found that once you understand a new feature and how it’s affected by certain input classes, typing it out into production code almost degrades to a simple act of manual labor. Sure, you sometimes still have to think about more complex business logic. But that usually follows already well-defined rules, such as mathematical formulas.
The second most important reason is that TDD tremendously reduces the time spent on manual testing. You must remember that software features usually do not exist in isolation. Instead, the modules affect and influence each other, meaning you must manually re-test all affected parts after every change. This approach quickly becomes infeasible for any reasonably sized software product.
Finally, refactoring code without TDD is similar to navigating a minefield. Sure, you can be extra careful and only refactor tiny fragments using changes guaranteed not to break anything (like renaming methods). But every larger (seemingly) non-functional change will always come with a bit of an aftertaste that leaves you wondering whether anything broke.
Case Study: A Non-Functional Change That Isn’t One
Let’s look at an example that violates multiple clean code practices. This example is fictional, but I saw it happen like this in a work project. It’s not uncommon for code to degenerate over time, especially when multiple people always add a bit here and there without addressing technical debt. Anyway, the example:
/* Import statements & package */
public class SomeModule {
/* many other variables */
private int offset;
/* many other variables */
public int calculateOffset(int x, int y, int stride) {
int offset = 0;
/* Some calculations that affect offset (Block A) */
offset += /* ... */;
/* End of Block A */
/* Some operations that directly change offset (Block B) */
/* Some misc. offset checks */
return offset;
}
}
I slimmed down the example significantly to make it easier to read. Still, you have to imagine that the class-wide offset variable is hidden between many other ones that obscure its existence. It is additionally shadowed by the local offset in calculateOffset. The global variable might have served a purpose some time ago, but someone decided to move it into the function and forgot to remove the global variable.
Now, imagine that calculateOffset has 200 lines of code. You identify three logical blocks: Operation A, B, and other miscellaneous operations. You decide to split the function into three distinct ones:
/* Import statements & package */
public class SomeModule {
/* many other variables */
private int offset;
/* many other variables */
private int calculateA(int x, int y, int stride) {
/* Some calculations that affect offset (Block A) */
return /* ... */;
/* End of Block A */
}
private void calculateB(int x, int y, int stride) {
/* Some operations that directly change offset (Block B) */
}
public int calculateOffset(int x, int y, int stride) {
int offset = 0;
offset += calculateA(x, y, stride);
calculateB(x, y, stride);
/* Some misc. offset checks */
return offset;
}
}
You might have just missed it, been in a hurry since the feature needs to be released soon, the whole class might just be a mess, or you might have been distracted by a colleague or email notification. But regardless of the reason, you forgot to pass the offset into calculateB, which results in the function applying whatever adjustments it performs to the global variable instead of the one defined in calculateOffset. The compiler doesn’t complain (in fact, you may actually see one less warning about an unused global variable). You didn’t add anything to the code (surely, only moving code from one place to another doesn’t break anything), and running the program does not result in runtime errors.
You test the code manually, and the result looks plausible, so you continue to add your new feature or bug fix. But eventually, you run into problems (likely in some entirely different module). You start to wonder what might have caused the issues. Why does your fix not work? Why does the value of the variable not change? Sure, you can go through it with the debugger, but those bugs leave you scratching your head.
Automated tests that you can run after the refactoring changes immediately show you that the calculated offset no longer has the expected value. You would at least know that something went wrong during that step, which helps narrow down the culprit.
TDD Does NOT Guarantee Correct and Useful Software
As valuable as it is, test-driven development does not guarantee error-free software. You can only test so many combinations, and adding too many tests can quickly become a liability since test code must also be maintained over time. Therefore, striking a good balance is critical. As actionable advice, identify values representing error cases, edge cases, and valid inputs. Write tests for those representative values and verify that the system behaves according to the specifications.
For example, consider testing a custom division function. Representative test values should cover typical cases, edge cases, and error scenarios. Dividing by 1, such as 5 / 1, represents an edge case. The function should return the original number. Regular test values might include 10 / 2 to check for standard division behavior. An error case would involve testing division by 0, such as 5 / 0, which should trigger a specific error or exception, as division by zero is undefined. Invalid inputs include null, if applicable. Testing all possible input values and combinations is infeasible and unnecessary if you plan out the value classes.
Finally, it’s crucial to remember that tests ensure your code behaves according to the encoded specifications. However, your specifications may still be wrong, you may still test the wrong things, or the software might not be useful at all. Therefore, a program should not be assumed 100% correct because it passes all tests.
The Bottom Line
TDD is one of many tools in a good programmer’s toolbox. It may not work in every project, but it’s a fantastic framework that helps you keep a clear head and a focused view of the requirements. Additional tests may incur additional upfront costs, but the effort is well worth it, especially in the later stages of a software’s life (operation, maintenance, and evolution). However, it’s critical to remember that TDD – like every other technique – can’t guarantee correctness. Instead, testing is meant to ensure accurate implementation of the specifications.