Let’s Implement Efficient Unit Tests

7 advices to make your unit tests useful, optimized and maintainable.

Pierre G.
6 min readFeb 3, 2021
Photo by Chenyu Guan on Unsplash

Let me guess… You are a software engineer, and you are aware of the importance of automated tests. They allow you to sleep well and to be confident when you deploy your code or when you do a demo of your most recently developed features. They let anyone who modifies the code know whether they’ve broken something.

Implementing unit tests is a good starting point. Implementing them correctly is better.

This article deals with some guidelines to help you write valuable and efficient unit tests. It allows you to avoid debugging files of several hundreds of lines during hours. Files that look like spaghetti and over which you can’t control dependencies and responsibilities.
Remember that clean code is easy to understand and maintain.

The difference between unit tests and integration tests

Let’s start by defining unit tests and explaining the difference with integration tests.

Unit tests aim to assess logic within a single and isolated component (a class, a JavaScript module, or a React component, for instance).
They require great coverage and should take into account all sets of possibilities, i.e., all cases the component could have to deal with.

Figure 1 — Unit tests principle

Integration tests are responsible for checking whether all components of your service work well together. Are they compatible and well-glued to each other? They check whether components’ interfaces are consistent and suitable.

Figure 2 — Integration tests principle

Unit tests and integration tests are complementary and both important.
It is painful to get services whose components are not reliable.
It is painful, too, to get services whose components are reliable but do not work well together.

1. Start with a consistent design

You can only implement proper unit tests if the tested components are well-designed.
Here are some non-exhaustive points to enable a correct design:

  • implement small pieces that comply with the Single Responsibility Principle
  • use small interfaces with a few public methods (or exported functions)
  • use a few parameters within your methods/functions
  • components should not depend on more than a couple of other components

2. Make your tests independent

Unit tests shouldn’t depend on each other. This means that the end result should be the same regardless of the order in which you run them.
Testing libraries provide a helper that allows you to reset states before test execution.
Usually, these helpers are named beforeEach, afterEach, beforeAll, … They allow you to generate a fresh state before running the following unit test.

By decoupling unit tests, you can quickly identify where problems come from.

3. Isolate your component

Tested components should not depend on other things than mocks.
Why? Unit tests focus on testing only the logic contained within the tested component.
Enabling dependencies (internal or external) makes unit tests hard to debug — as problems might come from dependencies.
Unit tests are not just about ensuring complete coverage of the tested component logic. They should also allow you to easily and quickly identify the broken parts of the production code.
As described above, integration tests ensure your components are correctly glued together: they will do this part of the job!

4. Test outcomes, not intermediate tasks

Implement tests that help you, not tests that slow you down.
Do not test intermediate steps handled by private methods. Instead, test the result returned by public methods (or exported functions).
A well-designed component that complies with the Single Responsible Principle should be compact and enable a small interface. Testing the final results is supposed to be straightforward.

Figure 3 — Interface testing in the case of Class

Testing every private method and intermediate steps would make any refactoring painful. Many useless tests would fail if one single part of the code contained a bug.
Also, you would have to rewrite them for any minor modification within the private methods.
Testing interfaces comprehensively will ensure 100% coverage and avoid redundancies.

5. Test one single thing at a time and test it once

Complying with the Single Responsibility Principle for unit tests makes debugging them easy. Indeed, tiny scopes make it easy to identify the cause of any failure.

Let’s understand this principle through a basic example.
We want to test a function that takes as parameters the width and the height of any rectangle to calculate its area and perimeter.
The first approach consists of testing both returned values within one single test.

The second and more efficient approach consists of splitting the previous test in two:
- by creating a dedicated test to check the calculation of the area,
- and then creating a dedicated test to check the perimeter calculation.

If the first test fails, you know the problem is with the area calculation. If the second test fails, the problem arises from the perimeter calculation.

The example is intentionally simple. However, the underlying principle should be applied in any context.

6. Corner cases

Some developers are tempted to take into account expected values only. This is normal as being the most natural approach.
However, you should consider corner cases and unexpected values (as parameters): handle any possible values (even the most unlikely!). This best practice enables a robust code.
For instance, in Javascript, think about null, undefined, empty strings, negative values, and NaN … (BTW, I strongly recommend using Typescript to improve your code's robustness).

Generally, corner cases are those which take the most time to consider. You’ll see it in the next section. For example, two cases deal with expected parameters (one for positive integers and another for positive floating numbers).
However, several corner cases deal with unexpected values (e.g., undefined, negative numbers).

7. Make your unit tests DRY: use table-driven tests

Testing code should consider the same best practices as production code. DRY (Don’t Repeat Yourself) is one of them.
One great approach to enable it is called table-driven tests. It consists of building cases containing input parameters and, for each of them, expected output.

Let’s look at it using the example of the rectangle in JavaScript.
In this example, cases are not exhaustive. They are defined to illustrate the primary purpose of table-driven tests.
This approach allows you to mutualize the shared testing logic (instead of duplicating it among separate tests).

The it.each helper (from Jest, a javascript testing library) is nothing more than a loop that handles each case. This dynamic approach makes the code compact and maintainable. Adding new cases becomes straightforward.

Unit testing remains an unmissable piece of writing clean code. We have seen together some guidelines to implement them correctly and efficiently.
Thanks for reading!

--

--

Pierre G.

Software engineer with a passion for physics, meditation, history, mathematics and martial arts.