How to better write unit tests

Do you want to improve your understanding of unit tests and learn how to craft high quality unit tests systematically? Then you are in the right place. 

Today we are reviewing 4 best practices on writing unit tests, which will help you ensure you are ticking all the boxes to keep your code maintainable and robust for many years to come.

1 - Use descriptive test naming

Tests are created for two purposes: we run them to make sure our code does what it’s intended to do, and we read them to understand the application’s behavior. The latter is why we often hear of “tests as documentation”. But it’s easy to make tests worthless as documentation if we don’t use good test names and descriptions. As you refactor, review or collaborate on a piece of code, your capacity to quickly comprehend what behavior the test is verifying is key. Take a test file and check if merely reading the test suite names and descriptions gives you all the information you need to understand the function behavior. Yes? You can move to the next point. No? Then you might need some of the advice below. 

The most important information that a reader of a test should have is the behavior that is being tested. We can breakdown such behavior by thinking of the classic “Arrange”, “Act”, “Assert” structure of tests: 

  • Given xxxxxx
  • if I do xxxxxx
  • I expect xxxxxx

Where possible, this information should appear clearly stated in the test name or description, so that there is no ambiguity about what behavior of the function is under test.

This is an approach largely championed by Behavior Driven Development (BDD), and it has the primary objective of easily communicating requirements between technical and non-technical stakeholders.

Some languages and test frameworks (for instance Cucumber, Jest, Mocha, ….) have warmly embraced BDD, and lend themselves better than others to such an approach, by offering testing methods that reflect natural language (“describe”, “test”, “it”, “should”, …) and by forcing developers to frame test suites and test cases in a very organized structure. 

For instance, a test of this kind will appear self explanatory to anyone:

describe(‘Making a withdrawal’,()=>{
		it(‘Should fail if the account is empty’,()=>{
    //…
    });
});

Other languages, such as Java, don’t offer such explicit instruments for a verbose explanation of the test behavior. It becomes, then, necessary to create a synthetic but expressive test name which will do the same job.

Here are a few popular approaches, that can be tweaked depending on your needs:

ClassName_MethodName_Scenario_Expectation
ClassName_MethodName_Should_Expectation_When_Scenario
ClassName_MethodName_When_Scenario_Should_Expectation

2 - Make sure your unit tests are reliable

When we say that a unit test must be reliable, we mean that it should only fail when the function does not work, and that when it does pass it guarantees that the function is working properly. In order to achieve this level of confidence in your tests, you should be mindful on three areas:

  1. Lack of dependencies
    Developers too often think they are writing unit tests, but are actually writing integration tests disguised as unit tests. This is because they will include some dependencies (such as time, a database, an API, or a call to another local function), which can occasionally produce unexpected outputs or errors due to external factors (think of an API that reaches the maximum number of connections per minutes, for instance), and therefore affect the reliability of your test. Removing these dependencies through mocking greatly improves the trustworthiness of your test.
  2. The size of the tested unit
    Most of the time, a function or method is the appropriate scope size for your unit test, but you must always judge this case by case. Sometimes, choosing too small a unit with no sufficient amount of logic can lead to a test having no discernible impact on your code maintainability. Other times, functions are too complex to be tested by a unique unit test, and should be instead split into smaller units before approaching testing.
  3. The range of scenarios
    It is essential to make sure that the range of scenarios present in a test covers all behavior possibilities for a function. This is also called “use case coverage”, and it gives us confidence that every time the test passes, it means our function works as expected. It can sometimes result in slightly redundant test formulations, but it is important towards ensuring that all specification scenarios are accounted for.
    For instance, I could have the following three scenarios:
  • if the user age is equal or above 18, the user can access the website
  • if the user age is below 18, they see an error message
  • If the user age is below 18, they cannot access the website

The two bottom scenarios are similar, but are not identical. They test slightly different behaviors, and it’s a good idea to test the two scenarios one at a time.

3 - Don’t rely solely on coverage

