The fault that breaks the fewest tests is your first extraction. Measure blast radius before you cut.
Most refactors start with the biggest mess. That is backwards. The biggest mess has the largest blast radius. It should come last.
Characterization tests can rank your extraction candidates. Deliberately break each boundary. Count the broken probes. Extract ascending.
This is the smallest safe change, measured by behavior risk. Not by line count.
The setup
Here is a messy billing function. It mixes discount math, volume math, tax fallback, and a cache write.
CACHE = {}
TAX_RATES = {"US": 0.08, "DE": 0.19, "JP": 0.10}
def calculate_total(order, region):
if region not in TAX_RATES:
region = "US"
subtotal = 0
for item in order["items"]:
price = item["price"]
if item.get("discount"):
price = price * (1 - item["discount"])
subtotal += price
if subtotal > 100:
subtotal = subtotal * 0.95
tax = subtotal * TAX_RATES[region]
total = subtotal + tax
CACHE[order["id"]] = total
return round(total, 2)
Four boundaries are obvious candidates.
- Discount math.
- Volume discount.
- Tax fallback.
- Cache write.
Where do you start? Guessing is common. Measuring is better.
The example is minimal. Real legacy functions are longer. The method scales.
Step 1: Lock behavior with probes
Write one characterization probe per behavior. Keep each probe small and specific.
def test_discount_applies_to_item_price():
order = {"id": 1, "items": [{"price": 100, "discount": 0.1}]}
assert calculate_total(order, "US") == 97.2
def test_volume_discount_over_100():
order = {"id": 2, "items": [{"price": 150}]}
assert calculate_total(order, "US") == 153.9
def test_unknown_region_falls_back_to_us():
order = {"id": 3, "items": [{"price": 200}]}
assert calculate_total(order, "XX") == 205.2
def test_total_written_to_cache():
order = {"id": 4, "items": [{"price": 200}]}
calculate_total(order, "XX")
assert CACHE[4] == 205.2
Run the suite. All four pass. The baseline is green.
Note the difference from mutation-testing your probes. Here the probes stay untouched. The source boundaries get mutated. You are mapping the code, not auditing the tests.
Step 2: Mutate each boundary
Introduce one deliberate fault per boundary. Run the suite. Record the failures. Revert.
Here is a runner that applies each mutation and prints the pytest summary.
#!/usr/bin/env python3
"""Measure blast radius for each refactoring boundary."""
import subprocess
from pathlib import Path
SOURCE = Path("billing.py")
MUTATIONS = {
"discount": ('if item.get("discount"):', 'if False and item.get("discount"):'),
"volume": ("if subtotal > 100:", "if False and subtotal > 100:"),
"fallback": ("if region not in TAX_RATES:", "if False:"),
"cache": ('CACHE[order["id"]] = total', 'pass # cache write removed'),
}
original = SOURCE.read_text()
for name, (old, new) in MUTATIONS.items():
SOURCE.write_text(original.replace(old, new))
result = subprocess.run(
["pytest", "tests/test_characterization.py", "-q"],
capture_output=True, text=True,
)
tail = result.stdout.strip().splitlines()[-1]
print(f"{name}: {tail}")
SOURCE.write_text(original)
Run it.
python mutation_matrix.py
The output tells you which probes break per boundary.
- Discount mutation: only the discount probe fails. Radius 1.
- Volume mutation: three probes fail. Radius 3.
- Fallback mutation: two probes fail. Radius 2.
- Cache mutation: only the cache probe fails. Radius 1.
Step 3: Build the matrix
| Mutated boundary | Discount | Volume | Fallback | Cache | Radius |
|---|---|---|---|---|---|
| discount math | FAIL | pass | pass | pass | 1 |
| cache write | pass | pass | pass | FAIL | 1 |
| tax fallback | pass | pass | FAIL | FAIL | 2 |
| volume discount | pass | FAIL | FAIL | FAIL | 3 |
The matrix is a dependency map. Low radius means isolated. High radius means entangled.
Read each row as a dataflow. The volume discount feeds the subtotal. The subtotal feeds the tax. The tax feeds the total. The total feeds the cache. Break the volume discount, and every downstream probe fails.
The cache write is the opposite. Nothing reads it inside this function. It is a terminal side effect. Break it, and only its own probe fails.
Step 4: Extract ascending
Extract the radius-1 boundaries first. Discount math.
def apply_discount(price, discount):
if not discount:
return price
return price * (1 - discount)
Wire it in. Run the suite. Green.
Then the cache write.
def write_cache(order_id, total):
CACHE[order_id] = total
Run the suite. Green.
Then the tax fallback.
def resolve_tax_rate(region):
if region not in TAX_RATES:
return TAX_RATES["US"]
return TAX_RATES[region]
Run the suite. Green.
Then the volume discount. Last. Highest radius.
def apply_volume_discount(subtotal):
if subtotal > 100:
return subtotal * 0.95
return subtotal
Run the suite. Green.
The function is now a composition of small verified pieces.
def calculate_total(order, region):
subtotal = sum(
apply_discount(item["price"], item.get("discount"))
for item in order["items"]
)
subtotal = apply_volume_discount(subtotal)
tax = subtotal * resolve_tax_rate(region)
total = subtotal + tax
write_cache(order["id"], total)
return round(total, 2)
Every step stayed green. Every step was the smallest safe change by blast radius.
Why ascending works
A low-radius boundary is isolated. A mistake there corrupts one behavior. You understand it fast.
A high-radius boundary feeds many behaviors. A mistake there corrupts many probes. Defer it. By the time you extract it, the function is smaller. The remaining logic is visible.
The order is not about code size. It is about risk.
There is a second benefit. The matrix tells you which probes are load-bearing. The volume discount is protected by three probes. Those three are your safety net. Never delete them during the refactor. The radius-1 probes are lighter. You can replace them with unit tests later.
Where a free model fits
Drafting the candidate list is mechanical. A free model can propose boundaries and probe functions. MonkeyCode's free model access handles this step.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Treat the model output as a proposal. The mutation matrix is the verdict. The model does not decide the order. The probes do.
The matrix needs one test run per boundary. That is N+1 runs. MonkeyCode's free server option provides a remote environment for that loop. No local setup. No leftover processes.
Limitations
The matrix assumes specific probes. A probe that covers everything covers nothing. Mutation-check your probes first.
The matrix assumes deterministic behavior. Flaky tests produce fake blast radii. Fix flakiness before measuring.
The matrix measures blast radius. It does not measure extraction difficulty. A pure function can have a high radius. Extract it last anyway. The risk justifies the wait.
The matrix can lie when mutations mask each other. Two faults in one run can cancel out. Always mutate one boundary per run. The script above does exactly that.
Who should not use this
Do not use this on greenfield code. Write real tests instead.
Do not use this when the suite takes minutes per run. The matrix multiplies runtime by N+1.
Do not use this when probes are missing. Write probes first. Then measure.
The takeaway
The smallest safe change is a measured property. Blast radius is the metric.
Mutate each boundary. Count the broken probes. Extract ascending.
Run the matrix on your messiest function. The order will surprise you.
Top comments (0)