Pin what it does before you change what it should do
Before you can safely change legacy code, you need tests - and the first ones should assert what the code does today, bugs included. Meet seams and characterization tests, and find out how an approval test can quietly stop being able to fail.
The previous lesson argued for changing a system in place instead of rewriting it. That raises the obvious problem: legacy code is often code nobody dares to touch, precisely because there’s nothing to tell you when you’ve broken it. This lesson is about building that safety net first.
Feathers’s three ideas
Michael Feathers’s Working Effectively with Legacy Code (2004) gave the field its vocabulary. The seam definitions below are his words, from the book’s published excerpt; the other two are paraphrases, as summarised by others:
- Legacy code is code without tests. Not old code, not bad code: code you can’t change with confidence.
- A seam: “A seam is a place where you can alter behavior in your program without editing in that place.” Passing in a different object, or linking a different implementation, are examples. And: “Every seam has an enabling point, a place where you can make the decision to use one behavior or another.”
- A characterization test records what the code actually does today, whether that’s right or not. You aren’t specifying correct behaviour yet. You’re pinning current behaviour, so a refactor that changes it by accident goes red.
That last point feels wrong at first. A test that asserts a bug? Yes, on purpose. Fixing the bug becomes a deliberate change with the test updated alongside it, instead of something that happens by accident in the middle of a refactor.
Approval tests: characterization at scale
When the output is big - a report, a page of HTML, a large JSON document - writing assertions by hand is impractical. Approval testing captures the whole output instead. The first run writes a received file; you read it and, if it’s what the code does, save it as the approved file. From then on, the test fails whenever the received output differs from the approved one. The tool is open source and has ports for many languages.
The same idea is often called a golden master test, and it has a well-known way to go wrong. When the test goes red and you’re confident the new output is right, it’s tempting to re-approve it without reading the difference. Do that once with a bug in the output, and the test now asserts the bug. J. B. Rainsberger’s advice is to make regenerating the golden master a deliberate, separate step, never the reflex when a test fails.
Try it
Three approval tests. For each, decide what it can still catch.
A characterization test asserts that legacy code rounds half-pennies down, which you think is wrong. Why keep it?
Characterization tests record what the code does, not what it should do. The point is to make every behaviour change visible and intended.
An approval test went red after a refactor. You're sure the new output is right. What should you do before re-approving?
Re-approving without reading turns the test into a check that the code does whatever it now does - including any bug in the new output.
Sources
- Understand Legacy Code - Key points of Working Effectively with Legacy Code (summary of Feathers, 2004)
- Michael Feathers - Working Effectively with Legacy Code, excerpt on seams (InformIT, Pearson)
- ApprovalTests (approvaltests.com)
- ApprovalTests.Java - README
- J. B. Rainsberger - How not to write golden master tests
Missions and quizzes above earn their own XP. This marks the lesson done.