1 How to Make Unit Testing not so much of a pain

1.1 Unit Testing

I find myself talking a lot about unit testing in my current workplace. Over time I've learned things from others as well as developed some habits that have served me well. I've gotten applications up to 100% test coverage without Herculean effort. Granted, test coverage is not the same as test quality, it's still a useful metric. I've demonstrated that there is at least one case in which my code can work.

1.2 Primer

As a quick intro: Unit tests are tiny little bits of code that aren't part of your implementation code, but exercise your implementation code. You have a suite of micro applications that run together to ensure certain behaviors in your application are doing things they should still be doing. Instead of having a throwaway application that tests the one thing you were working on, think about keeping it. That's basically a unit test. A test harness that you save and run together with a bunch of others just like it.

1.3 Dress for Success

Unit testing is hard for a lot of people. I believe a vast majority of the difficulty is self inflicted though. I've put together a list of things that are very helpful with testing. It's by no means exhaustive, and this isn't a religion to adopt. I find reasonable exceptions to these rules - but they do remain as exceptions.

1.3.1 Do not DRY your tests

As I see more and more people doing things like using loops to create tests and factoring out swathes of code just to make tests a little more terse, I find the tests get complex enough that I'm about to demand going to demand unit tests for the unit tests. Unit tests should be incredibly simple. Sure, helpers are useful. Especially with Angular's ceremony. That said, each test should be a little different. It should assert something else. It should provide a unique piece of data. If you're doing TDD (failing test -> passing test), you'll find that you pivot on every point in your tests. It's ok for unit tests to be WET!

1.3.2 Test Driven Development

Test Driven Development, or TDD, is a powerful means of having good unit tests in your code. The basic trick to pulling off high test coverage (getting 100% isn't as impossible as it sounds) is to make sure you always start with a failing test. Want to change something? Make a test that asserts something that currently is not true. Now write implementation code to make it pass. The sad part about TDD is it requires this discipline. Sometimes you'll want to explore and writing tests first don't make sense because you don't even know what to assert in the first place. That said, if you're transforming data (where a lot of your efforts should be), or doing general webby stuff that isn't that mysterious, you really should be able to write some tests. If it helps, get another programmer to pair with and have them write some tests with you.

1.3.3 Keep your tests

Another trick to making TDD shine: Don't delete tests as you put the code together. Each new test refines the old behavior, but the old truths you assert are still the same.

1.3.4 Assert a single truth per test

If you can, and you should be able to do this a lot, make a single assertion of truth per test. This means expect or assert once per test. If you want to assert more things, write more tests. They might even be mostly duplicates. There's benefit to doing this though. Each test becomes a small bit of living documentation. When the tests fail due to a change, the engineer can go look at the test and see the expectation. There should be a firm, human language description of what the tests is doing. "It returns an empty list when given an empty string". We aren't writing poetry here, so it's ok if it's dull. You'll thank yourself months later when you've forgotten everything you've worked on and the unit test stands as guardian to the old behavior. Even if you decide to sweep away that test, you'll at least be doing so as an informed decision. Someone else might pick up your work, and they don't have the knowledge you have accumulated and now take for granted. When you make multiple assertions per test, the truths of your work are lost.

1.3.5 Clearly state your intent

The "it renders" and "it works" kinds of tests are comically bad descriptors for a test. Better tests look like "shows a list of users ordered by last name". Your test should clearly state its intent of what it's trying to test. When a test shows a failure, you as the human must go in and determine one of these two outcomes:

  1. The test is wrong due to new changes, and must be updated.
  2. The test is correct, and your implementation needs an update to accommodate prior, expected behavior.

The only way to truly determine this is to have a clearly written test that briefly describes the scenario of the test and the expected outcome.

As a side note, I strongly prefer to omit the "should" kind of verbiage because:

  1. 80 columns wide is the best. Don't make me lug a monitor to this coffee shop so I can read your damn test descriptor. "should " costs you ~6 precious characters of width, while adding absolutely nothing to your test.
  2. "Should" is a "weasel word", meaning contributes to an ambiguous statement. If your test doesn't pass, then it "should"…? How's about we state clearly that our test does a thing, and anything else is considered an error/failure condition? Avoiding weasel words can be scary, because it requires a little bit of confidence or gal. That's okay! It's okay to make a statement and then be proven wrong! But making statements that are never wrong is a defensive strategy you don't need when all of us just want to find a way that works (arguably the "right way", if such a thing exists).

1.3.6 Avoid blob tests

This ties heavily in with asserting a single truth. Another way of asserting a single truth is to avoid the "Given this input I get this slight change in this giant output" kind of tests. The look like tests where inputs are provided, and you get a big blob of JSON or something similar. The test then asserts that the entire JSON structure matches the output. This makes the test hard to follow. What bits are important? Why are they important? Most imporantly: What's relevant to this single truth being asserted? Blob tests make this very difficult to determine. After you write a bunch of tiny little tests, you could make an "All together now" test that does a quick sanity check: Do all of these things I just made work in concert together? That's completely reasonable, but don't go making 20 more of them. They are the exception, not the rule. A pattern I'll sometimes use as a micro-blob test is perform some operation on a collection of things. Do a quick map to pluck out some data I'm interested in (almost always a single field), and then assert that the list I got back from map is some list I provide. The list is small, and usually the test will describe its assertion in terms of something that makes sense for a list. "It returns a list of users alphabetically sorted by first name ascending" is a perfect example of this.

1.3.7 Use acceptance tests instead

This might sound like a trolling statement, but seriously, if your organization is keen to it - don't write them. Not for applications. Your tests should only exercise through the application's normal interface. In the case of libraries, it's the API you want to test. If it's a web application, then interface through the browser. With a restful service you can use curl for most of it, or similar tooling. These "full stack", "integration", or "acceptance" tests provide immense value. They test that your software does what it says on the tin.

Consider writing a calculator application. Somewhere in there, I might have a plus function for adding two registers together. I can exercise this with unit tests until my boss pulls me aside for having a two thousand line file for testing a single function that is built into most languages. But it still might not work. That's because a calculator application isn't consumed via its plus function. There are loads of pieces often involved in our software to make it do something that seems very simple to an end user. It's better to make sure all of that is connected without having to manually dissect each part of your application and discreetly test one piece of it at a time, and hope you caught all of the integration points.

1.3.8 Or use an algebraic type system instead

Also consider that you should be using the computer and smart people who have come before you to have figured some of your work out for you already. Having an algebraic type system without binding yourself to sequential mutations (I'm looking at you, Object Oriented) means you can eliminate vast classes of unit tests you might write. Can this be null? No? Then you never have to check for it! Yes? Well then you must check for it. The type system won't let you get away with "Well I just won't pass it null"). Your type system can cover 90% of your unit testing needs. The other 10% are handled much better by your acceptance tests.

1.3.9 Admissions

Admittedly, we may not have as much control over our ecosystem as we would like. Perhaps you inherited a suite of unit tests. Nudging towards some kind of acceptance tests would be stellar, along with integrating a type system. Sometimes we must simply make do.

1.4 Conclusion

There's more we could into here, but perfect is the enemy of good, and I need to cut it off at some point. Unit tests can be very handy, make think about your code before you just start typing stuff, serve as excellent test harnesses to verify changes, and can serve as living documentation to help prevent mistakes when you start making changes in your application. Hopefully this helps clear some hurdles on how to make the process easier and therefore more attainable.