Refactor old code without breaking it.

A worked Python example: 264 recorded cases, one deliberate break, one clean refactor and one helpful fix the tests caught.

Scaffolding covering the face of a building
Photo by Reto Simonet on Unsplashdithered by Cyborb

To refactor legacy code with AI without breaking it, pin down what the code does today before you change anything. Have the agent map the code and write characterization tests that record its current outputs. Then refactor in small commits and rerun those tests after each one. If every recorded output still matches, the behavior did not change.

The agent can do most of the typing. Your job is to make sure the safety net is real.

The short version
  • Refactoring changes how code is built, not what it does. Your tests have to prove the second part.
  • Ask the agent for a map of the code before you ask for any change.
  • Record current behavior with characterization tests, including behavior that looks like a bug.
  • Break the code on purpose once, to prove the tests can fail.
  • Make one change per commit, run the tests every time, and fix bugs in separate commits.

Why legacy code breaks when an AI refactors it

Legacy code here means code you depend on but do not fully understand, usually with few tests. Martin Fowler defines refactoring as restructuring code “without changing its external behavior.” With legacy code, nobody wrote down what that behavior is.

That is where agents get into trouble. They read the code, form a view of what it should do and tidy toward that view. An odd branch looks like a bug, so they fix it. Sometimes a customer, a report or another service depends on exactly that oddity.

Michael Feathers tells a story from early in his career: he fixed a bug, and users complained, because they relied on the old behavior. A refactor should never surprise anyone that way.

Step 1: have the agent map the code first

Before any change, ask for a map, not a fix. An agent reads a large module in seconds, which makes it a good guide to unfamiliar code.

PromptMap this code before we touch it
Read [file or module] and everything that calls it. Do not change anything yet.
1. Explain what it does in plain English, in one paragraph.
2. List every public function and who calls it, with file paths.
3. List side effects: database writes, files, network calls, global state, the clock and randomness.
4. List anything surprising: odd branches, magic numbers, behavior that looks like a bug.
5. Suggest inputs a characterization test should cover, including edge cases.

Spot-check the answer with a search of your own, because a map can be wrong too. The list of surprises is the most valuable part. Each item is either a bug to fix later or behavior someone relies on.

Step 2: pin current behavior with characterization tests

Here is a small piece of legacy code: a shipping price function with nested rules and magic numbers. Note the quirk: the country check is case-sensitive, so an order with "us" pays the international rate.

shipping.py (before)
def shipping_cost(weight, country, express=False, member=False):
    if country == "US":
        if weight <= 1:
            cost = 5
        elif weight <= 5:
            cost = 5 + (weight - 1) * 1.5
        else:
            cost = 11 + (weight - 5) * 1.2
    elif country in ("CA", "MX"):
        cost = 8 + weight * 2
    else:
        cost = 15 + weight * 3
        if weight > 20:
            cost = cost * 0.9
    if express:
        cost = cost * 1.5 + 4
    if member and cost > 10:
        cost = cost - 3
    return round(cost, 2)

One test can pin all of it. It runs 264 combinations (11 weights, 6 country codes, express on or off, member on or off) and compares them with a recorded file. This style is often called a golden master.

test_shipping_golden.py
import itertools
import json
import pathlib

import pytest

from shipping import shipping_cost

WEIGHTS = [-1, 0, 0.5, 1, 1.01, 3, 5, 5.5, 20, 20.5, 50]
COUNTRIES = ["US", "us", "CA", "MX", "FR", ""]
FLAGS = [False, True]
GOLDEN = pathlib.Path(__file__).with_name("shipping_golden.json")


def current_behavior():
    return {
        f"{w}|{c}|{express}|{member}": shipping_cost(w, c, express, member)
        for w, c, express, member in itertools.product(WEIGHTS, COUNTRIES, FLAGS, FLAGS)
    }


def test_shipping_cost_has_not_changed():
    actual = current_behavior()
    if not GOLDEN.exists():
        GOLDEN.write_text(json.dumps(actual, indent=1))
        pytest.skip("Recorded the golden master. Commit it, then run again.")
    assert actual == json.loads(GOLDEN.read_text())

The first run records the outputs and skips. The second run compares. This is the real output with Python 3.14 and pytest 9.1:

