Legacy code — code without good automated tests or, equivalently, code that developers are afraid to change — cannot be fixed with a magic wand or one single approach. You need to put good automated tests in and refactor to restore modularity, but it’s a Catch-22: you can’t add good unit tests to spaghetti code without refactoring first, but you can’t refactor messy code first without good automated tests.
Legacy code is a lose-lose proposition, damaging external and internal quality:
- From a product point of view Legacy Code slows down development, increases defects, saps morale, and reduces the ability of the organisation to seize opportunities.
- From a developer perspective it’s slow and unpleasant to work with and increases stress.
Legacy Code arises because early in product development thing’s aren’t so bad: developers can hold the ideas of a small system in their head(s), and it makes sense not to take the extra steps around testing and modularity because the priority is to determine whether the new product is fit for purpose.
The problem is that few teams and organisations have the discipline to throw away the initial prototype once fit has been proven. In our excitement we start adding more features, and before you know it … there’s a big ball of Legacy Code.
And the genie is increasingly difficult to squeeze back into the bottle.
Cocktail time!
No one technique will get you out of this jam, but I have found that in most situations a suitable combination of approaches — much like how a cocktail of treatments is needed to effectively treat HIV — can work very well indeed.
A technique comparison / combination chart
Here’s a comparison and combination chart of basic and advanced techniques that relate to automated testing, and improving, clarifying and simplifying design through refactoring.
| Exercises code | Localises defects | Improves design | Corner cases | Legacy code | |
| Code, then write tests | ½ | ½ | |||
| Test Driven Design | ✔ | ✔ | ✔ | ||
| Property Based Testing | ✔✔ | ½ | ½ | ✔✔ | ✔ |
| Golden Master | ✔✔ | ½ | ½ | ✔ | ✔✔ |
| Design by Contract | ✔✔ | ✔✔ | ½ |
Legend: ½: minor benefit; ✔: good (with limits); ✔✔: leading edge
Attributes
- Exercises code: Drives the system and runs some sort of tests.
- Localises defects: Indicates where the code needs to be changed
- Improves design: Aids refactoring, especially in improving modularity
- Corner cases: Helps find difficult to reproduce bugs
- Legacy code: Helps untangle legacy code
Techniques
- Code, then write tests: Test last, often driven by code coverage requirements. Inferior to TDD, because it doesn’t drive good design. Naive approach to automation.
- TDD (Test-Driven-Design/Development): Write an automated test, make it pass by writing the code, check that all tests still pass, refactor to clean up the code, repeat. Requires discipline (pairing helps). Good for greenfields projects or adding new features. Can’t fix legacy code, because it relies on existing tests.
- Property Based Testing: Create and runs 1000’s of random tests to check invariants of the system. When it breaks, the PBT library reduces the test to a simple version to aid with debugging. An example of an invariant: in a banking system money should neither be created or destroyed, so any legal sequence of transactions between n accounts should have the same total funds at any stage. Great for tracking down difficult to find defects, corner cases, and even intermittent defects in complex systems. [Related to model-based testing.]
- Golden Master Testing (also known as Characteristic Testing): Treat the existing Legacy System behaviour as correct, throw a large set of random data at it, and record a text file of the output (the Golden Master). After making a small refactor — a true refactor cleans up code, but does not alter external behaviour — replay the test data and check that the new output matches the Golden Master. If there’s a difference we need to roll back and try again. If it matches we can be statistically confident that nothing was broken.
- Design by Contract: Specify pre-conditions and post-conditions of system functions (and optionally class invariants) by systematically adding assertions to existing code. The pre-condition says what a function expects, and the post-condition expresses what it promises. A pre-condition violation means that the calling code has a defect; a post-condition violation means that the function itself has a bug. For example: a square-root function expects a non-negative real number (the pre-condition), and it returns a result that is non-negative and when squared is equal to the original argument within error (the post-conditions). These conditions can be inferred from the requirements and checked by the computer. Breaking either triggers an exception. Developers who systematically write down pre-conditions and post-conditions before implementing their functions and classes tend to write well-thought-out, modular, maintainable code.
Notice that each technique has strengths and weaknesses. Fortunately, we can combine them to good effect, and the comparison chart helps with this.
There are other techniques, like mutation-testing, model-based testing, Pact testing, that look promising, but you don’t need all the techniques in your cocktail, just enough to get the job done!
I’ve left out UI testing, because that mainly serves a different purpose: exercising the UI and demo-ing functionality from a simulated user’s point of view. Unfortunately, they tend to be quite brittle, slow, and slow to develop, so I recommend using them sparingly.
Delicious Cocktails
Here are some combinations I like:
- Greenfields Martini: TDD + Property Based Testing. For new projects, TDD gives coverage and modularity. PBT finds corner cases and improves reliability.
- Golden Goose: For Legacy Code use Golden Master to safely refactor, restore modularity, and then write tests. Use TDD for new features.
- Gin and Contracts: Golden Master for Legacy Code, and Design by Contract to improve design, modularity, and localise defects.
- Property Pina Colada: To isolate and reproduce hard-to-find defects in an existing system, use Property Based Testing, and sprinkle with Contracts as use fix defects.
- Microservice Margarita: Golden Master + TDD + Pact Tests (related to Design by Contract) to gradually peel off microservices from an existing legacy monolith.
Conclusion
Beyond the amusing names, my point is serious. One technique is not enough. As with any craft you need to master multiple techniques, plus the experience and insight to choose the right tools for the job.
We need developers and technical leaders who understand many complementary techniques, the ability to combine them, learn new ones, and to create and sustain technical cultures in which this work gets done, not postponed.
Another dimension is to educate our product and business partners and other non-technical stakeholders, so that we don’t fall collectively into the trap of destroying our future by skimping on quality at the wrong time.
