Card 05/ 07
Refactoring Without Tests
Each step was correct on paper, and the discount quietly stopped applying.
A refactoring is only safe when tests pin today's behaviour before the first change, and a design that looks correct is no substitute for them. This card shows a single open/closed step that changed behaviour without any error, and the test that would have caught it.
The expectation is that a refactoring which only reorganises code cannot change results, so tests can be written afterwards against the new design. Here is a pricing function that branches on customer type, and its refactored version, which looks up a table of rates. The trace runs both.
RATES = {"member": 0.9, "staff": 0.7}
def total_before(subtotal, customer_type):
if customer_type.lower() == "member":
return subtotal * 0.9
if customer_type.lower() == "staff":
return subtotal * 0.7
return subtotal
def total_after(subtotal, customer_type):
return subtotal * RATES.get(customer_type, 1.0)
def pinning_test(total):
result = f"{total(15.00, 'Member'):.2f}"
return "passed" if result == "13.50" else f"FAILED: expected 13.50, got {result}"
print(f"before, 'member': {total_before(15.00, 'member'):.2f}")
print(f"after, 'member': {total_after(15.00, 'member'):.2f}")
print(f"before, 'Member': {total_before(15.00, 'Member'):.2f}")
print(f"after, 'Member': {total_after(15.00, 'Member'):.2f}")
print(f"pinning test on before: {pinning_test(total_before)}")
print(f"pinning test on after: {pinning_test(total_after)}")before, 'member': 13.50
after, 'member': 13.50
before, 'Member': 13.50
after, 'Member': 15.00
pinning test on before: passed
pinning test on after: FAILED: expected 13.50, got 15.00Reading the trace
Lower case agrees
For 'member', both versions return 13.50. A developer checking the new version by hand tries this case and sees it work.
Capitalised input disagrees
For 'Member', the old version returns 13.50 and the new one 15.00. The old code called .lower(), so it accepted any capitalisation. The table lookup does not.
Nothing raises
RATES.get(customer_type, 1.0) falls back to no discount for an unknown key, so the wrong answer is a plausible price, not an error.
The pinning test catches it
A test written before the refactoring, with the input the checkout form really sends, passes on the old version and fails on the new one.
The .lower() call was behaviour, not structure, and it was easy to drop because nobody had written down that it mattered. A test written after the refactoring would have been written against the new version, with lower-case input, and passed. Only a test written before the change records what the old code actually did.
The discount stopped applying for every member whose form sent a capitalised value, and nothing in the code would have flagged it until a customer noticed. Write the tests first, against the code as it is, with the inputs it really receives. They are what makes each refactoring step a reorganisation rather than a guess.
← → move through sections and cards