All products need quality assurance. In some industries, one product consists of several devices, computing units, and sensors, creating a complex set of working machinery. This means several steps of quality assurance before the product can be sold.
There are several safety-related requirements to ensure that machines are safe to use, and several standards need to be followed - depending on the type of industry. Investment costs are high, ranging from implementation to quality assurance to support costs.
Unit testing emerged in 1997 with the release of the JUnit framework, marking nearly three decades of its existence. Despite this, I feel that gaps persist in unit testing practices due to uncertainty about its role in product development and its potential impact on project lifespan and costs. While ample resources exist on how and when to write unit tests and their placement in quality assurance, they often assume a clear understanding of budgeting benefits.
This article will address the impact of unit testing on projects, define testable units, explore the benefits of disciplined practices, and provide guidance on getting started.
What is a Unit, and what is a Testable Unit?
A unit is a small, even tiny, part of software. A unit is usually a single function or sometimes a single class. Sounds simple enough, right?
The above statement implies that any function or class can be tested through unit testing. This impression is incorrect, and there are some things you should know about the units that unit tests can test:
- A tested unit has an observable behavior: You can provide input, which results in an output.
- A tested unit needs to be deterministic: You get exactly the output you expect with the same input.
- A tested unit's behavior can be verified within the unit test framework: Interaction and dependencies outside the unit test context must be mocked. Mocking means functionality, which has been created with a specific test in mind - for example, to test how error-resistant the unit is.
- A tested unit has only such dependencies that don't conflict with the above rules.
One of the most common practices to achieve a unit that unit tests can test is inversion of control (IoC). If you are a software developer unfamiliar with the concept, you should definitely check it out!
Below is an example of a simple unit, which can be unit tested.
While the above points are true with deterministic units, non-deterministic units can also be tested using output distribution or certain rules. Rules require knowledge of what is tested and the output's boundaries. For example, a unit that provides ciphering can be tested so that the input provides ciphered output - even if the output’s correctness can’t be tested.
Using unit tests systematically during software development is necessary to ensure that most of the code base will respect one or more points in the above list. Unless your team is well acquainted with unit testing and especially practices that enable unit testing, adding unit tests later in the development phase is really, really hard.
When to Use Unit Tests?
Unit tests make a huge difference when they are used properly. There are also horror stories about unit tests that turned out to be useless or even harmful to your product. An example of an unsuccessful unit test would be to neglect the most powerful aspect of unit testing - verification of sane error functionality! Sane error functionality means the tested unit works with known functionality with incorrect input. Neglecting error functionality verification can result in good code coverage, but crucial bugs are not found.
Another example is that the code structure is so complex that creating unit tests requires extensive work. In this case, there is a need to mock up major parts of the architecture to test a simple algorithm, where the algorithm itself could have been a testable unit.
Unit tests are targeted to testable units, so you need to consider how you structure your code. Inspecting how the implementation is structured is necessary if functionality requires extensive work to implement unit tests. Usually, difficulties emerge when there is an attempt to execute unit tests to these non-testable units:
- A non-testable unit interacts with external sources like files, sockets, or networks.
- A non-testable unit has several dependencies on your codebase.
- A non-testable unit has two-way or circular dependencies.
- A non-testable unit does not provide input or output.
With practice and using design patterns, it is possible to recognize functionality that can be structured into testable and non-testable units. Non-testable units should be tested, of course, by module, integration, and system testing.
How Unit Tests Reduce Costs?
Most articles and guides tell you that using unit tests is the cheapest way to ensure software quality. This is true, even if writing unit tests does not come without a cost! How exactly does unit testing reduce the costs of software development?
It has been argued that writing unit tests reduces the time to introduce new features to your product. This is only partially true - unit tests are code that do not have a function in the end product. On the other hand, implementing functionality for your product can be done much more efficiently using unit tests!
When you use unit tests to implement functionality, you gain a minimal set of unit tests, and your code is easy to integrate into your existing product. This leads to a consistent structure in your software.
The benefits of consistency are that you gain increased predictability and simplicity in implementing software. When you are a person who is responsible for profit, you should cherish predictability. With simplicity, you gain extensibility, meaning introducing new behavior to software becomes more predictable.
Unit tests can be run automatically in continuous integration (CI) systems, thus eliminating hidden regression for all the tested functionality. Furthermore, if the unit tests do not have verification for something, it is fast and cheap to add those verifications once there are unit tests in place.
Furthermore, unit tests aim to reduce the need for other test methods by removing complex test setups for cases that can be trivially tested using unit tests.
As you can see, there are more benefits when using unit tests than just the actual code quality, robustness, easier bug finding, and extensibility. In other words, unit tests increase code quality and predictability, and predictability enhances budgeting and reduces costs!
How to Start?
When you have a large software project with a long history, it is likely that there are parts of the code that are not optimal to be tested using unit tests. Enabling the existing code to be tested by unit tests may require re-structuring changes in the code, and changing code can mean laborious, time-consuming, and expensive testing with other test practices.
Below is an example of a complex unit, which can't be unit tested.
To start using unit tests, you just need to decide to use unit tests. With new code components, use the above definitions to implement testable units. Code with a long history may be somewhat quirky, although I have a couple of rules of thumb, which I have found helpful:
- Create a unit test for a code component anyway. When you have a unit test in place, you can evaluate more easily what things in the code contradict the specification of a testable unit.
- Implement an interface for a component that creates a dependency preventing unit testing and move instantiation of that component outside the unit. Using inversion of control allows you to implement a mock component for unit tests.
- Recognize functions with multiple responsibilities and restructure them to functions with only a single responsibility. You can use either inversion of control or more straightforward dependency rejection to enable unit testing of at least some of these functions.
You can use all the above points for your code base, although the code requiring changes to implement new functionality is where they are needed most.
Below is an example of a complex unit, which has been modified from previous example to be testable unit.
Summary
Unit testing increases the code quality and creates consistency by verifying that tested units’ observable functionality is exactly what they promise. Code written with the help of unit tests is much easier to integrate into your existing product.
Consistency provides better predictability, and predictability is what enables successful projects. With predictability, budgeting and road mapping are much easier - not to mention that your project delivers quality software.
Unit tests are the first layer of quality assurance. Other test layers like module, integration, and system testing are also needed, and unit tests aim to reduce the need for complex and costly test setups.
Implementing unit tests to your code base can be started anywhere, but the best place to start is at the beginning of software development. If you assume that you can delay unit testing, you will find yourself with a project that needs more consistency, is expensive to maintain, and is slow to expand. Old code bases usually have these kinds of challenges. You can, though, begin implementing unit tests for old code bases - I’ve given some advice in this article.
Overall, consistent and disciplined use of unit tests will benefit your project, especially when the software's lifetime is long. The benefits of unit testing software with a long lifetime are not only code quality but reduced implementation and integration costs in the long run.