I’m a big proponent of test-driven development: write tests first, production code next, and refactor it, but I also love improving legacy code. TDD is harder to do with legacy code because the code already exists. The tests can’t drive your design, because the design, however bad, is already there.
Let’s look at some techniques to get legacy code under test, as well as advantages and disadvantages of doing so.
Test-driven development follows a fixed pattern to develop software.
First, you write a failing test. In this test, you define the inputs of a piece of code and verify that the output is what you expect it is. Of course, this test will fail, because there isn’t any production code yet (except for a method definition maybe). In many test runners, this test will be colored red. That’s why we call this the “red” phase of TDD.
Next, you make the test pass by implementing the production code, but you do so with a minimal amount of code. This makes the test green in test runners.
As a last step, you look if you can refactor the production code. This won’t be really possible if you only have one test, but after a while, you will have many tests for a single component, and you’ll see you can improve the design of the code: remove duplicate pieces of code, simplify certain statements, etc.
This last step, the refactoring phase, is often forgotten. Developers will add tests and add code, but there is an opportunity to improve the quality of the code in this phase.
So TDD follows a continuous cycle of red-green-refactor.
Benefits of TDD
Test-driven development will push the design of your code in a positive direction: you will end up with only the minimum amount of code, and your components should end up being loosely coupled.
The fact that you have a suite of tests to avoid regression bugs is almost just an added benefit, but keep in mind that TDD doesn’t completely avoid bugs. If you forget to write a test for a certain edge-case, a bug might still make it to production.
TDD and Legacy Code
In legacy project, the design is already present. Even if it’s a mess or a bad design, some design is there.
Often, tests are not. Michael Feathers even defines legacy code as “code without tests.” I personally take a broader definition. I’m sure most of you have encountered code that everyone agrees is legacy code, even though there are tests. It’s often a matter of the quality of the tests or the fact that there simply aren’t enough.
This means we need to take it from the opposite side. We can’t write tests first and code later. The code is already present, but we want to add tests.
Specifics of Testing Legacy Code
When you start writing tests for your legacy code, there are some things to keep in mind.
First, the existing code is a single source of truth. Any documentation or stories other developers tell you may help, but they’re subordinate to the actual code.
The system is running and it’s being used. The users are expecting certain behaviors. Even if they have workarounds for certain bugs, you could break their workflow if you fix this bug without consulting them.
That is why we should often write tests based on what the code does now. Typemock’s Suggest feature comes in handy here. It will generate unit tests based on your existing code. I know many developers that don’t like the idea of code generation. But when you have a large body of legacy code to test, automation is your friend. You should still inspect the generated code. You might need to improve it here and there, but it beats writing it all by hand.
Watch a this short demo from the webinar “How to Get Away with Unit Testing Legacy Code“:
I already hinted at the second point: the fact that you should communicate with the “owners”: product owners, project managers, end-users, business people,… Because you will inevitably encounter strange behavior in the code. Things that make you wonder if they’re correct. Just removing or changing these pieces of code means you risk changing the expected behavior of the application, frustrating your users. You will need to have a good communication channel with someone that can answer these questions for you.
Finally, the last important point is to use your newly created test suite to improve the design of the code. Don’t just leave it like it was once you have your tests in place. Use the tests to improve the design. It will make your life easier in the future.
Advantages of Testing Legacy Code
If the system is running and there is a design in place, why do we even want to write tests for legacy code?
First, it will give us a safety net for refactoring, as I mentioned above. A decent test suite will give you the confidence to improve the quality of the code and to decouple components.
Tests for legacy code will also help you find unknown bugs or weird edge-cases. Sometimes, complex pieces of code contain bugs that nobody knew were there.
So the reason we write tests for legacy code is that it improves both the internal and external quality of the code. The internal quality is the quality of the code as we, developers, see it: is it easy to read, do we understand it, can we make changes easily without breaking things, etc.
The external quality is the quality of the software as the end-user sees it: is it free of bugs, is it fast and responsive, but also if changes can be made swiftly.
Test will improve the code overall, making both customers and developers happier. It’s a positive cycle that keeps improving the more you do it.
Disadvantages of Testing Legacy Code
There are also potential pitfalls when testing legacy code.
One is that a team might get a false sense of security. A feeling that the current state of the code is fine because it’s under test. But if you don’t use the tests to improve the quality of the code, the bad reputation of the code will remain.
Another danger is when you find you need to refactor the existing code before you can start writing tests. In some cases, it’s impossible to write a test without some changes to the production code. But how can we know that we haven’t broken anything with this untested refactoring? Well, we can’t. That’s why we should try to keep our changes as small as possible so that we can start writing tests as soon as possible.
This disadvantage is a little less relevant with Typemock Isolator because you can mock almost anything with it, more than you can with traditional mocking frameworks. But you still might encounter similar situations. For example, you could write tests for a method that is 1000 lines long. But it would probably be better to split it up first so that your tests also make more sense.
Finally, writing tests for legacy code can be difficult and time-consuming. It can be frustrating and demoralizing. Especially if you’ve spent hours and hours trying to get something under test and find you must give up. But don’t let that get you down. Maybe you can find an easier method to test, or maybe you’ll succeed with that difficult piece of code at another time.
When Not To Test Legacy Code
I’ve touched upon this on my own blog but there are cases where testing legacy code is not worth it.
For example, when you feel it’s better to replace the current implementation with a better design. Often, you can use the lessons learned from the current design. I once encountered a piece of code that had many complex SQL joins to retrieve data. I replaced it with a more performant query on a separate, denormalized table that contained a projection of the normalized data.
Or maybe your piece of legacy code runs fine and has no need for changing. Typical examples are applications that run only occasionally or services that have been running fine for years and don’t need any new features but most pieces of legacy code out there are live systems that are still evolving and require new features. In that case, automated tests are your best friend to improve the code quality and Typemock can help you write these tests.