Automated Safety Nets for Refactoring — Tasks¶
Source: Michael Feathers, Working Effectively with Legacy Code; Martin Fowler, Refactoring (2nd ed.)
Eight exercises. Try each before reading the worked solution. The goal is the judgment — what net, where, why — not just the code.
Task 1 — Write a characterization test for legacy code (warm-up)¶
Given this untested, unspecified method, build a characterization test before any refactoring.
public class TaxBracket {
public int taxCents(int incomeCents) {
if (incomeCents <= 1_000_000) return 0;
if (incomeCents <= 4_000_000) return (incomeCents - 1_000_000) / 10; // 10%
return 3_000_000 / 10 + (incomeCents - 4_000_000) * 2 / 10; // 20% over 4M
}
}
Worked solution
Use the golden-master loop: placeholder → read actual → pin. Hit each bracket *and its boundaries*.@Test
void characterizeTax() {
TaxBracket t = new TaxBracket();
assertEquals(0, t.taxCents(1_000_000)); // boundary, still 0%
assertEquals(1, t.taxCents(1_000_010)); // just into 10%: 10/10
assertEquals(300_000, t.taxCents(4_000_000)); // top of 10% bracket
assertEquals(300_002, t.taxCents(4_000_010)); // just into 20%: 300000 + 10*2/10
assertEquals(500_000, t.taxCents(5_000_000)); // 300000 + 1000000*2/10
}
Task 2 — Spot the net hole (mutation reasoning by hand)¶
This suite claims to protect clamp. Find at least two behavior changes it would not catch — surviving mutants — then add tests to kill them.
static int clamp(int x, int lo, int hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
@Test void inRange() { assertEquals(5, clamp(5, 0, 10)); }
@Test void belowLo() { assertEquals(0, clamp(-3, 0, 10)); }
Worked solution
Surviving mutants this suite misses: 1. **The `x > hi` branch is unprotected.** Mutating `return hi` to `return x`, or deleting the upper clamp entirely, keeps both tests green — no input exceeds `hi`. 2. **Boundary `<` vs `<=`.** Changing `x < lo` to `x <= lo` is not caught — no test pins `clamp(0, 0, 10)`. Kill them: Lesson: line coverage alone hides these holes. Boundary + each-branch reasoning — exactly what a mutation tester automates — is what closes them.Task 3 — Generate a golden master for a big, non-deterministic output¶
InvoiceRenderer.toText(Invoice) returns ~60 lines including a header with LocalDateTime.now() and a random confirmationId. Write an approval test that won't be flaky.
Worked solution
You cannot snapshot the raw output — the timestamp and id change every run, so it fails every time and trains the team to rubber-stamp. **Scrub the non-determinism**, then approve: Workflow: the first run produces `rendersInvoice.received.txt`. You **read** it (the non-skippable step), confirm it is the correct current output, and rename it to `.approved.txt`, committing it as a reviewed artifact. From then on any real behavior change shows up as a diff; the scrubbed fields never cause spurious failures. If you *cannot* scrub a field cleanly, do not snapshot it — assert it separately (e.g., "confirmationId matches `[A-Z0-9]{8}`").Task 4 — Choose the right net for four changes¶
For each, name the cheapest adequate net and justify it.
a) Rename getTotal() → getGrandTotal() across a typed Java codebase via the IDE. b) Extract a 40-line method out of a PricingEngine that already has solid unit tests. c) Restructure a 500-line legacy ReportBuilder with no tests and no documentation. d) Swap a hand-rolled sort for the library sort inside a hot path; behavior must be identical.
Worked solution
- **(a) Compiler only.** A type-aware IDE rename plus a clean compile *is* the net; the compiler finds every call site. Adding tests purely for this rename is gold-plating. - **(b) Existing unit tests.** Intent is already pinned; run them after the extract. Extract Method is mechanical and the suite catches any slip. No new net needed. - **(c) Characterization first, then refactor.** No intent is known, so you cannot write should-be tests. Golden-master the observable output over a wide input sweep, *then* restructure under that net. If the report is critical, run mutation testing on the new characterization suite first to confirm it has teeth. - **(d) Property-based / oracle test.** Keep the old sort, assert the new one always agrees with it over thousands of generated arrays, plus the invariant "result is sorted and a permutation of the input." An algorithm swap is exactly where invariants beat fixed examples.Task 5 — Turn a coverage-only test into a real net¶
This test exists only to hit a coverage target. Make it an actual net without changing the production code.
@Test
void covered() {
DiscountPolicy p = new DiscountPolicy();
p.apply(new Cart(120.0, "GOLD")); // 100% line coverage, asserts nothing
}
Worked solution
The test runs the method but a mutant changing the discount math survives — it is a false net. Replace coverage with *catch* by asserting the observable result and pinning the branches:@Test
void goldOverHundredGetsFifteenPercent() {
assertEquals(102.0, new DiscountPolicy().apply(new Cart(120.0, "GOLD")), 1e-9);
}
@Test
void standardTierNoDiscount() {
assertEquals(120.0, new DiscountPolicy().apply(new Cart(120.0, "STANDARD")), 1e-9);
}
@Test
void goldUnderThresholdNoDiscount() {
assertEquals(80.0, new DiscountPolicy().apply(new Cart(80.0, "GOLD")), 1e-9);
}
Task 6 — Run mutation testing and act on a survivor¶
You run PIT on ShippingCalculator and get:
>> Generated 22 mutations Killed 19 (86%)
>> SURVIVED: ShippingCalculator.java:9 removed conditional - replaced equality check with true (country.equals("CA"))
Explain what the survivor means and write the test that kills it.
Worked solution
The mutant forced the `country.equals("CA")` branch to always be taken. No test failed, which means **no test exercises a non-CA, non-US country** — the `else` multiplier path is unprotected. Anyone refactoring the country logic could break the default multiplier and the suite would stay green. Kill it by pinning a country in *each* multiplier branch: `defaultMultiplier` now fails if the CA condition is forced true (DE would wrongly get 1.3), killing the mutant. General rule: a surviving conditional-boundary mutant points at an untested branch *or* an untested boundary; add the input that distinguishes the two sides.Task 7 — Build a contract test for a service boundary¶
checkout calls orders-api at GET /orders/{id} and reads id, status, totalCents. The teams deploy independently. Sketch the consumer-side contract that prevents a silent break, and explain what catches a provider mistake.
Worked solution
The consumer declares what it needs; a broker shares that expectation; the provider is verified against it.@Pact(consumer = "checkout", provider = "orders-api")
public RequestResponsePact orderExists(PactDslWithProvider b) {
return b.given("order 42 exists")
.uponReceiving("get order 42")
.path("/orders/42").method("GET")
.willRespondWith().status(200)
.body(newJsonBody(o -> {
o.numberType("id", 42);
o.stringType("status", "PAID");
o.numberType("totalCents", 1080);
}).build())
.toPact();
}
Task 8 — Diagnose an over-pinned characterization test¶
A teammate wrote this before refactoring OrderProcessor. Their refactoring is behavior-preserving, yet the test fails. Diagnose and fix.
@Test
void characterize() {
var spy = new SpyDb();
new OrderProcessor(spy).process(sampleOrder());
assertEquals(List.of(
"BEGIN", "INSERT order", "INSERT line", "INSERT line", "UPDATE total", "COMMIT"),
spy.methodCallLog()); // pins the exact internal call sequence
}
Worked solution
This test pins **internals** — the exact sequence of DB calls — not **observable behavior**. A legitimate refactoring (batching the two line inserts, reordering the total update before commit, using an upsert) changes the call log without changing the *result* in the database. So the net fails on a good refactoring: it has inverted its purpose and now *blocks* the change it was meant to protect. This is over-pinning. Fix: pin the **observable effect** — the final state of the data — not how it got there: Now any refactoring that produces the same final rows passes, and any that changes the data fails. The net watches behavior, ignores structure — which is the entire definition of a refactoring safety net.In this topic