Text
$ pytest -q
s                                                                        [100%]
1 skipped in 0.01s
$ pytest -q
.                                                                        [100%]
1 passed in 0.00s

Commit shipping_golden.json. It is now the definition of correct. If your code talks to a database or an API, record at that boundary instead. Feed in fixed inputs, capture the writes and responses, and fake the clock and random numbers.

Step 3: prove the tests can fail

A test that cannot fail is decoration. Break the code on purpose once. Here, cost > 10 became cost >= 10:

Text
E     Omitting 262 identical items, use -vv to show
E     Differing items:
E     {'1|MX|False|True': 7} != {'1|MX|False|True': 10}
E     {'1|CA|False|True': 7} != {'1|CA|False|True': 10}
...
1 failed in 0.02s

A one-character change moved two of 264 cases, and the test caught both. If a deliberate break still passes, your inputs miss that path, so add cases. Mutation testing tools automate this check: mutmut for Python and Stryker for JavaScript, TypeScript, C# and Scala make many small breaks and report the ones your tests miss.

Step 4: refactor in small, reversible steps

Now the agent can change the structure. Commit first and work on a branch, so every step is easy to undo. Our git guide for AI coding covers the commands.

PromptRefactor in small steps
Refactor [file] so that [goal, such as: each pricing rule is a small named function].
Behavior must not change. Run [test command] after every step and show me the result.
Do one refactoring per step, such as extract function, rename or remove duplication, and commit each step with a message that says what moved.
Do not fix bugs, even obvious ones. List them for me instead.
Do not edit the tests or the recorded output file.

In the example, the refactored version splits the rates into three small functions and names the magic numbers. All 264 cases still match: 1 passed.

If a step turns the tests red, revert it rather than debugging a half-finished change. Reverting a small step costs seconds.

When the code is too big: the strangler fig pattern

Some code is too large or too tangled to refactor in place. For that, Martin Fowler describes the strangler fig pattern, named after vines that grow around a host tree until they can stand alone. You grow new code around the old system and move one piece at a time.

  1. Put a seam in front of the old code

    Route all callers through one entry point, such as a facade, an API route or a single function. That seam is where you will switch from old to new.

  2. Build one small piece anew

    Pick a slice with clear inputs and outputs. Have the agent build the new version against the recorded behavior of the old one.

  3. Run old and new side by side

    Send the same inputs to both and compare the outputs. Log every difference and explain each one before you switch.

  4. Switch over, then delete

    Move callers to the new piece one at a time, with a quick way back. Remove the old code once nothing calls it.

Fowler’s point is that this spreads risk and reward over time instead of betting everything on one big switch. For framework upgrades, language ports and other large moves, see our guide to migrating a codebase with AI.

How do you prove behavior did not change?

Passing tests are the evidence, but only if the tests themselves are trustworthy. Use this list before you merge.

Before you merge a refactor0 of 7

The third item matters most with agents. When a test fails, some agents edit the test or regenerate the recorded file. Our guides to test-driven development with AI and reviewing AI-written code cover how to catch that.

FAQ

Can AI refactor legacy code safely?

Yes, when tests pin the current behavior first and each change is small enough to check. Without those tests, you are trusting the agent’s reading of the code, and a confident misreading looks exactly like a correct one.

What if the legacy code has no tests at all?

That is the usual case, and characterization tests are how you start. Record outputs for a wide spread of inputs, including odd and invalid ones, before changing anything.

Should the agent fix bugs it finds while refactoring?

Not in the same commit. A refactor that also changes behavior cannot be proven safe by the characterization tests. List the bugs, finish the refactor, then fix each one in its own commit with its own test.

How big should each refactoring step be?

One named change at a time, such as extracting a function or renaming a variable. Small steps keep each diff easy to review and each mistake cheap to undo.

Read next: spec-driven development for planning bigger changes, or debugging with AI for when something does break.

Sources
  1. Refactoring, Martin Fowler, accessed September 2026
  2. Strangler Fig, Martin Fowler, August 2024
  3. Characterization testing, Michael Feathers, August 2016
  4. mutmut, mutmut on GitHub, accessed September 2026
  5. Stryker Mutator, Stryker, accessed September 2026
cyborb.ai

Stop reading about it. Build it.

Describe what you want in plain words. Cyborb plans the work, writes and runs the code, makes the assets, and puts the result online.

Download Cyborb

Free to start. No card required.