DEV Community

Dakota Huang
Dakota Huang

Posted on

Snapshot JSON Encoder Bytes Before One Helper Extract

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

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

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.

  1. Grep json.dumps and JSONEncoder in the target package. Record file paths and flag kwargs in a short list.
  2. Pick one module that owns most call sites. Leave sibling packages untouched for this change.
  3. Copy eight to twelve objects into the catalog above. Reject any object built from datetime.now.
  4. Run the harness once to write snapshots. Read every snapshot as raw text, not as pretty JSON.
  5. Commit snapshots in their own commit. The message should name the module, not the future extract.
  6. Extract one helper only. Keep flag defaults identical to the messy call sites.
  7. Re-run the harness. If a snapshot drifts, revert the extract. Do not rewrite the snapshot to match taste.
  8. 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)
Enter fullscreen mode Exit fullscreen mode

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)