TDD and Refactoring: Characterization Tests for Legacy Code
Characterization tests (Michael Feathers, "Working Effectively with Legacy Code") capture what legacy code currently does, not what it should do. Write them before touching legacy code, use them as a safety net during refactoring, then replace them with proper unit tests once the code is clean. The golden master pattern and ApprovalTests library make this practical for complex output.
Legacy code is any code without tests. That is Michael Feathers' definition, and it is useful because it removes the judgment about age or quality. Code written last week without tests is legacy code. Code from 2009 with a comprehensive test suite is not.
When you inherit a class with thousands of lines, no tests, and a mandate to "just add this one feature without breaking anything," you face the core legacy code problem: you cannot safely change code you do not understand, and you cannot understand code without running it, and you cannot safely run it without tests.
Characterization tests break this deadlock.
What Characterization Tests Are
A characterization test captures the current behavior of a piece of code — whatever that behavior is, correct or incorrect. You are not asserting what the code should do. You are asserting what it does do right now.
This distinction matters. If the legacy code has a bug, your characterization test captures the bug. That is intentional. The test is a safety net for refactoring, not a validation of correctness. Once you have characterized the behavior, you can refactor the structure without changing the behavior, then separately write new tests that assert the correct behavior and fix the bugs.
Feathers describes the process simply: run the code, observe what it produces, assert that it produces that. If the assertion fails, you changed something you did not intend to change.
A Practical Example
Consider a PricingEngine class that has evolved for six years and calculates order totals with discounts, taxes, and promotional rules baked into a single 400-line method:
// Legacy class — do not touch until characterized
public class PricingEngine {
public OrderSummary calculate(Order order, Customer customer, List<Promotion> activePromotions) {
// 400 lines of imperative logic, conditionals, magic numbers
// Several known bugs that "customers have worked around"
// No tests
}
}Before any refactoring, write characterization tests by running the method with representative inputs and recording exactly what comes out:
class PricingEngineCharacterizationTest {
private PricingEngine engine = new PricingEngine();
@Test
void standardOrderWithNoPromotions() {
var order = OrderFixture.standardOrder(); // items totalling $100.00
var customer = CustomerFixture.regularCustomer(); // no special pricing
var promotions = List.of();
var result = engine.calculate(order, customer, promotions);
// CHARACTERIZATION: Record what the code actually produces.
// Do not change these values without understanding why they changed.
assertThat(result.subtotal()).isEqualByComparingTo("100.00");
assertThat(result.taxAmount()).isEqualByComparingTo("8.75"); // 8.75% tax — why? unknown
assertThat(result.discount()).isEqualByComparingTo("0.00");
assertThat(result.total()).isEqualByComparingTo("108.75");
}
@Test
void premiumCustomerReceivesTenPercentDiscount() {
var order = OrderFixture.standardOrder();
var customer = CustomerFixture.premiumCustomer();
var promotions = List.of();
var result = engine.calculate(order, customer, promotions);
assertThat(result.subtotal()).isEqualByComparingTo("100.00");
assertThat(result.discount()).isEqualByComparingTo("10.00");
assertThat(result.taxAmount()).isEqualByComparingTo("8.09"); // tax on discounted amount?
assertThat(result.total()).isEqualByComparingTo("98.09");
}
@Test
void summerSalePromotionAppliesBeforeCustomerDiscount() {
var order = OrderFixture.standardOrder();
var customer = CustomerFixture.premiumCustomer();
var promotions = List.of(PromotionFixture.summerSale()); // 15% off
var result = engine.calculate(order, customer, promotions);
// CHARACTERIZATION: At the time of writing, the two discounts DO NOT stack.
// The higher discount (15%) wins. Premium discount (10%) is ignored.
// Whether this is correct behavior is unknown — characterizing as-is.
assertThat(result.discount()).isEqualByComparingTo("15.00");
assertThat(result.total()).isEqualByComparingTo("91.94");
}
}Add comments explaining that these are characterization tests. The magic numbers in the assertions deserve comment too — you do not know why the tax is 8.75%, but you are recording it. A future reader (probably you) needs to know this is captured behavior, not validated behavior.
The Golden Master Pattern
For code that produces complex or large output (HTML generation, report formatting, data transformations with many fields), characterizing individual fields becomes tedious and brittle. The golden master pattern is the solution.
Instead of asserting individual values, you capture the complete output as a "golden master" file and compare the current output against it. Any difference means behavior changed.
class ReportGeneratorCharacterizationTest {
private ReportGenerator generator = new ReportGenerator();
@Test
void monthlyOrderReportMatchesGoldenMaster() throws Exception {
var orders = OrderFixture.mayOrders(); // fixed dataset
var report = generator.generateMonthlyReport(orders, YearMonth.of(2024, 5));
// Write golden master on first run, compare on subsequent runs
var goldenMasterPath = Path.of("src/test/resources/golden-masters/monthly-report-may-2024.txt");
if (!Files.exists(goldenMasterPath)) {
Files.writeString(goldenMasterPath, report);
fail("Golden master created. Re-run the test to verify.");
}
var expected = Files.readString(goldenMasterPath);
assertThat(report).isEqualTo(expected);
}
}The golden master file goes into source control. On the first run it is created and the test fails with a message to re-run. On subsequent runs, any deviation from the golden master fails the test.
The limitation: golden master tests fail for any change, including whitespace changes and formatting improvements that do not affect behavior. Manage this by keeping the golden master output as semantic as possible (JSON rather than formatted text, for example) and updating the golden master intentionally when you refactor formatting.
ApprovalTests: Golden Master with Better Tooling
The ApprovalTests library formalizes the golden master pattern with tooling that makes it much less painful to manage:
// build.gradle
testImplementation 'com.approvaltests:approvaltests:22.3.3'import org.approvaltests.Approvals;
import org.approvaltests.combinations.CombinationApprovals;
class PricingEngineApprovalTest {
private PricingEngine engine = new PricingEngine();
@Test
void approvePricingForAllCustomerTypes() {
var order = OrderFixture.standardOrder();
var customers = new Customer[]{
CustomerFixture.regularCustomer(),
CustomerFixture.premiumCustomer(),
CustomerFixture.vipCustomer()
};
var promotionSets = new List[]{
List.of(),
List.of(PromotionFixture.summerSale()),
List.of(PromotionFixture.clearance(), PromotionFixture.loyaltyBonus())
};
CombinationApprovals.verifyAllCombinations(
(customer, promotions) -> engine.calculate(order, customer, promotions).toString(),
customers,
promotionSets
);
}
@Test
void approveMonthlyReport() {
var report = new ReportGenerator().generateMonthlyReport(
OrderFixture.mayOrders(), YearMonth.of(2024, 5)
);
Approvals.verify(report);
}
}On the first run, ApprovalTests writes the actual output to a received file and opens a diff tool (or fails with instructions). You review the output, confirm it is the current behavior, rename the file to approved, and commit it. Future runs compare against the approved file.
ApprovalTests supports:
- Text, HTML, JSON, XML output
- Combination testing (all combinations of inputs)
- Sequence testing (lists of outputs)
- Reporter configuration (diff tools, CI-safe reporters)
For CI, configure a reporter that fails rather than opening a GUI:
// In test setup or ApprovalTests.properties
@Before
public void configureApprovals() {
if (System.getenv("CI") != null) {
Approvals.setReporter(new DiffReporter());
}
}The Safe Refactoring Workflow
With characterization tests in place, the refactoring workflow is:
Step 1: Characterize
Write enough characterization tests that you are confident any behavior change will be caught. For complex code, "enough" means covering:
- Happy paths with representative inputs
- Boundary conditions you can identify from reading the code
- Any cases mentioned in comments or commit history as "tricky"
Do not aim for 100% coverage at this stage. Aim for enough confidence to refactor.
Step 2: Refactor Structurally
Extract methods, rename variables, introduce explaining variables, split responsibilities. Do not change behavior — only structure.
// Before: 400-line method
public OrderSummary calculate(Order order, Customer customer, List<Promotion> promotions) {
// ... 400 lines
}
// After: Same behavior, decomposed
public OrderSummary calculate(Order order, Customer customer, List<Promotion> promotions) {
var subtotal = calculateSubtotal(order);
var promotionDiscount = applyPromotions(subtotal, promotions);
var customerDiscount = applyCustomerDiscount(subtotal - promotionDiscount, customer);
var taxableAmount = subtotal - Math.max(promotionDiscount, customerDiscount);
var tax = calculateTax(taxableAmount, customer.billingAddress());
return new OrderSummary(subtotal, Math.max(promotionDiscount, customerDiscount), tax);
}Run the characterization tests after every extraction. If they pass, the refactoring preserved behavior. If they fail, undo and investigate.
Step 3: Verify
All characterization tests still green. The behavior is unchanged. The structure is cleaner.
Step 4: Write Proper Tests
Now that the code has structure — separate methods with clear responsibilities — write proper unit tests for each extracted method:
@Test
void promotionDiscountIsHigherOfPromotionOrCustomerDiscount() {
// This was implicit in the characterization tests.
// Now it is explicit and documented.
var subtotal = new BigDecimal("100.00");
var promotions = List.of(PromotionFixture.summerSale()); // 15%
var customer = CustomerFixture.premiumCustomer(); // 10%
var discount = engine.applyBestDiscount(subtotal, promotions, customer);
assertThat(discount).isEqualByComparingTo("15.00");
}Step 5: Fix Known Bugs
With proper unit tests in place, you can now fix the bugs that were characterized (not validated) in step 1. Update the tests to reflect correct behavior, then update the implementation:
// Was characterized as: tax applied after promotion discount
// Should be: tax applied after ALL discounts (customer + promotion)
// Fix the test first, then fix the implementation
@Test
void taxIsCalculatedOnFinalDiscountedAmount() {
var order = OrderFixture.orderTotal("100.00");
var customer = CustomerFixture.premiumCustomer(); // 10% discount
var promotions = List.of(PromotionFixture.summerSale()); // 15% wins
var result = engine.calculate(order, customer, promotions);
// Correct: tax on $85.00 (after 15% discount), not $90.00 (after 10%)
assertThat(result.taxableAmount()).isEqualByComparingTo("85.00");
}What to Do When Tests Reveal Surprising Behavior
Characterization tests often surface behavior that nobody knew existed. You run the code with an edge case and get a result that seems wrong. Now what?
Do not fix it yet. Add a comment to the characterization test:
@Test
void orderWithZeroPriceItemsProducesNegativeTax() {
// CHARACTERIZATION: This appears to be a bug — negative tax should not be possible.
// Do not fix during this refactoring pass. File JIRA-4521.
// Current behavior is documented here so refactoring does not accidentally change it.
var result = engine.calculate(order, customer, promotions);
assertThat(result.taxAmount()).isEqualByComparingTo("-2.25"); // negative!
}File the bug. Fix it in a separate commit with a clear commit message. This keeps refactoring commits clean and bug-fix commits separately reviewable.
Conclusion
Characterization tests are not glamorous. They capture possibly-incorrect behavior and encode it in assertions. They are a means, not an end.
But they are the only safe path into a legacy codebase. Without them, every refactoring is a leap of faith. With them, you have a net. The net is not perfect — it catches what you characterized, not what you missed — but it is infinitely better than nothing.
The workflow is clear: characterize → refactor → write proper tests → fix bugs. Each step is reversible. Each step is independently verifiable. You move through legacy code not by hoping you didn't break anything, but by knowing, because the tests tell you.