DEV Community

Dakota Huang
Dakota Huang

Posted on

Let the Free Model Write Inputs, Not Assertions: A Mutation Gate Before Your Refactor

A passing test suite can hide a refactor bug for months. That happens when the tests assert nothing real.

Free coding models make this worse. They produce plausible tests fast. Most of those tests pin the current behavior only by accident.

Here is the rule I use now. Let the model propose inputs. Take expected values from the code as it runs today. Then filter the suite with a small mutation gate.

Only then do I touch the function.

The problem with model-written assertions

A model writing a characterization test makes two guesses. It guesses an input. It also guesses the output.

Those guesses fail differently.

Bad inputs are obvious. They crash, or they never reach the branch you care about. Bad assertions are silent. They lock in behavior the code never had.

So split the job:

Job Who does it Why
Propose risky inputs Free model Cheap, broad, good at edge cases
Record expected output Current code It is the specification
Decide if the suite is strong Mutation gate Coverage cannot tell you

That split is the whole method. Everything below is mechanics.

Step 1: Freeze the target function

Pick one function. Not a module, not a package. One function with one job.

In this example it is a legacy discount calculator. It reads five module-level constants and has never had a test.

# legacy/pricing.py
def compute_discount(cents, tier, months, coupon=None):
    ...
Enter fullscreen mode Exit fullscreen mode

Do not edit it yet. Note the import path. That path is your contract for the rest of the exercise.

Step 2: Build a golden corpus from real inputs

Pull inputs from logs, fixtures, or the bug tracker. Twenty to sixty cases is enough to start.

Store them as data, not as code:

[{ "cents": 1999, "tier": "pro", "months": 12 },
 { "cents": 0, "tier": "free", "months": 1, "coupon": "HALF" }]
Enter fullscreen mode Exit fullscreen mode

Then record what the current code returns. Serialize deterministically, or the goldens will drift on their own.

# goldens.py
import json, pathlib
from legacy.pricing import compute_discount

def serialize(result):
    return json.dumps(result, sort_keys=True, default=str)

