An opening guide to understanding unit testing best practices
Have you heard that you should write unit tests for your code but you're not sure why? Or are you looking for advice on the best practices to follow when testing your application? In this article, find some insights into how to best approach unit testing in order to make sure that your effort is worthwhile.
First, let’s go back to basics: what is unit testing?
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 behaviour 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 behaviour 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 benefits of unit testing
There are numerous benefits to writing unit tests.
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 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.
What makes a good unit test?
In order to write effective unit tests and make sure the time invested is truly useful, it’s important to keep in mind some best practices and rules. Unit testing guidelines demand that a good unit test should be:
1 – Independent
The behaviour of each portion 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 behaviour 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.
2 – Repeatable
In order to have confidence in the correct behaviour 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 behaviour and ensure it can be unit tested.
3 – Readable
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 organised 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.
4 – Exhaustive
A common mistake is stopping at testing only the “happy path” of a piece of code – the behaviour 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 Ponicode we know it’s sometimes tricky to come up with the right scenarios. It’s part of our mission to support you in this, to make sure that you never leave a path untested.
5 – Realistic
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 Ponicode 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.
6 – Maintainable
Broadly speaking, there are two types of assertions that can be done in a unit test: state-based and interaction-based. The former check 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 behaviour reflects its intention, and that the outcome is the expected one.
7 – Fast
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.
8 – Easy
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 Ponicode can help increase efficiency and free more time for the more creative part of software development.
Check your unit test quality with your code coverage
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.
Make unit testing fun with Ponicode!
If you’ve read until this point, you are probably in agreement with us that unit testing ones code is an essential step to building a robust piece of software. However – let’s admit it – it’s pretty boring!! Not to mention very time consuming.
At Ponicode, we decided to make it more fun. We have decided to let you focus on the essential part of your job – coding – and we take care of the unit tests for you, with an AI-powered low-code interface which naturally respects all unit testing good practices.