Coding with AI Assistants · Workflows That Work
Refactoring Safely With AI
Assistants refactor quickly and change behaviour quietly; pin current behaviour with characterisation tests first.
The problem
You ask the assistant to "clean up" a 200-line pricing function. The new version is half the length and much easier to read. Two weeks later, finance notices that orders of exactly 10,000 no longer get the bulk discount: the old code checked >= and the new code checks >.
Real-world analogy
Before renovating an old house, you photograph where every pipe and wire runs. Then you can check afterwards that the water still reaches every tap, even if the walls look completely different.
The idea
- Characterisation tests first: capture what the code does today on many inputs, including odd ones, before changing it. These tests describe current behaviour, not ideal behaviour.
- Refactor in small steps (extract a function, rename, simplify one condition), running the tests after each.
- Separate refactoring from behaviour changes: if a bug is found, fix it in its own change, with a test.
- Review the diff for changed conditions: comparisons, rounding, defaults and exception types.
The code
Record the old function's outputs on boundary inputs, then compare the refactored version:
def price_old(quantity, unit_cents):
total = quantity * unit_cents
if total >= 1_000_000:
total = total * 95 // 100
elif quantity >= 100:
total = total * 98 // 100
return total
def price_new(quantity, unit_cents): # "cleaner" version from the assistant
total = quantity * unit_cents
discount = 5 if total > 1_000_000 else 2 if quantity >= 100 else 0
return total * (100 - discount) // 100
inputs = [(1, 500), (99, 1000), (100, 1000), (1000, 1000), (2000, 500), (5000, 300)]
recorded = {args: price_old(*args) for args in inputs} # captured before refactoring
for args, expected in recorded.items():
got = price_new(*args)
print(args, expected, got, "" if got == expected else "<- behaviour changed")
Output
(1, 500) 500 500 (99, 1000) 99000 99000 (100, 1000) 98000 98000 (1000, 1000) 950000 980000 <- behaviour changed (2000, 500) 950000 980000 <- behaviour changed (5000, 300) 1425000 1425000
Common mistakes
- Refactoring without tests that pin current behaviour.
- Letting one big "clean up" mix refactoring with bug fixes and new features.
- Testing only typical inputs, missing boundaries such as
>=versus>. - Trusting that shorter code means equivalent code.
Key points
- Pin current behaviour with characterisation tests before refactoring.
- Refactor in small steps and keep behaviour changes separate.
- Check boundaries, rounding and defaults in the diff.
Further reading: Characterization test · Refactoring (Martin Fowler)
Practice questions
What is a characterisation test?
Answer
A test that records what existing code currently does on a range of inputs, so that a refactoring can be checked for unintended behaviour changes.
Why keep refactoring and behaviour changes in separate commits?
Answer
So each can be reviewed and verified on its own; if tests change, it is clear whether behaviour was meant to change.
Which details in a refactoring diff deserve extra attention?
Answer
Comparison operators and boundaries, rounding, default values, ordering and the exceptions raised, because these change behaviour quietly.
Open in the app to mark lessons complete, take notes and track your progress.