Given the choice between working in a tested or untested codebase, most developers will choose the tested code in a heartbeat. Tests add reliability and stability to your application and allow developers to grow and change code with less fear of triggering unintended results.
Of course, legacy codebases are often missing tests, or have scant coverage. It can be daunting to even think about adding tests to them. Where do you start, what do you prioritize, and how to find time for it? Those are all vital questions for testing any application, but they’re even more important when you want to add tests to an existing, mature codebase with limited coverage.
1. Figure Out What Tests Already Exist
The first step toward testing your legacy application is to find out what’s already being tested. Answering these questions can get you started:
- Is there a testing framework in place?
- When do tests run?
- Exactly what code is covered by tests?
Legacy applications often pass through many developers with many styles—you can find obsessively tested units passing their output to entirely untested functions. Mapping existing code coverage is the first step to systematizing and expanding a testing regimen. Remember, code coverage is the percentage of code that gets run by tests. Tools like Codecov can provide precise metrics tracking complicated coverage scenarios, like if statements, and integrate with deployment services like GitHub to indicate what code is covered.
2. Make a Plan
Adding tests to an existing codebase can be a full-time job all on its own, but it’s likely you have other work to do. Prioritizing high-impact tests saves time and begins to generate the organizational buy-in necessary for cementing testing as an important fixture of the development process.
Once you have an overview of what code is currently covered, you can start working on expanding it. Start thinking about your testing process:
- Is there code review, or a manual testing process?
- When do tests get run
- What events trigger tests?
Increasing your code coverage percentage is a great overall goal, but when you’re just starting out, it’s time prohibitive to test everything. You’ll have to discover and prioritize sections of your code where tests will have the highest impact.
To figure out what your most impactful test could be, think about the key performance indicators (KPI) for your business:
- Is response speed vital?
- Is accuracy of data most important?
- Do you have an uptime service agreement?
- Is security extremely important?
Tests that support the core of your business are most important because they safeguard the code that generates revenue. Creating these tests also allows you to refactor and test smaller, less vital pieces of code with confidence that you’re not breaking core functionality.
One hurdle for adding tests to a legacy application is that well-written tests don’t create solutions, they prevent problems. This is a huge benefit, obviously, but it’s difficult to quantify. People rarely get credit for giving disaster a wide berth.
It can take time to feel the benefit of testing, but extremely successful companies are obsessive about testing. It grants developers security and flexibility, and technical debt is much easier to sweep away, not to mention digging out bugs that often plague and occasionally dramatically overwhelm legacy codebases. Testing also allows new developers to ramp up productivity faster; they can have the confidence to experiment knowing that vital functions of code are safely guarded.
In order to generate enthusiasm for testing in your organization, you’ll have to measure success somehow. Tracking uptime is a great start—a good testing program helps you catch errors before they paralyze services and can help guide developers to quick solutions when problems do arise. Also consider tracking time to deployment. A robust testing suite often leads to faster deployments as the amount of effort spent on quality assurance is reduced.
Find the pain points in your deployment and development processes and focus on writing tests that produce tangible benefits for stakeholders. People struggling with those problems will appreciate the help and are likely to start clamoring for more testing.
3. Get Started and Be Consistent
When you write tests, choose the most important metrics for your organization, as mentioned in the previous section, then start testing against them. A big challenge of testing legacy applications is writing tests regularly and efficiently. You have to evaluate how much time you have and write tests with that constraint in mind.
In many testing situations, the end goal is to have a comprehensive suite of unit tests. Unit tests are great because they’re small and focused on a limited section of code, so they can quickly point developers to exactly what’s wrong. In fact, as you’re adding new tests to a legacy codebase, a best practice is to add unit tests for any new code you write. However, because they target small sections of code, it can be extremely time-consuming to write unit tests for an entire codebase.
Because of the high time commitment involved in writing unit tests, the most high-impact tests when you’re starting out are often end-to-end tests. These tests treat code as a black box; they won’t help you catch existing bugs in a system, but they’re great at broadly supporting business goals and can establish and systematize a baseline of performance and functionality with a smaller investment of time.
It’s particularly important to write end-to-end tests with concrete goals in mind. Ask around and focus on the most important results of your codebase and test those. Examples include:
- Verifying that a page is loading
- An API returns results
- Data gets stored properly
Once you know the goals you’re pursuing, choose testing tools to support them. Code coverage tools like Codecov can provide metrics to measure progress and formalize processes. Frontend testing tools like Selenium are great for end-to-end testing, while unit tests should be written using a library that’s convenient for your codebase to support.
When adding tests to a legacy application, it’s extremely important to verify that your tests can fail. Verifying that a test can fail can be as simple as commenting out a vital line of code or mangling an input, but when applying new tests to existing applications, it can be very tempting to write the test, see it pass, and move on. This leaves your tests critically untested. If you’re not able to verify they can fail, it’ll rarely be clear to future users what they’re really testing. Be sure your tests both pass and fail appropriately.
4. Harness Your Momentum
To ensure that a test suite is maintained and expanding, be sure to integrate the testing process into your development and deployment workflow. Tests that don’t get run aren’t helping anyone, and if your test suite drifts out of sync with your codebase, you’ll often modify behavior, then have to modify a test to make it pass, bitterly twisting the purpose of testing. Find a place for testing in your deployment workflow and ensure that tests are run and checked regularly…which in turn reminds developers to write them.
As your code coverage grows, you’ll likely find sections of unused code. Without tests, it’s often terrifying to remove these sections from legacy code bases because you can’t be sure they’re not used on some obscure page. Removing unused code and tidying up your codebase becomes easier with every test that’s added.
“Dead code needs to be found and removed; leaving dead code in is an obstacle to programmer understanding and action…Deleting dead code is not a technical problem; it is a problem of mindset and culture.” – Bin Linders
A great spot to add a check for code coverage is during the review process, as code is committed and merged. Codecov’s Patch Coverage feature can specify the percentage of code converted by tests required for a successful commit. Tracking code coverage is most helpful if you’re able to present that information to developers as they write code, which will help you avoid the challenges of bolting tests on later.
5. Reflect and Refactor
“In almost all cases, I’m opposed to setting aside time for refactoring. In my view refactoring is not an activity you set aside time to do. Refactoring is something you do all the time in little bursts.” – Martin Fowler, Refactoring: Improving the Design of Existing Code
We’ve explored the general process of testing a legacy application: first evaluate what tests exist, test the most vital parts of your applications, choose tools like Codecov that will support those goals, then cultivate organizational buy-in by tracking metrics of success like code coverage and uptime, and finally create the expectation that testing is a regular concern in the development process.
Once your organization has some of the most important areas of your code tested, it’ll become easier to refactor code, clear out technical debt, quickly onboard new developers, and write even more tests.