Pin JSON encoder bytes before you extract any helper. Characterization tests record current dumps, not desired dumps. One green snapshot suite licenses one function move.
Messy repos grow json.dumps calls with ad hoc default= lambdas. A later extract changes ensure_ascii, separators, or datetime shape. Callers then parse strings that no longer match.
Why encoder extracts fail
Hidden flags live next to the payload. sort_keys, ensure_ascii, and separators alter bytes. default= swallows Decimal, UUID, Path, and datetime.
A refactor that only moves the helper still retunes those flags. Tests that decode JSON and assert key presence miss the byte contract. Downstream caches, webhooks, and signed payloads then miss.
Do not start from a clean encoder design. Start from the bytes the messy module already emits. Treat that text as the temporary specification.
What to snapshot
Snapshot the exact dumps text for a frozen input set. Keep inputs free of clocks and random UUID calls. Use explicit datetime values and constructed uuid.UUID objects.
Record four axes on every case. Encoder flags. Default handler outcome. Key insertion order. Error class for non-encodable values.
Do not snapshot live production responses. Copy representative objects into the test module. Freeze timezone-aware datetimes in UTC only.
Skip objects whose default= path calls utcnow. Those cases are not characterization. They are flaky generators wearing test clothes.
Decision table
Use this table to pick cases before writing files. Each row must produce stable text. Drop a row if it needs the system clock.
| Case | Object | Flags | Observed role |
|---|---|---|---|
| UUID | uuid.UUID('11111111-1111-4111-8111-111111111111') |
default=str |
stable hyphen hex |
| Decimal | Decimal('1.250') |
default=str |
trailing zero kept |
| Aware DT | datetime(2026, 9, 18, 12, 0, tzinfo=timezone.utc) |
default=isoformat |
+00:00 suffix |
| Naive DT | datetime(2026, 9, 18, 12, 0) |
default=isoformat |
no offset |
| Path | PurePosixPath('/var/job/out') |
default=str |
POSIX slashes |
| Nested | {"b": 1, "a": 2} |
sort_keys=True |
keys ordered |
| Compact | {"a": True} |
separators=(',', ':') |
no extra spaces |
| ASCII | {"t": "café"} |
ensure_ascii=True |
\u00e9 escape |
| Bad set | {"s": {1, 2}} |
no default |
TypeError class |
Copy the table into the pull request. Reviewers then see the contract without reading dumps of dumps.
Harness
Place the harness next to the messy module. Do not import production network clients. Label this as an unexecuted template until you point SRC at real code.
# test_json_encoder_char.py
# Template: point SRC at the messy module under test.
from __future__ import annotations
import json
from decimal import Decimal
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from uuid import UUID
import pytest
SRC = Path(__file__).with_name("job_payloads.py")
SNAP = Path(__file__).with_name("snapshots")
CASES = Path(__file__).with_name("encoder_cases.json")
def _load_cases() -> list[dict]:
return json.loads(CASES.read_text(encoding="utf-8"))
def _object_for(name: str):
catalog = {
"uuid": UUID("11111111-1111-4111-8111-111111111111"),
"decimal": Decimal("1.250"),
"aware_dt": datetime(2026, 9, 18, 12, 0, tzinfo=timezone.utc),
"naive_dt": datetime(2026, 9, 18, 12, 0),
"path": PurePosixPath("/var/job/out"),
"nested": {"b": 1, "a": 2},
"compact": {"a": True},
"ascii": {"t": "café"},
"bad_set": {"s": {1, 2}},
}
return catalog[name]
def _dump(obj, flags: dict) -> str:
# Import the messy helper only after cases load.
from job_payloads import encode_payload # type: ignore
return encode_payload(obj, **flags)
@pytest.mark.parametrize("case", _load_cases(), ids=lambda c: c["id"])
def test_encoder_bytes_match_snapshot(case):
SNAP.mkdir(exist_ok=True)
path = SNAP / f"{case['id']}.json.txt"
obj = _object_for(case["object"])
flags = case["flags"]
expect_error = case.get("error")
if expect_error:
with pytest.raises(Exception) as raised:
_dump(obj, flags)
assert type(raised.value).__name__ == expect_error
return
text = _dump(obj, flags)
if not path.exists():
path.write_text(text, encoding="utf-8")
pytest.fail(f"wrote {path}; re-run to characterize")
assert text == path.read_text(encoding="utf-8")
json.loads(text)
Keep case metadata outside Python when flags differ. A JSON list documents the matrix without extra test functions.
[
{"id": "uuid-str", "object": "uuid", "flags": {"default_mode": "str"}},
{"id": "decimal-str", "object": "decimal", "flags": {"default_mode": "str"}},
{"id": "aware-iso", "object": "aware_dt", "flags": {"default_mode": "isoformat"}},
{"id": "nested-sorted", "object": "nested", "flags": {"sort_keys": true}},
{"id": "compact", "object": "compact", "flags": {"separators": [",", ":"]}},
{"id": "ascii", "object": "ascii", "flags": {"ensure_ascii": true}},
{"id": "bad-set", "object": "bad_set", "flags": {}, "error": "TypeError"}
]
First run writes snapshot files and fails on purpose. Second run compares bytes. Commit both the cases file and the snapshot directory.
Numbered procedure
Follow this order. Do not skip the inventory step. Do not extract during the first run.
- Grep
json.dumpsandJSONEncoderin the target package. Record file paths and flag kwargs in a short list. - Pick one module that owns most call sites. Leave sibling packages untouched for this change.
- Copy eight to twelve objects into the catalog above. Reject any object built from
datetime.now. - Run the harness once to write snapshots. Read every snapshot as raw text, not as pretty JSON.
- Commit snapshots in their own commit. The message should name the module, not the future extract.
- Extract one helper only. Keep flag defaults identical to the messy call sites.
- Re-run the harness. If a snapshot drifts, revert the extract. Do not rewrite the snapshot to match taste.
- Stop. A second helper waits for a second snapshot commit.
The eighth step is the actual control. Most failed refactors continue into a second move.
The only allowed diff
The messy module often inlines a default= lambda. That lambda is the extract target. Flags stay at each call site unless every site already shares one dict.
# job_payloads.py — after characterization, one helper only
from __future__ import annotations
import json
from datetime import datetime
from decimal import Decimal
from pathlib import PurePath
from uuid import UUID
from typing import Any
def default_payload(value: Any) -> str:
if isinstance(value, (Decimal, UUID, PurePath)):
return str(value)
if isinstance(value, datetime):
return value.isoformat()
raise TypeError(f"not jsonable: {type(value).__name__}")
def encode_payload(obj: Any, **flags: Any) -> str:
default_mode = flags.pop("default_mode", None)
if default_mode in {"str", "isoformat"}:
flags = {**flags, "default": default_payload}
return json.dumps(obj, **flags)
Do not switch to orjson in this diff. Do not add sort_keys=True globally. Do not pretty-print snapshots to make reviews easier. Pretty-print changes bytes and hides the bug you came to catch.
If two call sites disagree on ensure_ascii, keep both sites. The helper must accept flags. Unifying flags is a second change with a second snapshot.
After the harness, a model may draft the extract
A free coding model can propose the helper once snapshots exist. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option you can point at that harness.
Feed the cases file, the snapshot directory, and the messy module. Ask for one function extract that keeps snapshot bytes identical. Reject any patch that edits snapshots/ or loosens assertions to decoded dicts.
The model is a diff source, not a spec source. Characterization stays in your tree. If the free server is already in your loop, keep the snapshot paths in the prompt and request only the extract.
Limitations
This harness does not prove the JSON is semantically right. It proves the messy encoder did not drift. Wrong datetime suffixes stay wrong until a later, explicit change.
default=str on Decimal("1.250") keeps the trailing zero. A numeric conversion would not. Do not mix those policies inside one helper.
Dict key order is insertion order on current Python. Characterization still needs sort_keys cases when any caller sets that flag. Missing that flag is a real behavioral fork.
Path versus PurePosixPath changes slashes on Windows. Snapshot POSIX forms unless the production helper truly emits local separators. Mixed separators are a contract, not noise.
Non-UTF8 ensure_ascii output is still valid JSON text. Do not round-trip through ensure_ascii=False during compare. Compare the exact dumps string.
Who should skip this
Skip this if the encoder is already one module with golden contract tests. Extra snapshots add noise there. Skip this if you must change the datetime shape in the same patch.
Skip this for streaming encoders and incremental JSONEncoder.iterencode paths. Byte snapshots of a full dump do not cover chunk boundaries. Skip this when payloads embed signatures over pretty-printed JSON from another language.
Do not use this approach to bless a format change. A format change needs new snapshots written on purpose, in a dedicated commit, with a parser migration. Characterization is for moves, not for redesigns.
Pin the bytes. Extract one helper. Leave the next flag unification for the next snapshot.
Top comments (0)