DEV Community

Dakota Huang
Dakota Huang

Posted on

The Smallest Safe Refactor: Lock Behavior, Then Change One Thing

Big refactors fail quietly. The code compiles. The tests pass. Then production breaks at 2 AM.

The root cause is almost never one bad line. It is a hundred lines changed at once. When you refactor a messy repo, you need a different order: lock behavior first, then change exactly one thing.

This article shows a concrete workflow. It uses characterization tests as a safety net, free model access to draft those tests, and a free server to verify the baseline. The goal is a refactor you can revert in ten seconds, not a week.


Why Characterization Tests Come First

A messy repo has no tests. Or it has tests that assert nothing useful. You cannot refactor code you do not understand.

Characterization tests do not tell you what the code should do. They tell you what the code actually does. That is the contract you preserve.

Write tests that call public functions with known inputs. Record the outputs. Treat those outputs as the current behavior, even if the behavior looks wrong.

One example. A legacy pricing function:

def price_with_discount(price, discount):
    if discount > 0.5:
        discount = 0.5
    return price * (1 - discount)
Enter fullscreen mode Exit fullscreen mode

The magic number 0.5 is suspicious. Maybe it is a bug. Maybe it is a business rule. For now, it is just behavior.

Capture it:

def test_price_with_discount_known_inputs():
    assert price_with_discount(100, 0.2) == 80
    assert price_with_discount(100, 0.8) == 50
    assert price_with_discount(0, 0.5) == 0
Enter fullscreen mode Exit fullscreen mode

The second assertion looks strange. A 80% discount should give 20, not 50. But the function caps the discount at 50%. Your refactor must keep that cap unless a human explicitly says otherwise.


Use Free Model Access to Draft More Tests

Two tests are not enough. Edge cases hide in empty inputs, negative numbers, and boundary values.

This is where a free model can help. Instead of writing every case by hand, ask a model to generate a property-based or table-driven test set.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

In my workflow, I use MonkeyCode's free model access to generate candidate test cases. I paste the function signature and the two known cases into the model. It returns a parameterized test with five more edge cases.

Here is the kind of draft it produced:

import pytest

@pytest.mark.parametrize("price,discount,expected", [
    (100, 0.0, 100),
    (100, 0.5, 50),
    (100, 0.51, 50),   # capped
    (50, 1.0, 25),     # cap wins
    (0, 0.3, 0),
])
def test_price_with_discount_param(price, discount, expected):
    assert price_with_discount(price, discount) == expected
Enter fullscreen mode Exit fullscreen mode

Do not trust the draft. Review each case. The model might invent behavior that does not exist. Your job is to run the draft and keep only the cases that match the current implementation.

How to verify: run the generated test. If it fails, the model guessed wrong. Delete that case. If it passes, the case documents real behavior. Keep it.


Run the Baseline on a Free Server

A local run is not enough. You want the same tests executing in a fresh environment. This catches missing dependencies and hidden state.

MonkeyCode's free server option gives you a disposable environment. Push the repo with the characterization tests to a branch. Trigger a run on the free server.

The server executes the suite exactly like a CI job. No local caches. No developer-machine magic. Just the code and the tests.

This baseline run is your contract. Save the output. It is the fingerprint of the behavior before any refactor.


Make the Smallest Safe Change

The whole point is to keep the diff tiny. One change. One meaning.

In the pricing example, the smallest safe change is extracting the magic number:

MAX_DISCOUNT = 0.5

def price_with_discount(price, discount):
    if discount > MAX_DISCOUNT:
        discount = MAX_DISCOUNT
    return price * (1 - discount)
Enter fullscreen mode Exit fullscreen mode

That is it. No logic change. No variable renaming across the codebase. No architecture overhaul.

Run the characterization suite again. On the free server or locally. All tests must pass.

If a test fails, you know exactly what broke. Revert the one change. Re-examine the behavior. The failure is data, not a disaster.


Decision Table: When This Workflow Helps

Situation Use this workflow? Why
No tests, messy function, single responsibility Yes Fast baseline guard for a small refactor
Large module with tangled side effects Partially Lock key entry points, then refactor per function
Behavior already covered by strong tests No You already have a safety net
Rewriting from scratch No Characterization tests lock old behavior you plan to discard

The smaller the change, the more valuable the baseline. A one-line refactor either keeps behavior or breaks it. There is no middle ground.


Limits and Who Should Not Use This

Free model access has limits. Drafted tests can be wrong, incomplete, or confidently hallucinated. Always run them against real code before trusting them.

The free server option is not a full production CI. It is a quick validation endpoint. Do not treat it as a permanent infrastructure replacement.

This workflow fails when the repo has no clear entry points. If a function reads global state or hits a database, characterization tests require stubbing or fixtures. That is extra work. Budget for it.

Also skip this if the team has already decided to replace a module entirely. Locking old behavior means preserving it. Sometimes you want behavior to die.


The Pattern in Five Steps

  1. Pick one function with no test coverage.
  2. Write two or three manual characterization tests.
  3. Use free model access to draft more edge cases.
  4. Run the suite on a free server to create a baseline.
  5. Apply one small refactor and re-run the suite.

That is the whole pattern. It is boring. It is slow. It is safe.

A week of tiny safe changes beats one heroic rewrite. The hero leaves a pile of broken assumptions. The boring refactor leaves a green test suite and a small diff everyone can review.

Next time you face a messy repo, resist the big rewrite. Write the characterization test first. Change one thing. Let the tests decide if you are done.

Top comments (0)