Unit testing shouldn’t be hard. In fact, given its usefulness, it should be easy and pleasant—a tool that every developer would never want to do without.
And yet, I see programmers (and, more frighteningly, systems architects) struggling with it constantly. In many cases, they are saddled with large codebases that have never seen any tests whatsoever on one hand, and the fact that a recent changes has caused their entire system to crap out on the other.
The unfortunate thing is that most “experts” will tell you that you need to drop everything you’re doing right now and spend however long it takes to build a complete testing harness for your project.
These people, in my oh-so-humble opinion, live in a fairy-tale land in which deadlines are things that happen to other people and management is made entirely of sugar cookies.
Back in the real world, stopping development for weeks while you figure out how to add unit tests to cover your entire codebase is simply something that cannot be done (at least, not if you want to keep your job), no matter what future benefits it might bring.
The good news is, adding unit testing to your existing project only takes five minutes—which is pretty much how long it takes to get a unit testing framework installed. That’s it. Move on.
A test today is better than a thousand tests tomorrow
Still here? OK, read on.
Chances are that your application “mostly works” today. If it didn’t, you’d have much bigger problems to worry about than unit tests. Adding unit tests is not going to solve any problem because you don’t have any.
That’s fine; you just need to look at testing in a different light.
Where unit testing helps is in managing change. The job of unit testing is not to determine that your application works as advertised: that’s what your QA process—of which unit testing is part—is for.
Rather, unit testing is there to help you ensure that changes you introduce in your code do not have consequences that you did not intend.
Once you make this decision, adding unit tests becomes really easy—and very productive.
Fixing bugs
A typical example is correcting a deficiency in your code. Obviously, you can’t fix a bug until you’ve reproduced it. And, when you have figured out a way to consistently trigger the bug, you’ll have to do so multiple times as you try to figure out a solution to the underlying problem.
Curiously, that exactly one of the things that a unit test does well. Instead of triggering the bug manually, you can simply write a test that does so programmatically; in so doing, not only have you added a very important test to your code—you actually made reproducing the bug (something that you will probably need to do multiple times as you fix it) much easier.
Refactoring
Another place where writing unit tests is an easy part of the development process is refactoring existing code. The worst thing that can happen here is that a change you make breaks something else, and you don’t find that out until the code is in production. That, as they say, would be bad.
Since that’s something you (a) want to avoid and, therefore, (b) you will have to track anyway, writing a unit test is a great way to automate the process. More interestingly, it’s an easy way to write a meaningful test that validates how your application works as opposed to what your code does (more about that later).
Adding new functionality
This leaves with the scenario in which you’re writing completely new code.
Proponents of TDD and (to a certain extend) AOP will tell you that you should either write your tests before your code, or alongside it.
I know I will probably catch a lot of flak for this, but here’s my recommendation: do absolutely nothing.
Writing code is often a process of near-scientific discovery. The abject failure of the waterfall model should have taught us by now that the idea development and architecture are two completely separate phases is simply at odds with reality.
In the real world, we have to contend with imperfect specifications, unknown constraints, and the distinct possibility that, halfway through the development process, we find out out that we’re trying to solve the wrong problem.
As a result, I, personally, prefer to write code in an almost free-flowing manner, focusing on finding the best solution to the problem at hand. Throwing testing in the mix, while definitely a good idea in principle, just adds one more level of complexity to an already overly complex task in practice.
This doesn’t mean, however, that tests are out of the picture.
If you think of unit tests as a tool to manage change, in this particular scenario their job is to ensure that the change you’ve made (adding new code) works the way you mean it to. In other words, once your code is completed and working the way you think it should, you can write a test that exercises it to “solidify” it as a future constraint.
The best time to do so is when you commit your code to HEAD. That’s because committing to head indicates specifically that the code is complete and its behaviour should therefore be invariant from that point forward.
Better yet, if you work in a team with more than one person, you can simply make writing tests part of the code review process—when you push your branch for review, whoever reviews it also write the tests. In addition to spreading the workload, this ensures that another person validates the principles under which you built the software, increasing the likelihood that they will poke holes in your programming and reduce the incidence of defects.
Writing meaningful tests
Writing tests is pointless if they don’t actually help you manage change. The question, then, is: how do you write meaningful tests?
That’s a complex topic, but the best thing you can do is to think of unit tests in relation to software design. (Or, you can grab the slides from last year’s Codeworks Tour, where my colleague Keith Casey made a wonderful presentation on this topic.)
In architectural terms, software is a kind of mapping between visible affordances (what your application can and must do) and constraints (the ways in which it must be limited so that things are done properly). Any kind of QA that you perform on your software (including unit testing) must be built around the concept of validating your code as a whole, even when you are only exercising a small portion thereof.
A good test, therefore, is not concerned with replicating all the possible sets of inputs and output of a particular piece of code; it’s only concerned with replicating those sets of inputs and outputs that can happen during its intended usage.
The difference is critical, because in the first case you’re exercising the code in isolation from its surroundings, while in the second you’re exercising it based on the role it’s meant to play in the final product.
Of course, this still means that you need to test all the realistic inputs—including edge cases and input that is purposely incorrect or malicious in nature. And that’s why it’s often better to have another person write your tests, since they will be able to look at your code in a more detached way.
What about code coverage?
It should be obvious by now that complete code coverage is not a useful goal of unit testing. Instead, it should be a consequence of a good testing strategy.
Again, the difference is substantial. If you’re trying to achieve code coverage for the sake of it, anything less than 100 percent means that you need to write more tests.
If, on the other hand, 100 percent should be the consequence of a good testing strategy, situations where it is not achieved could simply indicate that you’ve got dead code branches that need to be pruned. Writing more tests then becomes the next step in the process.
Testing is a continuous process
All these things will help you get started with unit testing, but writing good, meaningful tests remains a hard thing to do.
This doesn’t mean that you should be discouraged, or that you should give up. Just think about testing as a process of continuous improvement. An imperfect test is better than no test at all—as long as you understand the limitations of your testing strategy and strive to make it better over time.
Some offbeat reading
You’ll find articles and books on how to write good unit tests all over the place. Most of them are bad, many are well-intentioned, and very few actually explain why you should write unit tests in a particular way.
Rather than give you yet another list of those, allow me to suggest three books that are not on unit tests at all, but, instead, on the psychology of design, which is, in turn, a great tool to help you understand how to write tests that are meaningful.
The Design of Design is a great read from Frederick Brooks (suggested to me by Joël Perras) on the process of designing software.
The Design of Everyday Things by Donald Norman (one half of Nielsen Norman) is an excellent exposition on the psychology behind design.
Both these books should help you understand what to test for in relation to how a software application works. Rather than focusing on the mechanics of your code, building tests around its intended functional specs will yield much better results (and help your sanity).
Finally, you may want to read Apollo by Charles Murray and Catherine Bly Cox. This book has little in the way of technical content, but shows how the Apollo program was almost destroyed by an insistence on disconnecting testing from development, until George Mueller told engineers to suck it up and test the whole thing as one unit.