Many companies use code coverage targets as their only north star to monitor their unit tests, and many developers who are not too obsessed with unit tests are pretty satisfied with this. But if you want to truly increase the quality and durability of your code, you should take a more qualitative approach, and consider code coverage as one of the indicators available to you, not as the only one.

100% code coverage (if you can get there!) does not mean that your code has no bugs. It indicates how many lines of your unit or codebase are covered, but it keeps you blind as to which test scenario you might have missed so far, and therefore which behaviors you forgot to test. This is especially true when the scenarios don’t strictly correspond to different branches or lines in your code. Because of this, we usually recommend developers to trust low code coverage as a good warning of an undertested codebase, but to distrust high code coverage as a signal of great test exhaustiveness.

Another great measure for the quality of your tests is the “mutation score”. The mutation score for a unit test is obtained by applying mutation testing, which involves slightly modifying the body of the function and running the tests again to see if they have correctly captured the change in behavior. If all tests still pass, it means they were not sufficiently exhaustive. If at least one test fails, the “mutant” has been caught and killed. Repeating this process for lots of different mutations in behavior can give real peace of mind on whether the unit test suite is reliable. 

You can learn more about mutation testing in this article, or read more broadly about different ways to measure test quality in this other article.

4 - Mock with caution

 

We discussed above the importance for a test to be independent - that is for the unit to be isolated from other units or dependencies. This is achieved through mocking.

Mocking allows us to cut out all dependencies from a function for the sake of our test, and therefore test it as an isolated unit, and as such it’s a formidably powerful tool. We should, however, be mindful and use mocking with caution. There are two main reasons for this:

  1. Good code is not heavily coupled
    By nature, good code should have as little coupling and dependencies as strictly necessary. This is what keeps it readable, maintainable, and robust. If we find ourselves having to mock 5 different methods inside the same function, this is probably an indication that our code is too complex and would benefit from a bit of refactoring. The best approach, then, rather than jumping straight to writing unit tests, would be to reconsider the design of the function and service in question to make our unit simpler by design.
  2. Mocking makes refactoring harder
    When we introduce a mock inside our test, it means that we are not simply testing the external behavior of the code (i.e. for input X I get output Y), but we also test the internal workings of the code itself, by stating which other modules are being used, what inputs are passed to them, how many times they are called, what they should return, and so on. This means that if at some point we want to change the design of our code and remove the dependency on a module, the whole test will have to be modified. In short, the cost of change becomes much greater.

For this reason, whenever you need to mock, think twice about whether there is any design simplification you can apply to your code, and consider whether all modules actually need to be mocked and to what extent. In some cases, for instance, you might find out that using a dummy rather than a full blown mock (stub and spy) is sufficient.

We have written more extensively about mocking in this article.

5 - Make your tests run fast

The Mocha testing framework states that any test taking more than 75ms to run is to be considered slow. This might seem extreme, but in practice slow tests jeopardize their own usefulness and effectiveness.

Developers tend not to run slow tests as often as they run quick ones, and consequently they reduce their capacity to spot bugs and regressions ahead of users. You would not want to reduce your confidence in your code because of a unit testing strategy that doesn’t not warns you over potential flaws, would you?

In order to keep your tests running fast, these are the three mantras to follow:

  • keep them simple
  • keep them independant
  • always mock external dependencies.

You might notice that these are all points that come back from previous sections of this article. Well - that’s because we are just joining the dots. A test’s speed is a nice way to verify whether the test is good or not. A slow test is often a symptom that something isn’t right.

If you follow all the good practices above, you should be on the right track to create foolproof unit tests. Keep in mind that it’s always a great idea to write your tests shortly after you write your code, as task shifting is an enemy of thoroughness. 

And if you have heard of test-driven development and are curious about it, read Everything you need to know about test driven development.

You can also look around for new unit testing tools that accelerate the production of unit tests or increase the exhaustiveness of your test scenarios. 

Our other articles to become a great unit tester:

• What is unit testing

9 Questions about unit testing answered

Also on the hub

Ready to write beautiful code?

Smart and simple unit testing assistant. Now available for free.

Try it now

Solutions for JS, TS, Java and Python

Lines Footer
Flexing Unicorn by Ponicode