Software product damage from the lack of unit tests in its codebase is like a beautiful piece of meat being left out to rot: It is cumulative over time and cannot be reversed. Once a codebase starts growing without unit tests decays quickly into code difficult-to-change and understand. It becomes Legacy Code. Why would anybody want that?
„Legacy code is code without tests“, Michael C. Feathers.
The most worrying and lasting damage occurs when business logic is scattered across the codebase, without factoring that this code will more than likely change in the future. Failing to have a feedback system to verify the correctness of the business logic as the code is modified leads to highly-risky software changes. With any adjustment in the codebase, the team struggles to determine if the software is getting better or worse. This situation undermines the team’s capacity to frequently ship software meeting market needs and high-quality requirements. Fortunately, unlike a rotten piece of meat, it is possible to prevent and reverse a software product from prematurely expiring or settling. This is where unit testing comes to the rescue.
What is unit testing?
Unit Testing is the technique of managing the code’s functionality and quality through tests. As Developers are writing code, they also write test code. The test code focuses on verifying whether a unit of the software system behaves as expected and indicating a defect when it is present. This technique provides a fast and early feedback loop to make changes on the codebase without accidentally breaking a single feature or the whole system.
What should be unit tested?
Any code encapsulating business logic and domain objects are good candidates for being unit tested. These are typically classes in the codebase referencing actions and entities a user can perform and manage through the system. Ideally, they do not depend on third-party libraries or external frameworks and hence they are easier to test at the unit level. For example, in a Responsible Gaming application, we might have classes representing services to handle player’s deposits, bets, and sessions: DepositService, BetService, SessionService. We also might have classes representing entities such as Players and Wallets. All of these are good candidates for unit testing.
Other good candidates for unit testing are the Controllers. These are the end-points exposing the services the application provides to its clients. For example, we might have a DepositController using the DepositService to fulfil a deposit requested by a player.
How do I start unit testing?
A good starting point is to structure your unit testing practice. Below, an example template on how to do it:
Goal functionality: A player can make deposits in the Maltese jurisdiction.
Deposit service expected behaviour:
- Accept deposits only from Malta.
- Accept deposits up to €3000.
- Not accept deposits from self-excluded players.
- Not accept deposits from unverified players.
Start off writing tests against the unimplemented service code. Create one unit test per service expected behaviour. These tests will fail at the beginning, and as you are writing the relevant service code, they will pass. This type of development approach is normally referred to as Test Driven Development (TDD).
What if I am working with Legacy Code?
Dealing with Legacy Code means: before you change a service, domain object, or controller, first and foremost, you should characterize its behaviour. Unit testing these classes helps to create a safety net and to understand what exactly they do before changing them. Once you have their unit tests, you will be able to change and refactor these classes with peace of mind that you are not breaking their original intended behaviour.
How many unit tests do we need?
The metric showing how much unit testing requires a codebase is Code Coverage. It usually comes in percentage, and it is the amount of code being exercised by a set of unit tests. Although the ideal code would be covered 100% by unit tests, there is an accepted range starting from 80%. Coverage exposes untested code.
How do we prove our unit tests are good enough?
By performing Mutation Testing, you assess if a unit test suite is sensitive to defects. Mutation Testing consists of running unit tests against mutations on the codebase. Every code mutation – a mutant – is controlled and managed by a library that changes code in services, domain objects, and controllers. Mutants change the underlying behaviour of these classes, and their unit tests should fail, indicating they are sensitive enough to detect unintended behaviour changes on the codebase. A unit test failure due to the presence of a mutant means the mutant was killed. Surviving mutants means your unit tests are not sensitive enough to the underlying behaviour of classes.
How does all this relate to quality?
Unit Testing, Code Coverage, and Mutation Testing can serve as quality gates for pull requests and artefact promotions. For example, the team can agree that:
- Before creating a pull request, it is to be made sure that all unit tests pass.
- Code can only be merged into a stable branch after it is 80% covered by unit tests.
- All mutants should be killed before merge code between source branches.
Will unit testing eradicate all issues in production environments?
The short answer is no. Unit testing alone is not enough to get rid of all bugs and issues in production environments, and that’s why different types of tests exist: component tests, integration tests, acceptance, performance tests, usability tests, etc. (see the Quality Matrix). Even when having a multi-dimensional testing model there are chances that you will get issues in production environments. Unit Testing can help accelerate the process of fixing bugs and adapt the software product to market needs, as you can change it with confidence.
Unfortunately, to ship untested code is sometimes an accepted practice to streamline the development pipeline. Whether you are a vegan, pescatarian, or a see-food-and-eat-it-type, no one wants to eat something that is rotten because of bad practices, and the same goes for code: no one wants to consume expired legacy code. What are you waiting for to just start unit testing?
Orlando Garcia, QA Engineer