I Didn't Like Unit TestsLike many Developers, I suspect, I used to see Unit Tests as a necessary evil, something to tolerate at best or ignore at worst. I might force myself to produce 1 or 2 but I didn't really know why or how other than it seemed like a tick-box effort.
Then I Started Working On Another CodebaseFor quite a long time, I had either worked on very small parts of a system or written the codebase myself so Unit Tests didn't seem to offer much but then I started working on a legacy codebase pushing 10 years old and I started to appreciate the problem.
We would be attempting to rewrite a piece of functionality but this wasn't replacing one HtmlEncode with another one, that is fairly easy (isn't it) but I realised the risk involved of larger changes. What is this code supposed to do? Without existing unit tests, it is very hard to recreate the same functionality (or at least to highlight the differences which might or might not be important).
How do you write unit tests for nasty existing code other than writing something that goes green for good reasons or bad?!
Lots of Reasons for Writing Unit Tests
- Capture what your code is supposed to do now so that it someone either changes it or rewrites it, the changes are visible.
- Writing them for existing code can highlight hidden defects (I have found several that have laid dormant)
- They can ensure that code that will "obviously work" does actually work as expected. I wrote some code to use a Handlebars template with some "if" logic inside but it didn't work because it didn't consider the string "false" as falsy. I found this instantly with a Unit Test.
- It can help you ensure flaky code like manually building HTML templates ends up with valid HTML instead of waiting until deployment.
- It can reveal strange or unexpected additional behaviours such as the fact that we don't need to manually HTML encode content in handlbars templates because it happens automatically and doesn't encode when it doesn't need to.
- It ensures that upgrades to libraries etc. do not cause breaking changes (which we assume they don't!)
- It can highlight external changes that has fixed things when our code used to rely on the broken functionality - again so that we can decide whether we need it to still act broken or we need to change other code to cope with the fact that the external code still works.
- It gives us a warm fuzzy feeling that the automated tests have our back when we make changes and that we won't end up with completely random breakages in other areas (obviously they are not actually random but they can seem so, which is why we don't always forsee everything)
- It ensures that the logic in our head that we implement in code actually fulfils the correct functionality. This works because Unit tests should be coded in high level terms so that A and B should produce C even if the logic that is doing that is really complex.
- It provides simple checks that we have bracketing etc. in the correct places in our code so that logic combinations are correct.
- It allows us to spot nasty code architecture. For example, I had some code that I thought was creating self-contained HTML fragments but the unit test revealed that we were starting the fragment in another method ("...<thead>") then adding an invalid fragment in the tested method ("...</thead><tfoot>") and then finishing it elsewhere ("</tfoot>..."). Nasty!
- It can highlight overly complex parameter types, for example, passing an entire object when you only consume one property of it - you can identify this when it is hard work to create the types needed to call the method being tested
- We probably want one class per target class for the most simple cases but often a class per target method. The per-method classes can live inside a parent class that targets the class so they are grouped nicely.
- We ideally want to hit all methods in all classes (except private/protected ones which should be testable via public methods)
- We ideally need to test every combination of logic. If that makes the Unit tests difficult, it might imply we need to simplify the code we are testing. This could easily mean 10 tests for a single method but hopefully not many more.
- We need to use Mocks where possible to inject data but we need to consider that different values in the injected data can cause logic branches that need to be tested.
- We should extract common blocks of code into shared methods, into the test setup/teardown and if necessary, use features that allow us to inject large combinations of data into a single test which will avoid large bloated test classes.
- We should set a target coverage level and consider failing automated builds if the number decreases!