If you were to take a poll of software development shops and ask whether or not they unit tested, you’d get varied responses. Some would heartily say that they are, and some would sheepishly say that they totally mean to get around to that next year and that they’ve totally been looking into it. In the middle, you’d get a whole lot of responses that amounted to, “it’s complicated.”
In my travels as a consultant, I witness the reason for this firsthand. The adoption rate of automated testing has increased dramatically in the last decade, and that increased adoption means that a lot of shops are taking the plunge. And naturally, this means that a lot of shops with a lot of legacy code and awkward constructs in their codebases are taking the plunge, which leads to interesting, complicated results.
“It’s complicated” generally involves variants of “we tried but it wasn’t for us” and “we do it when we can, but the switch hasn’t flipped yet.” And, at the root of all of these variants lies a truth that’s difficult to own up to when talking about your group – “we’re having trouble getting any good at this.”
If this describes you or folks you know, take heart, though. The “Intro to TDD” and “NUnit 101” guides make it look really, really easy. But those sources of learning usually show you how to write unit tests for things like “in-memory calculator,” intending to simplify the domain and code so that you understand the mechanics of a unit test. But, in doing this, they paint a deceptive picture of how easy covering your code with tests should be.
If you’ve been writing code for years with nary a thought to testing at the unit level, it’s likely that familiar, comfortable coding practices of yours are proving to be false friends. In other words, your codebase is probably littered with things that are actively making your life extremely difficult as you try to adopt automated testing. What follows are some of the most common ones that I see.
When you’re writing unit tests, there’s a pretty simple and minimalist pattern. You instantiate an object, arrange it for the conditions you want to test, do the thing you’re testing, and then verify that the result is what you expected. Busy constructors threaten to trip you up right out of the gate, on thing one.
If the constructor is executing many lines of code, that means many lines of code that can fail. Are you passing the wrong argument to it? Is something inside of one of the objects that you’re passing to it not setup correctly? Is the constructor instantiating something that’s blowing up? Is it expecting a global variable to have a value that it doesn’t?
Any of these problems results in a unit test that blows up and requires debugging. This is not only frustrating but wholly confusing when you’re trying to figure out the particulars of testing in the first place. Your busy constructors are testing headaches.
Speaking of testing headaches, global state is a huge source of testing problems. Global variables (public static variables that can be mutated) are the most overt example of this and one that I mentioned in the last section, but there are other forms as well. The singleton design pattern and service locator patterns are basically global variable repositories and static methods that encapsulate state have the same effect as well.
The main problem with a global state, from a testability perspective, is that it creates hidden dependencies that will not be obvious to you. If you’re going to write tests for something called “CustomerOrder” that has a parameter-less constructor, you might want to instantiate it and then assert that it has a single line item after you add a line item to it.
Imagine your surprise if, when you’re instantiating it, you get an exception telling you that you have a bad connection string. Oh, well, that’s because the order class refers to a database singleton that, as part of its initialization, reaches out and connects to the database using something defined in an app config file and stored in a global variable. Oops. Good luck setting all of that up for a unit test.
Another pattern I see that correlates with hard-to-test codebases is an affinity for lazy loading. I understand the attraction of this pattern, as someone who can appreciate a good abstraction. You get the best of two worlds: not incurring a performance hit before it's absolutely necessary and not burdening clients of your code with the implementation details.
But on the flip side lies a problem. Hiding those details from clients means also hiding them from people trying to test the code – people to whom “is this going to take a millisecond or 5 minutes” matters a great deal. Lazy loading is typically reserved for operations that take a lot of time, and operations that take a lot of time typically do so because they do things like talk to databases, access files, or call out to web services. People trying to test your code are now faced with the conundrum of “when I run this code from my unit test, it will either behave normally or it will try to talk to a database somewhere, and I’m not really sure which.”
This type of thing fares poorly in unit tests. So if you’re trying to test code that makes use of lazy-loaded constructs, there’s a pretty good chance you’ll wind up banging your head against your desk.
External Access In-Situ
The last source of difficulty that I’ll mention is what I think of as “in-situ” access to things outside of your application’s space in memory. This might mean reading from files, talking to a database, getting input from a driver, etc. In applications that lend themselves well to testability, these types of activities are localized to specific places at the edge of your application to minimize dependence on them.
In hard-to-test codebases, however, they seem to just kind of happen wherever they’re needed. Need to know what a config setting is in the InvoicePeparer class? Well, just read it in right there from the config file.
While that may seem innocuous, you’ve murdered the testability of the method in question. Before you put that in, testing that bit of the logic would be no problem. But now, your unit test suite (and the one on the server) depends on some file existing in some specific place on the disk in order to have any chance of passing. Now you’ll wind up with a test that fails all the time or sporadically, and both of those create frustration and lead to deleted unit tests and abandonment of the effort.
Make It Easy on Yourself
Starting to unit test is hard. It means figuring out a new skill, obviously, but what fewer people realize is that it tends to mean starting to reason differently about your code. That’s a lot on your plate already, so it’s important to understand when you’re making life hard for yourself. And, if you’re doing the things that I mentioned, you’re making life hard on yourself.
This doesn’t mean that you have to change all of your practices or go on a massive re-work effort in your codebase. That’s not reasonable in the face of real delivery pressure. It just means that you should pick your battles, particularly in the beginning. Test things that are actually testable, and you’ll save yourself considerable heartburn.