You have decided that it is time to adopt leaner development practices by bringing more of your testing to the left and you are looking for advice on the best practices to follow when testing your code? In this article, find some insights into how to best approach unit testing in order to make sure that your effort is worthwhile.
Unit testing is the action of verifying that each simple part of code that can be checked independently from one another works as expected in order to increase the confidence that once shipped to production the whole code works seamlessly. It also enables, when the application crashes, to quickly identify which unit of code could be responsible for the issue. Tests can be runned everytime you make modifications to the codebase to verify that these new additions do not create unexpected flaws.
The idea behind unit testing is that a complex piece of software can be broken down into many simpler parts – each testable independently one from the other. Verifying that each of these parts – each unit – works as expected can give us confidence that the whole application does too. In most situations, the smallest testable portion of an application is a function – therefore unit testing effectively takes the shape of functions testing the correct behavior of other functions.
This can be rather confusing, since the label “functional tests” defines a very different type of software testing altogether: it refers to tests that set out to validate the behavior of an entire application against some specification.
In between unit tests and functional tests we find integration tests, which are used to check that multiple modules or components of a system work together as expected.
The key benefit is clearly higher software quality in the form of less production bugs, which stems from being able to catch errors earlier on in the development process. Unit tests may be individually narrow in scope, but they allow us to cover a very wide range of cases and scenarios for each function. This means that a codebase where all functions are unit tested is much less likely to have bugs than an untested one.
A well unit tested codebase is also an excellent way to ensure non-regression – meaning that existing functionality won’t risk being broken or compromised by the implementation of some new feature. Should this happen, the test runner will flag it immediately leading to a prompt fix. This is incredibly helpful when working on existing unfamiliar code, in that it removes part of the burden from the shoulder of the developer and gives them confidence that they can push their code to production.
Finally, the process itself of writing a unit test is an excellent way to force yourself to review and think about your code with a critical eye – often leading to finding implementation errors or encouraging you to start that much needed refactoring that you had been postponing for a while.
Unit testing guidelines demand that a good unit test should be independent, repeatable, readable, exhaustive, realistic, maintainable, fast and easy.
These are the best practices you want to keep in mind in order to write effective unit tests and make sure the time invested is truly useful.
1 – Independent
The behavior of each unit of code should be tested in isolation from other parts of the codebase. This means that their outcome should not be verifying nor be dependent on the behavior of databases, external libraries, web resources, file system, etc. Essentially, a unit test should not have side effects – be it a database modification, file deletion or card payment through api.
The key to being able to isolate a component is mocking – which allows to simulate external function calls with the double advantage of controlling their return value and avoiding unwanted side effects.
In order to have confidence in the correct behavior of an application, it is key that all unit tests return the same result every single time. Factors such as moment of execution, running order or local environment setup should have no influence on the outcome of a test suite. If the function itself is not deterministic – for example if it uses datetime of execution, mocking can be used to force it into deterministic behavior and ensure it can be unit tested.
When a test fails, it needs to be easy to identify the point of failure. This is why unit test structure is important: tests should be well organized in suites and accompanied by a brief description of the scenario at hand. The best way to make it easy to read a test report and fix failed tests is having one assertion per test.
A common mistake is stopping at testing only the “happy path” of a piece of code – the behavior that occurs most often. The scenarios that are most likely to malfunction, however, are very often those less likely to happen – be it because they are harder to reproduce in the development environment, or because they weren’t kept in mind at the moment of coding. Unlikely scenarios and edge cases are essential to test the limits of your code and give you peace of mind about its robustness.
To come up with an exhaustive range of scenarios, it can be useful to think less about the code you are testing and more about the use cases it supports. Still, at the Developer Experience team we know it’s sometimes tricky to come up with the right scenarios. It’s what we have tried to do with the artificial intelligence suggesting happy paths and edge cases contained in our Unit Test extension for VS Code. We designed it to make sure that you never leave a path untested.
The more a test resembles the way your application is used in real life, the more confidence it can give you in the fact that your code will work correctly. For this reason, it’s important not to overly simplify the variables in your test scenarios. A great approach to achieve this is using some of the actual inputs that your function is called with when your application is running – and tools like our Unit Test extension for VS Code can help do this without any copy and paste.
A realistic unit test is also great documentation for anyone to understand at a glance where and why a function is used.
Broadly speaking, there are two types of assertions that can be done in a unit test: state-based and interaction-based. The former checks that the function produces the correct result (intention); the latter that the function properly invokes certain methods (implementation).
Testing implementation details can be tempting, but it doesn’t give you much confidence that the function is producing the expected outcome, and requires that you change your test every time you refactor your code. This should never be the case: a test that fails when there is no bug is a bad test. To ensure the easy maintainability of your tests, focus instead on checking that the function’s behavior reflects its intention, and that the outcome is the expected one.
Most developers are impatient. If unit tests are too slow to run, they are more likely to skip running them on their own machine and push their code directly to the CI, which means bugs will take longer to be identified.
What’s more, slow tests usually indicate a dependency in some external system – therefore contravening the principle of independence.
It may seem strange to demand that a test be “easy”, but the reality is that a difficult unit test is often a symptom of an underlying problem – usually poor code design in the code that needs to be tested. Following the Single Responsibility Principle in writing your code is a great way to make sure writing unit tests doesn’t become a pain.
Unfortunately, easy doesn’t necessarily mean fast to write – and good, exhaustive unit tests can easily take as much time (if not more) to code than the functionality itself. Relying on unit testing tools such as our Unit Test VS Code Extension can help increase efficiency and free more time for the more creative part of software development.
If you have written a unit test for every function in your file, with an exhaustive and well thought list of scenarios, you would expect that every line of your source code is executed at least once by your tests. But how do you verify if this is the case?
Code coverage comes in your help, with an indicator that shows you what percentage of your code is executed by your tests at least one time.
100% code coverage is practically impossible to achieve, and should not necessarily be a goal in itself: the quality of each unit test and the relevant selection of testing scenarios is more vital. However, it is definitely a good indicator of the quality of a codebase and its evolution over time, and can be very handy in many situations – such as checking the quality of one’s own unit tests before bothering a colleague for a code review.If you want to thoroughly monitor your code quality there is a set of metrics available that you can check in the following article.
If you’ve read until this point, you are probably in agreement with us that unit testing one's code is an essential step to building a robust piece of software. However – let’s admit it – it’s pretty tedious!! Not to mention very time consuming.
At the Developer Experience department of CircleCI, we decided to make it a more enjoyable process. We have decided to let you focus on the essential part of your job – coding – and we accelerate unit testing for you, with an AI-powered low-code interface which organically respects all unit testing good practices. Give it a try on the VS Code Marketplace
More questions about unit test answered in our 9 questions about unit testing answered article.
Do you want a better understanding of code coverage? Follow this link
The concept of mutation score raises your eyebrows? You can read more here
Or you want to explore all code quality metrics? We have a piece of content ready for you here