The Legacy Code Trap
Michael Feathers defines legacy code simply as 'code without tests.' Without tests, you cannot change the code with confidence, because you have no automated way to know whether a change broke existing behavior. This creates the legacy code trap: you need tests to refactor safely, but the code is often too tightly coupled - large methods, hidden dependencies, static calls, singletons - to be testable without refactoring first. Breaking this trap requires a deliberate, incremental strategy rather than a rewrite, because rewrites on untested code carry enormous risk of silently dropping business logic nobody remembers exists.
Cricket analogy: It's like inheriting a bowling action with no video footage of what 'correct' looks like for that bowler - you can't safely tweak the action until you've filmed and documented its current behavior first.
Characterization Tests
A characterization test captures what the code currently does, not what it should do - it is a safety net, not a specification of correctness. The technique: call the function or method with a representative input, run it, observe the actual output (even if it looks wrong), and write an assertion that pins that output down. This gives you a regression detector before you touch anything. Once characterization tests cover the behavior you're about to change, you can refactor freely, confident that the tests will fail if behavior shifts unexpectedly - then you decide deliberately whether that shift was intended.
Cricket analogy: It's like installing a stump-cam on a bowler's current action before any coaching change, purely to record baseline mechanics - not to judge whether the action is 'correct,' just to have a reference to compare against after adjustments.
# Legacy function with no tests and unclear edge-case behavior
def calculate_discount(price, customer_type, quantity):
if customer_type == 'vip':
d = price * 0.2
elif customer_type == 'regular' and quantity > 10:
d = price * 0.1
else:
d = 0
if quantity > 100:
d = d * 1.5
return round(d, 2)
# Characterization test: pins down CURRENT behavior, not necessarily correct behavior
def test_characterize_vip_bulk_discount():
# Observed actual output for these inputs, recorded as a baseline
result = calculate_discount(price=1000, customer_type='vip', quantity=150)
assert result == 300.0 # 1000*0.2=200, then *1.5 for quantity>100
def test_characterize_regular_small_order_gets_no_discount():
result = calculate_discount(price=500, customer_type='regular', quantity=5)
assert result == 0.0Finding Seams to Break Dependencies
A 'seam,' in Feathers' terminology, is a place where you can alter behavior without editing the code at that exact spot - typically by injecting a different implementation. Legacy code often lacks seams because dependencies are instantiated directly inside methods (e.g. calling a real database client or 'new'-ing a service internally) rather than being passed in. The minimal-risk fix is 'extract and override' or introducing a thin interface parameter: extract the hard-to-test call into its own method, then either subclass to override it in a test, or convert it to a constructor/parameter-injected dependency so a test double can be substituted - all while making the smallest possible edit to avoid changing behavior before characterization tests exist.
Cricket analogy: It's like a physio finding one specific joint they can safely test range of motion on without disturbing the rest of a complex injury - a controlled point of access rather than manipulating the whole limb at once.
Feathers' 'sprout method' and 'sprout class' techniques let you add new, tested functionality to legacy code with almost no risk: instead of editing an untested method, write the new logic as a separate, fully-tested method or class, and add a single line to the legacy method that calls it. This confines new code to a testable surface while touching the risky legacy code minimally.
Never refactor and add new behavior in the same change when working with legacy code that lacks tests. First add characterization tests to pin down current behavior, then refactor with those tests as a safety net, then - as a clearly separate step - change behavior deliberately. Mixing these steps makes it impossible to tell whether a test failure came from an accidental regression or an intended change.
- Legacy code is defined by Michael Feathers as 'code without tests,' regardless of its age.
- Characterization tests pin down current actual behavior as a safety net, not a spec of correctness.
- Seams are points where a dependency can be substituted without editing the code at that exact spot.
- Directly instantiated dependencies (databases, services created with 'new' inside methods) are the main obstacle to testability.
- The 'extract and override' and 'sprout method' techniques add minimal, low-risk edits to create testable seams.
- Never mix refactoring and behavior changes in the same step when working without a safety net.
- Small, incremental, test-covered changes are safer than rewrites for code with unknown/undocumented behavior.
Practice what you learned
1. According to Michael Feathers, what defines 'legacy code'?
2. What is the primary purpose of a characterization test?
3. What is a 'seam' in the context of legacy code refactoring?
4. Why should refactoring and behavior changes be kept as separate steps on untested legacy code?
5. What does the 'sprout method' technique achieve?
Was this page helpful?
You May Also Like
Test Isolation and Independence
Understand why each test must run independently of every other test, and the patterns - fresh fixtures, dependency injection, and cleanup - that guarantee it.
Arrange-Act-Assert Pattern
Master the three-phase structure - Arrange, Act, Assert - that keeps unit tests readable, focused, and easy to debug when they fail.
Flaky Tests and How to Fix Them
Learn what makes automated tests flaky, how to systematically diagnose the root cause, and proven techniques to make your test suite deterministic and trustworthy.
Related Reading
Related Study Notes in Software Engineering
Browse all study notesMicroservices Study Notes
Software Architecture · 30 topics
Software EngineeringDesign Patterns Study Notes
Software Design · 30 topics
Software EngineeringSoftware Engineering Study Notes
Python · 40 topics
Software EngineeringGit & Version Control Study Notes
Bash · 40 topics
Software EngineeringSystem Design Study Notes
Architecture · 40 topics