DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Container Identity Before You Extract One Mutator

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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())"
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)