Characterization tests must pin object identity before any extract. Equality assertions hide copies that break call-site aliases. Extract one in-place helper only after those pins exist.
The bug class
Messy helpers often mutate caller-owned lists in place. They also rewrite nested dictionaries without making copies.
A later extract can return a new equal list. Callers holding a second alias then see stale data. Tests that only compare values miss the split.
This failure is not a style problem. It is a semantic contract problem for callers. In-place mutation is part of the public behavior.
Facts the dump must freeze
Pin four facts, not one summary hash. Record identity, content, exceptions, and alias updates together.
| Fact | Probe | Failure signal |
|---|---|---|
| Return identity | result is records |
helper copied the container |
| Item identity | result[0] is original_row |
helper rebuilt nested rows |
| Content | canonical JSON bytes | helper dropped or reordered keys |
| Exceptions | type name plus message string | helper swallowed or wrapped errors |
| Alias view | second name reflects mutation | helper rebound a local name |
Do not start this work with a rewrite. Start with a dump of current behavior. The dump is the specification until you choose a behavior change.
Example mutator (illustrative)
The module below is only a teaching fixture. It is not a production service or benchmark. It mixes filtering, sorting, and logging in one function.
# messy_normalize.py
from __future__ import annotations
import json
from typing import Any
def normalize_records(
records: list[dict[str, Any]],
log: list[str],
drop_empty: bool = True,
) -> list[dict[str, Any]]:
if not isinstance(records, list):
raise TypeError("records must be a list")
if len(records) == 0:
raise ValueError("records must not be empty")
if drop_empty:
kept = 0
for row in records:
name = row.get("name")
if name is None or str(name).strip() == "":
log.append("drop")
continue
records[kept] = row
kept += 1
del records[kept:]
for row in records:
extra = row.pop("scratch", None)
if extra is not None:
log.append("scratch")
row["name"] = str(row["name"]).strip()
records.sort(key=lambda r: r["name"])
log.append(json.dumps([r["name"] for r in records], separators=(",", ":")))
return records
The function returns the same list object on the happy path. Nested dictionaries stay the same objects after mutation. A naive extract often allocates a new list with copied dicts.
Value equality tests still pass after that copy. Alias tests fail because the original list is untouched.
Step 1 — Capture a characterization dump
Write a recorder first, not a refactor. Run the current function against a fixed input set. Persist identity flags and JSON, not prose notes.
# record_normalize.py
from __future__ import annotations
import json
from pathlib import Path
from messy_normalize import normalize_records
CASES = [
{
"id": "happy_trim_and_sort",
"records": [
{"name": " zeta", "scratch": 1},
{"name": "alpha", "n": 2},
{"name": " ", "scratch": 9},
{"name": "Beta"},
],
"drop_empty": True,
},
{
"id": "keep_empty_names",
"records": [
{"name": " ", "n": 1},
{"name": "alpha"},
],
"drop_empty": False,
},
{
"id": "reject_empty_container",
"records": [],
"drop_empty": True,
},
{
"id": "reject_wrong_type",
"records": {"name": "x"},
"drop_empty": True,
},
]
def canonicalize(obj: object) -> str:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
def main() -> None:
dump = []
for case in CASES:
records = case["records"]
alias = records
log: list[str] = []
entry = {"id": case["id"]}
try:
before_container = id(records) if isinstance(records, list) else None
before_items = [id(x) for x in records] if isinstance(records, list) else []
result = normalize_records(
records, # type: ignore[arg-type]
log,
drop_empty=case["drop_empty"],
)
entry["ok"] = True
entry["same_container"] = (
isinstance(records, list)
and result is alias
and id(result) == before_container
)
if isinstance(result, list):
entry["kept_item_was_original"] = [
id(x) in set(before_items) for x in result
]
entry["result_json"] = json.loads(canonicalize(result))
entry["alias_json"] = json.loads(canonicalize(alias))
entry["log_json"] = json.loads(canonicalize(log))
except Exception as exc:
entry["ok"] = False
entry["exc_type"] = type(exc).__name__
entry["exc_msg"] = str(exc)
entry["log_json"] = json.loads(canonicalize(log))
dump.append(entry)
Path("normalize_dump.json").write_text(
json.dumps(dump, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if __name__ == "__main__":
main()
Run the recorder once against the untouched module. Use the commands below on the same tree.
python record_normalize.py
python -c "from pathlib import Path; print(Path('normalize_dump.json').read_text())"
Commit normalize_dump.json on the same working branch. Treat it as a fixture, not as logs.
Step 2 — Convert the dump into pytest pins
Do not hand-write expected dictionaries from memory. Load the dump inside the test module. Assert identity and content against that dump.
# test_normalize_characterization.py
from __future__ import annotations
import json
from pathlib import Path
import pytest
from messy_normalize import normalize_records
DUMP = json.loads(Path("normalize_dump.json").read_text(encoding="utf-8"))
CASES = {row["id"]: row for row in DUMP}
def test_happy_path_keeps_container_identity():
records = [
{"name": " zeta", "scratch": 1},
{"name": "alpha", "n": 2},
{"name": " ", "scratch": 9},
{"name": "Beta"},
]
first_kept = records[0]
second_kept = records[1]
alias = records
log: list[str] = []
result = normalize_records(records, log, drop_empty=True)
gold = CASES["happy_trim_and_sort"]
assert gold["ok"] is True
assert result is alias
assert result is records
assert first_kept in result
assert second_kept in result
assert json.dumps(result, sort_keys=True) == json.dumps(
gold["result_json"], sort_keys=True
)
assert json.dumps(alias, sort_keys=True) == json.dumps(
gold["alias_json"], sort_keys=True
)
assert json.dumps(log, sort_keys=True) == json.dumps(
gold["log_json"], sort_keys=True
)
def test_drop_empty_false_keeps_blank_name_row():
records = [{"name": " ", "n": 1}, {"name": "alpha"}]
blank = records[0]
log: list[str] = []
result = normalize_records(records, log, drop_empty=False)
gold = CASES["keep_empty_names"]
assert result is records
assert blank is result[0] or blank in result
assert json.dumps(result, sort_keys=True) == json.dumps(
gold["result_json"], sort_keys=True
)
def test_empty_container_raises_value_error():
gold = CASES["reject_empty_container"]
log: list[str] = []
with pytest.raises(ValueError) as info:
normalize_records([], log, drop_empty=True)
assert gold["ok"] is False
assert gold["exc_type"] == "ValueError"
assert str(info.value) == gold["exc_msg"]
def test_wrong_type_raises_type_error():
gold = CASES["reject_wrong_type"]
log: list[str] = []
with pytest.raises(TypeError) as info:
normalize_records({"name": "x"}, log, drop_empty=True) # type: ignore[arg-type]
assert gold["exc_type"] == "TypeError"
assert str(info.value) == gold["exc_msg"]
These tests are characterization tests, not design tests. They freeze current behavior, including odd error messages. They do not approve or reject the existing design.
Use pytest.raises for the exception cases here. See the pytest assertion docs for this pattern.
Step 3 — Prove the suite fails on a copy extract
Introduce a failing experiment on a throwaway branch. Do not keep this experimental change in history.
The block below is an unexecuted counter-example. It exists only to prove the pin.
# experimental only — do not commit
def normalize_records(records, log, drop_empty=True):
cleaned = [dict(row) for row in records]
# ...repeat the rest of the logic on cleaned...
return cleaned
Run the same pytest file against that experiment. Equality checks on JSON may still pass. The result-is-alias assertion must fail here.
pytest test_normalize_characterization.py -q --tb=short
If it does not fail, the pin is too weak. Strengthen the pin before any real extract.
Revert the experimental copy immediately after the run. The red result is the required proof. Keep the proof in the review notes.
Step 4 — Extract one mutator only
Change only one helper in this step. Leave sorting and logging in the original function. Extract only the empty-name drop, and keep it in-place.
def drop_empty_names(
records: list[dict[str, Any]],
log: list[str],
) -> None:
kept = 0
for row in records:
name = row.get("name")
if name is None or str(name).strip() == "":
log.append("drop")
continue
records[kept] = row
kept += 1
del records[kept:]
Call that helper from normalize_records and nothing else. Do not change the original return value yet. Do not copy rows or rename keys here.
That sequence is the smallest safe change. One behavior, one helper, and the same objects.
The original compact loop still mutates records. That in-place contract stays frozen until a later change.
Step 5 — Re-run the characterization file
Run the same tests after the extract. Do not add new feature tests yet.
pytest test_normalize_characterization.py -q --tb=short
python record_normalize.py
git diff -- normalize_dump.json
The git diff on the dump must stay empty. A dump drift means the extract changed behavior. Restore the helper and shrink the edit.
How to choose the next extract
Pick the helper that touches one concern. Drop-empty is one concern. Sorting is another concern.
Do not extract two concerns in one patch. The dump cannot attribute a failure then. Bisect becomes guesswork.
If drop-empty still shares a function with logging, split later. Identity pins stay green during the wait. Resist the cleanup urge.
Using a free model only after the dump exists
Disclosure: This article was prepared as part of MonkeyCode's product outreach. A coding model can draft pytest pins from normalize_dump.json. MonkeyCode's free model access is one place to do that.
MonkeyCode's free server option can run the pytest file. The dump still comes from your local module. The model does not get to invent expected values.
Paste the dump and the mutator signature only. Do not paste secrets, tokens, or customer records. Generated assertions are proposals until pytest passes against the dump.
If the model rewrites the mutator, reject that patch. If you already use the free server, run this pytest file there.
Limitations
The id builtin is a process-local CPython probe. Do not persist it across tests or processes. Use the is operator inside one test process.
See the Python id() docs for that scope. Identity is not a durable key.
Canonical JSON cannot encode every Python value. Datetime, bytes, and sets need an explicit default. Prefer hashes for binary fields and ISO strings for datetimes.
This workflow preserves existing bugs on purpose. Do not fix sort order in the same extract. Behavior change needs a new test that states the new contract.
Update the dump only after that new contract test exists. Do not silently refresh gold files.
Do not use this method for networked mutators. HTTP calls need transport stubs before identity pins. Database writes need a transaction fixture first.
Identity pins will not catch those I/O leaks. Add stubs before any extract in those modules.
Skip this approach when the function is already pure. Pure functions have no alias contract to pin. Value tests are enough for those functions.
Skip it when the team is deleting the module. Characterization cost is wasted on doomed code.
Skip it when inputs contain production secrets. Redact those fields before recording the dump. A dump file is a fixture and a leak surface.
What this workflow does not claim
This workflow does not claim faster reviews. It does not claim any model accuracy figure. It also does not replace static type checkers.
It only makes one extract fail closed on identity. That is the only guarantee on the table.
The next extract starts from a new dump. Do not reuse an old dump after a planned behavior change. Record again, then change one helper again.
Top comments (0)