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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

Characterisation test catches a changed boundaryTry it yourself
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.

Was this page helpful?