Investing in Unit Testing: Benefits and Approaches
Stakeholders demand quick fixes for a buggy app release. Such patching is costly, and does not always offer a complete solution. Break the cycle with unit testing, a worthwhile investment in project quality.
Stakeholders demand quick fixes for a buggy app release. Such patching is costly, and does not always offer a complete solution. Break the cycle with unit testing, a worthwhile investment in project quality.
Dacian is a senior full-stack mobile applications developer and a contributor to the Flutter framework. He specializes in rigorous testing solutions that help companies around the world design and deliver quality software applications.
Expertise
Previous Role
Senior Mobile DeveloperHow do you really feel about unit testing? Does it make sense to shift focus away from development in order to dedicate the team’s efforts to improving the codebase? Developers don’t always relish the tedious cycles of evaluating code, mocking data, defining test group(s) and their function signature(s), writing tests, and then going back to tweak existing code.
Moreover, managers must weigh the team’s resources against the costs of implementing unit testing. Top reasons cited for opting out of unit testing include a project’s limited scope—whether due to a small team, or limited working hours when meeting a set deadline—and budget concerns.
But adding unit tests can be as seamless and practical as checking whether the milk is fresh before you pour it in your coffee. In the long run, unit testing undeniably enhances a project’s development experience and reduces costs, as we will see in our exploration of unit testing’s advantages and the prevailing strategies for its implementation.
How Unit Testing Benefits Your Projects
Unit testing is the process of running focused tests over small chunks (units) of code to check whether code behaves as intended during the course of an app’s development. Preemptively identifying glitches through unit testing reduces debugging time and saves money.
Reduced Post-production Costs
By thoughtfully incorporating unit testing into the development process, you increase your application’s quality. The app is delivered with fewer bugs and malfunctions for QA engineers to document, or for developers to modify.
A lighter post-production workload results in lowered project costs and faster project completion. Managers can forecast and budget for curtailed QA team contract periods, scheduling upcoming or dependent projects to begin earlier, and developers can start working on new apps even sooner.
All this translates into even more reduced costs. It’s reassuring to know that you’ve done the groundwork to avoid potential disasters.
Increased Revenue Potential
A high-quality, bug-free application experience leads to more satisfied, loyal end users. If users recommend the app by leaving positive online reviews, or by sharing with friends and family, the app’s earning potential increases exponentially.
And let’s not overlook the fact that a dearth of negative reviews can also lead to financial success. Just as there are consumers who search out favorable information, there are also those who specifically look for critical reviews as a way to avoid a flawed product or service. (I count myself in the latter group.) It can be argued that, from a business perspective, garnering positive reviews is beneficial—but avoiding damaging reviews is crucial.
Stopgap Documentation
Reviewing unit tests can help bring new developers up to speed on an application. When a project is segmented or apportioned to siloed teams, a review of unit tests goes a long way toward filling knowledge gaps and providing insights into the application as a whole.
Regardless of a team’s organization and communication style, reading unit tests enables developers to glean information that may otherwise be insufficiently documented, or buried under mountains of notes.
Natural SMART Goals
By its nature, unit testing segments a project into well-ordered, digestible portions. These individualized code divisions effectively translate into clearly defined SMART (specific, measurable, achievable, realistic, and timely) goals.
Meeting SMART goals serves a project by making the team’s progress more transparent to leadership and stakeholders. Developers are impelled to plan ahead and code in an organized manner. Each unit of code is assigned a single function, and tested to ensure that the unit performs as intended. The code boasts a separation of concerns:
- Each project unit supports a single objective.
- Each function within a unit fulfills only its own scope.
Having bite-size units facilitates the project’s tracking. Contrasted with a team whose milestones are unclear or faraway, the team that routinely checks off unit after unit is likely the happier of the two.
Improved Scalability
Well-planned unit testing affects the code’s scalability on a number of fronts, equipping us with the ability to:
Architect |
|
Engineer |
|
Team |
|
Load |
|
Testable code is clean, modular, and reusable in other environments.
Stable Code and Features
Say we’ve previously tested and implemented a global search-by-name feature, and would now like to give the end user the ability to filter the search results.
To add this feature, we would create a new unit for our filtering function. We can remain confident that the unit that controls the global search-by-name feature should still pass if retested. The new unit’s code should not “break” code in other units.
Enhanced Debugging
Identifying and correcting a bug’s root cause is more readily accomplished in unit-tested code. Say the search feature is not working correctly. Instead of a classic needle in a haystack scenario—checking the entire codebase for the root cause—you can revisit the unit test results in the project’s search module.
Efficient Refactoring
Unit testing a feature helps us to confirm that it works as expected—even if we refactor code logic, or update third-party libraries, for example.
Picking up with our previous global search-by-name example, let’s say the feature works perfectly, but is as slow as molasses. To remedy the speed issue, we implement a potential fix (e.g., we replace the algorithm) and retest. Once again, we can be confident we have not “broken” our feature that has previously tested perfectly. The feature should still pass its unit tests post-refactor.
Unit Testing Strategies
When it comes to testing, there is no universal standard. In fact, there is much discussion among experts on how much unit testing is necessary in order for a project to be successful. There are trade-offs between time invested and code quality. After the scope of unit testing is decided, the project manager must choose from among multiple unit testing strategies.
Unit Testing Scope
Think of unit testing as an insurance policy to safeguard your project. Often, it is a person’s risk tolerance that dictates how much insurance to buy or or how extensive a unit testing plan to implement. At one end of the spectrum are those who would spend more to get more, their goal being maximal coverage for the sake of averting or preventing disaster. At the other end are those who would take their chances. Perhaps they are financially or otherwise resilient and well positioned to rebound from a loss, should one occur. The vast majority of people fall somewhere in between the two mindsets.
A unit testing plan’s scope typically falls into one of three patterns:
- The entire codebase sequentially
- The entire codebase in order of importance
- Just the critical parts, perhaps the portions that offer the most bang for our testing buck
The third pattern, referred to as targeted unit testing, is often most practical, given project constraints. In this case, we cherry-pick the code to test, focusing on parts that are most critical to a project’s success.
Software developers are particularly qualified to translate their knowledge of each code snippet’s purpose into fitting tests. To revisit the coffee metaphor: Given limited testing resources, most of us would agree that sniffing the milk that could go bad is a far more valuable test than sniffing the sugar that can age gracefully on the shelf.
Once a plan’s scope is decided, we’ll want to consider and adopt a strategy that works for our project.
Unit Testing Approaches
The industry standards are:
- Post-implementation testing, in which developers write tests after features have been implemented.
- Test-driven development (TDD), in which developers write code and tests together for each feature requirement use case.
The idea of post-implementation testing can appeal to managers who tend to prioritize development in the race to submit deliverables to stakeholders. Post-implementation testing, therefore, is a far more common practice than TDD, which, in comparison, starts off slowly and requires discipline and patience throughout the project’s duration.
Both approaches employ the same basic steps, with only their orders of operations differing. The following table displays these steps, while color-matching those that are identical across both approaches:
Post-implementation | TDD |
---|---|
Step 1. Convert feature requirements into use cases. Step 2. Implement code. Step 3. Define test cases. Step 4. Write, run, and validate tests. Step 5. Correct code as necessary. Step 6. Approve feature after all tests are successful. |
Step 1. Convert feature requirements into use cases. Step 2. Define test cases. Step 3. Write, run, and validate tests. Step 4. Implement code. Step 5. Rerun tests. Step 6. Correct code as necessary. Step 7. Approve feature after all tests are successful. |
This discussion would not be complete without a mention of hybrid unit testing, in which we test features post-implementation and fix bugs encountered during development with TDD, adding tests for each new bug.
Technical Examples
We have seen various unit testing approaches, but what does it mean to ready our project for clean, differentiated unit testing in practice? To start an example testing implementation, we must first provide for a separation of concerns in which each unit supports a single objective and each function fulfills a single task.
Only the interface of a unit should be tested: Internal states and properties intended to be read and/or written by other units should be excluded. Thus, if a unit is responsible for a multiplication function, we could write a test that ensures that multiplication is correctly performed (e.g., 5x7=35), but we would not investigate how the multiplication actually happens (e.g., 5x7 vs. 7x5 vs. 7+7+7+7+7, etc.).
Let’s imagine we have an application in which we want to present three text files whose headings should display in blue font color. We begin by writing the entire program for our feature, loading the files, and showing our headings, employing the best practices and separation of concerns discussed earlier. We then test and update our code as necessary.
Now that our code is implemented, we define test cases to check the feature in its entirety:
- Do the files load?
- Does the file text display?
- Is the font color of our headings blue?
Next, we write separate unit tests for the corresponding code units:
- The function that loads files
- The function that displays text
- The function that deals with formatting
We can inspect these scenarios in the readable Gherkin language, which is structured to present such behaviors:
Feature: Load and display text from the files and display all headings in blue font color
Scenario: User loads file successfully
Given user navigates to the platform
And user navigates to the Import File page
When user selects the file and chooses Import
Then file is imported successfully
Scenario: File is loaded and text displays successfully
Given user navigates to the platform
And user navigates to the Import File page
When user selects the file and chooses Import
Then file is imported
And file text displays in its entirety
Scenario: File is loaded and text displays successfully and all headings display in blue font color
Given user navigates to the platform
And user navigates to the Import File page
When user selects the file and chooses Import
Then file text displays in its entirety
And all headings display in blue font color
Each scenario needs to be unit tested.
Post-implementation Unit Testing Demo
In our post-implementation testing approach, we proceed as follows:
- Implement the code for all three scenarios.
- Write the tests for these scenarios.
- Run the tests.
If all three scenarios pass unit testing, our code is good to go. However, if any tests fail, we must modify our code and retest until all tests are successful.
We can expect that some tests could fail, since they were not developed concurrently with their corresponding features. If testing reveals any issues (e.g., if the headings don’t display in a blue font), we need to tweak the code and retest.
TDD Demo
In TDD, prior to writing any code, we translate project requirements into tests, feature by feature. As we have not yet implemented the software, our tests fail when we first run them. But we do so anyway, in order to confirm the integrity of our structure: If the code’s syntax is correct, the test runs and fails. But if it is flawed, the test does not run and we get a syntax error.
Now we implement the code and rerun the tests. For each failure encountered, we update our code and retest, approving the feature only after testing is successful.
Continuing with our previous example, let’s demonstrate TDD. We define and run tests for our feature (headings that display in blue font color). Assuming no syntactic issues are detected in our test, we are now ready to implement our code and rerun the test:
- Write tests for the first scenario.
- Implement code for the first scenario, reiterating until all tests pass.
- Repeat steps 1 and 2 for any remaining scenarios.
Once we have finished this process, our TDD approach is completed.
An Ounce of Prevention
We have demonstrated that unit testing your project will more than pay for itself in financial savings, bug prevention, and the peace of mind it affords you.
Benjamin Franklin’s cautionary saying—“An ounce of prevention is worth a pound of cure”—still holds true today. Like an insurance policy, unit testing is a worthwhile investment. We test for the assurance of knowing we have averted or prevented the potential disasters, and that is priceless.
The editorial team of the Toptal Engineering Blog extends its gratitude to Saverio Trioni for reviewing the technical content presented in this article.
Further Reading on the Toptal Blog:
- .NET Unit Testing: Spend Upfront to Save Later On
- Unit Testing in Flutter: From Workflow Essentials to Complex Scenarios
- Unit Testing and Coding: Why Testable Code Matters
- A Guide to Robust Unit and Integration Tests With JUnit
- Android Testing Tutorial: Unit Testing Like a True Green Droid
- A Unit Testing Practitioner’s Guide to Everyday Mockito
Understanding the basics
What is the purpose of unit tests?
Unit testing identifies code that does not function as intended. In doing so, it enables your team to improve the application prior to its release, deliver a quality user experience, and prevent bugs.
What are the pros and cons of unit testing?
Unit testing has many benefits: It enforces best practices, produces modularized and repeatable code, creates project documentation, reduces the total time spent on a project, and reduces project costs. Barriers to unit testing include a limited project scope and/or budget.
How often should you run unit tests?
Frequently. Unit tests should be run prior to (a) the merging of a code change into the codebase, (b) deployment, and (c) release into production.
What should be unit tested?
One viewpoint states that all code should be unit tested. But many teams limit the scope of testing and target only the most critical areas of the codebase, or those that offer the greatest potential payoff for the project.
Is unit testing possible in all circumstances?
No, unit testing is only possible to the extent that best practices have been applied to modularize the code being tested.
Bucharest, Romania
Member since November 9, 2020
About the author
Dacian is a senior full-stack mobile applications developer and a contributor to the Flutter framework. He specializes in rigorous testing solutions that help companies around the world design and deliver quality software applications.