Unit tests had a bad reputation in many teams I worked with. To my confusion I even experienced that a team wrote a hell lot of integration tests but rarely any unit tests. This is contrary to the well-known testing triangle, and surprised me quite a bit. The reason - as I was told - was the experience of the team. “When you change something you have to adapt many unit tests”, was the common tenor. So they decided to write integration tests which call the real endpoints instead. This comes with a cost. Generally you have a harder time to identify where a issue lies when an integration test - which goes through the whole system - fails. Also the runtime will not lead to fast feedback - one of the main benefits of having proper unit tests. I discovered that the issue is that many developers never learned how to write proper unit tests. In this post I will cover the best practices that I developed throughout my career.
Mock (almost) everything
A unit test - by definition - tests only, and exclusively, the unit under test. That means you only focus on the logic inside the unit you test. In object oriented languages a unit translates to an object, so you only test what’s going on in this very object. Everything else called inside of the function under test should not be covered and get their own tests instead. So - in order to be independent of changes in the called methods - you should mock every dependency of the function under test.
This is also true for data objects. As soon as you mock them away you make the test independent of changes of other classes. E.g. when you add another field to an object that is irrelevant for the test you don’t need to adjust the constructor call.
Write atomic tests
Every test should be independent of any outside state. You should avoid building large test suites to mock away all objects that you might need and instead only mock very specifically what’s needed for the test. This will give you more flexibility since changes in the test suite won’t produce complex side effects and your focus remains.
Use the given-when-then form
When the maintenance of your unit tests begin to be a burden you’ve failed to write proper ones. The goal of unit tests after all is helping your team in development by saving time, be it because of fast detection of malfunction or by assisting in refactorings by defining the contract of a method. So it’s crucial that you can understand what a test is doing in a blink. So having a common structure of every test will help the reader to understand it. I went best with the given-when-then form. First you define the state of the unit and parameters. So you define parameters and set return values of mocked dependencies. In the when part you call the method you want to test and assign the return value and in the then section you test the result against the contract of the method.
Use proper signatures
When a test fails, the most important information is why it failed. So the best case would be that a developer can understand what’s wrong by just reading the name of the failed test. To make this as efficient as possible you should consider writing the method names in the form of:
methodName_conditions_expectedResult. You will experience many cases where you just see the method name and just know what’s’ wrong.
Cover the boundaries
By boundaries, I mean values for the parameters that you give in the function under test that are at the edge of what the method accepts. For instance, you test what the result of
null is. Besides, of the boundaries you should also cover a “normal” case as well as cases where the method should fail. When your method has side effects in your application you need to cover these as well, but consider writing an integration test for that.
Don’t write too many tests - focus on the essentials
Even though I just advocated to cover the boundaries of a method you should still consider that more tests mean more maintainance effort potentially. So when you have the rule in your whole application that you will never ever pass null values around. Then it wouldn’t increase the stability when you write tests for that, but only create maintenance effort. Instead, you should always prefer to cover the path that you know will be passed.