You’ve heard it before, probably a million times. “Developers must write unit tests.” Easier said than done. There’s the big monoliths, the ugly codebases, the mission-critical code that one really knows how it works. Well, it turns out that those ugly pieces of software that provide a lot of value are probably going to require new changes.
I’ve said myself long ago. It’s daunting thinking about making a change to a convoluted codebase and also gut-wrenching at the thought of introducing a breaking-change. The less we change, the less risk of the change, right? Not quite.
The more the codebase goes without unit tests, the less clean-up that happens, the more the technical debt grows. At some point, things will break and it’s going to take a whole lot of coffee, long hours, and stress to make it work again.
Why?
Unit tests are difficult to create at first. But, after some time and with practice, teams can reach an inflection point where they are able to deliver higher quality code with automated tests faster than untested code. For the skeptics, it might be hard to believe yet research studies like the 2017 State of DevOps report show us the end result. High-performing IT organizations are to ship code to production multiple-times faster than other orgs. How often do they deploy? At least once a day per developer per day. Unit tests are an ingredient to their success.
But wouldn’t deploying this much mean that production breaks all the time? Well, no. They’ve also found that high-performers not only go fast but they build more reliable systems. Software delivery and development doesn’t have to be the way it used to be. We don’t have to trade agility for stability.
How do I get there?
Unit-testing a green-field application is much easier. Technical debt is zero and you have flexibility into start building the product with a test-driven approach. You also have more flexibility to choosing design patterns and adopting principles that make it much easier to write tests.
On the other hand, when adding tests to a legacy app, there’s going to be a lot of refactoring. Usually, the code is not written in a way that adding unit tests is easy. Code is tightly coupled, there’s a lot of external dependencies, and probably a lot of technical debt.
Here’s some tips to adding tests to legacy code.
1. Start Somewhere
When creating a new feature or fixing a bug, take some time to think about how to write some tests about the changes you’re implementing. There’s really good literature on some techniques you could use – I recommend the book “Working Effectively with Legacy Code.”
Typically, validation behavior or utilities classes could be a great place to start and practicing. Refactor and validate you’re on the right track often. Don’t wait until the end and submit a massive code review to your peers.
In some situations, you might find that refactoring the code to make it testable is more difficult than you anticipated. It’s ok to stop. Sometimes the level of difficulty exceeds the reward.
2. Incrementally
Add tests as part of your daily workflow. Execute your unit-tests as part of your CI/CD pipeline. Make the code coverage results visible and start appreciating the progress that you’re making.
What you don’t want to do is stop feature development just to start adding unit tests. It’s a lot of pressure to get X amount of code coverage within a certain time period. There’s simply too much risk.
Instead, find opportunities how to add tests as you go along. Pay attention to the areas that are painful to test – tests and refactoring could be beneficial.
3. Take on Technical Debt
Is there a component that is so difficult to test and convoluted that keeps you up at night? Is it so complex that no one really knows how it works and therefore no one wants to “touch” it? Consider taking on some technical debt on purpose – start slowly rewriting the component using Test Driven Development (TDD) and deploy it to production frequently. For example, start rewriting it based on your understanding of what the legacy piece does today. Then, if you’re not fully sure of how it works or you’re not finished, leave it as dead code. Dead code simply means that it will only get executed by unit tests. The power of dead code is that you can still release it to production and there’s no need to keep a long lived branch for the rewrite.
When the time comes, you can simply change the execution paths to point to the new rewritten & fully tested module. Perhaps, you could also choose to throw a feature flag in front so that this is only available to some users and you can quickly revert back if something goes wrong.
At New Signature, we’re huge believers in helping companies create more business value through Azure and DevOps. I also believe that to be an effective and high-performing IT organization, teams must unit test. Unit testing helps organizations move faster and deploy more frequently. Unit testing helps dramatically with lower lead times – development teams no longer have to wait business days or weeks to get feedback on whether everything works. In the end, software will become more stable because of the reduced technical debt, increased simplicity, and increased clarity.
With the pressures of work and dead-lines, unit-testing a legacy app can be very daunting. We can partner with you to create hands-on workshops that empower developers to start unit-testing immediately. We’ll show you how to get the most out automated tests by executing them often and making those results highly visible to the team. Sometimes, these key and fundamental practices like unit-testing can have a bigger impact than a new architecture or new virtualization technologies.