cases = json.loads(pathlib.Path("fixtures/pricing_cases.json").read_text())
goldens = {str(i): serialize(compute_discount(**c)) for i, c in enumerate(cases)}
pathlib.Path("fixtures/pricing_goldens.json").write_text(
    json.dumps(goldens, indent=2, sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

Run it once. Commit both files. The goldens are now the ceiling of what you are allowed to change.

Step 3: Ask the model for inputs only

The prompt matters more than the model. Ask for cases, never for expected values.

Below is the signature and a short description of a legacy function.
Propose 25 input combinations that are most likely to break a refactor.
Prioritize: zero and negative values, boundary tiers, coupon interactions,
and values that appear only in one branch. Output JSON list of kwargs only.
Do not include expected outputs. Do not explain.
Enter fullscreen mode Exit fullscreen mode

I run this on MonkeyCode's free model access, because the task is cheap and repetitive and I do not want a paid quota in the loop. Free tokens at the time of writing are advertised by the operator as a generously sized allowance; check the project page for the current number, since terms change.

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

Paste the returned JSON into fixtures/pricing_cases.json. Regenerate goldens. Read every new row.

Reject anything that raises. Reject anything that hits a network or clock path. Keep the rest, even the weird ones.

Step 4: Turn the corpus into characterization tests

One parametrized test. No hand-written assertions.

# tests/test_pricing_char.py
import json, pathlib, pytest
from legacy.pricing import compute_discount
from goldens import serialize

CASES = json.loads(pathlib.Path("fixtures/pricing_cases.json").read_text())
GOLDENS = json.loads(pathlib.Path("fixtures/pricing_goldens.json").read_text())

@pytest.mark.parametrize("idx", range(len(CASES)))
def test_matches_golden(idx):
    assert serialize(compute_discount(**CASES[idx])) == GOLDENS[str(idx)]
Enter fullscreen mode Exit fullscreen mode

Green. Now the important part: this suite might still be worthless.

Step 5: Score the suite with a mutation gate

Coverage tells you the test ran the line. Mutation tells you the test would notice a change.

You do not need a framework. Sixty lines of stdlib is enough for one function.

# mutate.py
import json, pathlib, re, shutil, subprocess, sys, tempfile

TARGET = pathlib.Path("legacy/pricing.py")
TESTS = "tests/test_pricing_char.py"

MUTANTS = [
    (r"<=", "<"),
    (r">=", ">"),
    (r" == ", " != "),
    (r" and ", " or "),
    (r"return 0\b", "return 1"),
]

def suite_green(root):
    p = subprocess.run([sys.executable, "-m", "pytest", "-q", TESTS],
                       cwd=root, capture_output=True, text=True)
    return p.returncode == 0

def main():
    src = TARGET.read_text()
    killed, total = 0, 0
    for pattern, repl in MUTANTS:
        for match in re.finditer(pattern, src):
            total += 1
            with tempfile.TemporaryDirectory() as tmp:
                root = pathlib.Path(tmp)
                shutil.copytree(".", root / "repo", dirs_exist_ok=True)
                mutated = src[:match.start()] + repl + src[match.end():]
                (root / "repo" / TARGET).write_text(mutated)
                if not suite_green(root / "repo"):
                    killed += 1
    print(json.dumps({"killed": killed, "total": total,
                      "score": round(killed / total, 3) if total else None}))

main()
Enter fullscreen mode Exit fullscreen mode

Run it. Read the score, not the percentage sign.

python goldens.py && python mutate.py
# {"killed": 5, "total": 19, "score": 0.263}
Enter fullscreen mode Exit fullscreen mode

The numbers above are illustrative shape, not a measurement of any product. Your score is the only one that matters.

Step 6: Use the score to decide

Low mutation score means your corpus is thin, not that your code is bad. Add inputs, not assertions.

Mutation score Reading Action
under 0.40 The suite runs code but notices little Ask the model for boundary inputs near the surviving mutants
0.40 to 0.70 Partial pinning Hand-add cases only for branches still uncovered
above 0.70 Reasonable for legacy code Proceed to the refactor
1.00 Suspicious Check for equivalent mutants before celebrating

Surviving mutants are free documentation. Each one shows a branch your corpus never exercises.

Loop twice. Three passes rarely pay for themselves.

Step 7: Make the smallest safe change

Now, and only now, edit. One extraction. One rename. Nothing else.

def _tier_rate(tier):
    if tier == "pro":
        return PRO_RATE
    return BASE_RATE
Enter fullscreen mode Exit fullscreen mode

Re-run both scripts. Goldens must match byte for byte. The mutation score must not drop.

If the score drops, your extraction changed a branch. Revert. Do not reorder.

Where a free server fits

The mutation loop is slow and dumb. It copies the repo once per mutant and runs pytest.

That is a batch job, not an interactive one. A free server instance from the same project is a reasonable place to park it, because the workload is short and bursty. Specs and availability are operator-declared and can change, so confirm them before you depend on the box.

Keep the loop in CI as well. A gate you run by hand is a gate you skip.

Limitations

  • Equivalent mutants. Some substitutions cannot change behavior. They cap your score below 1.00 forever. Read survivors before chasing the number.
  • Cost grows fast. Mutants multiply with operators. Bound the run at one function and a few hundred cases.
  • Goldens freeze bugs too. A characterization test commits you to current behavior, defects included. Fix bugs in a separate, labeled commit.
  • Generated inputs need review. A model can propose cases that touch the filesystem or the clock. Filter them before they enter the corpus.
  • This is not a correctness proof. A high mutation score means the suite is sensitive. It does not mean the code is right.

Who should skip this

Skip it if the function has no callers you can enumerate. Skip it if the output is non-deterministic and you cannot stub the source.

Skip it for code under regulatory change control. There, the specification is the artifact, not a recorded snapshot.

And skip it if you cannot commit the golden files. A corpus you regenerate on every run measures nothing.

The takeaway

Free models are strong at enumeration and weak at ground truth. Point them at the first problem.

Record reality with your own runtime, then measure how hard your tests push back. A mutation score of 0.7 before the refactor is worth more than 100 percent coverage after it.

If you want the cheapest place to run step 3, start with a free model endpoint and keep the gate in your own repo.

Top comments (0)