I’ve been spending a lot of time lately thinking about unit tests. There’s a divide that exists between developers who champion their use, and those who are skeptical. I fall firmly into the former camp, and while I’m trying to understand why the other camp is as large as I perceive it to be, I also might understand why.
A portion of the skeptics have actually tried it, but had a poor experience. The tests they wrote broke often. The tests also were long and complex, and when they broke, they took a long time to repair (or were simply deleted). This experience is familiar, and mine was no exception.
As soon as I thought I understood the value proposition, I immediately started to write tests against an existing codebase because I wanted what the tests promised. (First mistake.) I installed NUnit, created some test fixtures, compiled … I was off to the races! From there, the intuitive thing to do was to mimic the path of execution I expected a given class to take. (Second mistake.) Does that sound like an integration or service-level test? You bet, but everything compiled and all of the tests passed, so I had no reason to believe I was doing anything wrong.
I understood that there was to be an element of isolation to these tests, and the classes I was testing had multiple dependencies and cross-cutting statics. TypeMock to the rescue! (Third mistake.) Writing mocks sucked the life out of me. Writing mocks is, I just, ugh. Graphing out the order of each method call and hardcoding each return value on an external module is one of the least exciting things one can do with a computer.
A couple of weeks later, I had a small suite of passing “unit” tests! It felt good. Until they started to break.
As soon as I refactored code, tests broke. I could understand if it was a class that I had written tests for, but the mocks? Changes to the object I was mocking also broke the mocks — go figure! Ugh, and they were such a pain to write. This wasn’t supposed to happen. Both grokking the broken tests (and their mocks) and fixing them was no fun. Tests were supposed to encourage refactoring, not discourage it. Test maintenance was becoming expensive and rapidly not providing any tangible benefit. As more tests broke, more tests were deleted.
I took a break from unit tests. But, you couldn’t read a blog or listen to a podcast without someone plugging Test-driven development (TDD) and all of its greatness. Despite my skepticism, it was hard to ignore that there was something different about my experience versus TDD: when the tests are written.
With TDD, you write tests while you’re writing your code. (Purists would have you writing the tests first, but I’m still not there yet. Baby steps.) Determined not to miss out on something great, I gave it another go, but this time with a feature that hadn’t yet been written. I established a cadence of writing a small piece of code (e.g., a method) and then writing tests for it. I discovered pretty quickly that my tests were influencing my architecture in positive ways. For example, I started parameterizing context. If my production method needed to know the current time, I’d pass it in as a parameter versus making a hardcoded call to DateTime.Now. Then, my unit test could provide a fixed context. Before I knew it I was using full-blown dependency injection to parameterize context. Not only was this better architecture, but the tests were so much easier to write. (And, no mocks ftw!)
This second pass at unit testing was a far more rewarding experience. In addition to tests taking less time to write and having a positive architectural influence, they were also less brittle. Since this experience, I’ve unit tested nearly every new chunk of code I’ve written, with zero regret.
That’s all well and good for new stuff, but how about that legacy code — the stuff that many newcomers are tempted to write tests for? My first attempt at this was an epic failure. I haven’t given up, but clearly it is something that takes more work than just authoring a few test fixtures. And I won’t attempt it again until after I’ve read Working Effectively with Legacy Code, because I understand that Michael Feathers imparts lots of wisdom in this space. (It’s on my short list, I’m hoping to absorb it in the coming weeks.)
Having gone through the exercise of journaling my past experience with unit testing, I suppose I can understand some of the skepticism among those who’ve tried it. But, when the movement first began, there was a lot less guidance available. It’s also important to understand that a unit testing framework is just like any other tool in that it can be used in any number of ways, including ways in which it was never intended.