christian-thompson.net

My ideas about Agile, DevOps, and software development in general

An Ode to Unit Tests

Unit tests are awesome. Anyone who talks to me about software development eventually gets to hear me rant about how much I love them. The funny thing is I was a late adopter – my first foray into writing unit tests ended badly. At the time I was building a multi-threaded event processor and thought I would give it a shot. I wrote some crazy complex tests that were fragile and broke often. I found exactly one regression with them, but probably fixed 30 or 40 false positives. In my mind at the time, this confirmed some of the posts I had seen about unit tests being a waste of time. I deleted them and moved on.

Thankfully a year or so later, I ran into a team that was using them successfully. The team was full of smart developers so I decided to reevaluate – maybe it wasn’t the unit tests after all, maybe it was me. I tried again and started having some success, but no big lightbulb moments. That came a few months later on a form generation project where I had written a bunch of tests to prevent specific regressions on individual forms. It turned out finding regressions on the forms wasn’t that helpful, but holy cow, having all those tests in place was a game changer when I needed to make changes to the core framework! I ended up making at least 3 massive overhauls to that framework without a single bug slipping out to production. After each of those changes, I’d have a few hundred broken tests that boiled down to 3 or 4 root causes. I’d fix those, everything would turn green, and life was good.

After that I was sold and unit testing became a key part of my development process. It’s a joy to work on a well-tested codebase and make changes without worrying about breaking things. The more I use unit tests, the more benefits I find. There’s the obvious benefit of preventing regressions, but there are so many more:

  • Unit tests create short feedback loops – they let you know very quickly if you break something. It’s a whole lot easier and cheaper to fix breakages in the moment than it is days or weeks later when QA or a production user finds the issue.
  • Unit tests make it harder to write poorly structured, hard to maintain code. Writing SOLID code is great, but often the advantages of doing so don’t start revealing themselves until much later when it needs to change. When you are in the moment writing code, even a jumble of spaghetti makes sense because it’s all fresh in your mind. But it’s a different story months or years later when you need to change the jumble without breaking things… If you unit test right from the start, all the same things that make it hard to change 3 weeks from now make it hard to test right now (hmm… another shortened feedback loop). Some examples:
    • Big, multi-responsibility methods usually require big, complex tests. And you need a lot of them to test the big method.
    • Big, multi-responsibility classes bury complex logic in private methods 3 or 4 hops down the call stack, requiring even more of those big, complex tests to get good coverage.
    • External dependencies instantiated in the spaghetti make it untestable without refactoring.
  • Unit tests make it really easy and safe to refactor code. Even the best designed code falls victim to the law of entropy and gets sloppy if we update without regular refactoring. And refactoring complex code without thorough test coverage is almost guaranteed to break stuff.
  • They serve as documentation. Reviewing unit tests is a great way to learn what the code does and what assumptions the original author made. They also offer a convenient and fast way to step through a block of code without having to hit the user interface.
  • Writing unit tests against existing code is a great way to learn how it works.
  • Unit tests make it very easy to test edge cases. Setting up edge case tests from the UI can be a painful process (and sometimes is virtually impossible), but it’s a snap to do from a unit test.
  • Unit tests make it possible to cover every path through a codebase with automated tests. The number of paths through functionality grows exponentially when you test multiple methods as a group. That makes full coverage of all paths impossible with UI and API integration tests in almost all cases. But it’s totally doable to test every path in each method when tested individually.
  • They speed up the code, compile, test cycle – especially on big code bases where compilation, startup, and navigation can take several minutes or more. Keep repeating that and you quickly eat up a day without getting much done. Unit tests skip a lot of this so make this cycle a lot faster.
  • They speed up the development process in general. Anyone who has worked on legacy code has probably had the experience of spending 2 days researching a bug only to end up making a 2 line change to fix it. Unit tests encode all that research so you don’t have to do it every time.

I’m sure I’ve missed a few, but you get the point. If you aren’t using unit tests, please give them a try. And please stick with it because there is an art to making them work well. I promise you (and your users) won’t regret it.