6 Best Unit-Testing Practices
The phrase ‘The whole is greater than the sum of its parts" is often taken too literally in software development. As a result, development teams will over-rely on a combination of integration and user acceptance tests. The reality is that although these tests can verify a problem exists, they are slow, and can not confidently and quickly identify where and why that problem exists. The reality is that, without solid foundations, the end product is destined to fail, so it makes logical sense to thoroughly test the individual parts before ever testing their integration, therefore identifying any issues that exist at the earliest possible stage of the development cycle, whether that’s introducing a new feature or refactoring and improving existing features. This is the goal of unit testing.
The unfortunate side effect of testing every unit of code is that the process can be time-consuming and tedious at times. Still, by following best practices and incorporating automation tools and techniques, the effort can be reduced while maintaining the highest standard possible for your code. The list below introduces some of these practices, although each language and framework may have its own idioms and tools which help ease the burden of responsibility on the individual developer.
Tests should have a minimal scope
If a test fails, it should be immediately apparent what piece of code or logic has caused it to fail. Each test should therefore focus on a single piece or unit of code. Should the test fail, a developer can quickly find and debug the misbehaving code. If it’s still tricky to figure out the failing logic, this is a sign that the code may have a high cyclomatic complexity which means there are simply too many branches of logic in a given function. The simple solution is to break down complex functions into multiple smaller and more simple functions.
Tests should act as documentation
Complex design patterns and technical syntax can make legitimately good code hard to follow. A variety of simple, well-named tests that clearly show the input and expected output of a particular unit of code can provide a logical, practical picture of the code and its place in the system as a whole. Unit tests should demonstrate common use cases of the code, as well as edge cases that show the boundaries and limitations of the code. This information effectively acts as self-describing documentation.
Tests should be deterministic
Human beings yearn for predictability and stability in their lives; your unit tests should satisfy this desire by being completely deterministic, meaning the same input values will always return the same output value.
Tests should be integrated into CI/CD
The sooner bugs in code are found, the lower the cost of its remediation. There is a significant difference in the time, cost, and effort it takes to fix a bug in development versus production. You should not make the assumption that a developer will never publish code before testing it thoroughly. By integrating your testing directly into the CI/CD process, you can ensure bad code never makes it as far as your production environments. This also has the added benefit of making sure customers never have to experience bugs.
Fake it till you make it
Unit tests are designed to test the logic of your code, not how it integrates with external services. As such, you have three options when it comes to testing code that includes integration with such services. You can exclude the code from tests (not recommended for obvious reasons), test it using integration tests (time-consuming and not practical or even possible in all development or CI/CD environments), or use strategies like dependency-injection/mocking to swap out the external service for your own fake implementation/values.
This allows you to get immediate feedback and full test coverage on your logic, regardless of what external services it references.
Leverage tooling to determine code coverage
When writing unit tests, you can use tools to determine the total percentage of code covered by your test cases. The same tools typically use simple graphics to display which sections of code or which branches of logic require more attention. For example, suppose any single block of code requires a high number of separate tests to cover each eventuality or logic branch. In that case, that’s a sign of high cyclomatic complexity, and the code should be reviewed and refactored or simplified if possible. Alternatively, the code coverage could show that particular lines or blocks of code are never reached, in which case that could be a good sign to remove it.
Implementing a healthy test automation culture is not an easy task. Sticking with the practices above can ensure creating such a culture. The more mature an organization is, the more quality-dependent it is going to be, and changing bad testing habits is a challenging task; so the sooner you will start the better.
In the next post, we will talk about the advantages of unit testing in addition to the relation between an organization's maturity and its automation infrastructure. Stay tuned!
Snowmate - Early Access
What if you had a solution that generates and maintains your entire backend unit tests 100% automatically? Well, it is now getting built and will soon be released.
If this interests you, sign up for our early access.