Messy import scripts fail extracts for a measurable reason.
Validation rules hide inside file reads and writes.
Move the helper first, and reject codes drift.
Pin observed row outcomes before you extract anything.
Keep the writer and the CSV reader in the original module.
Extract one validator only after the outcome table is green.
Why this order holds
Characterization tests record current behavior as a contract.
They do not prove that behavior is correct.
They prove the next edit did not change it.
AI-assisted refactors often rewrite the wrong layer.
They fold I/O into helpers and “simplify” defaults.
Those edits change which rows reach the writer.
A frozen outcome table makes that drift visible.
Accepted counts, reject codes, and normalized fields stay pinned.
If any pin moves, the extract is too large.
What the suite must pin
Capture four observables on one fixed fixture file.
- Count of rows marked accepted after parse.
- Ordered list of reject codes for bad rows.
- Canonical dicts for each accepted write.
- Writer call count plus the written key set.
Skip private function names in the assertions.
Skip log text unless an operator depends on it.
Skip elapsed time; it is not a behavior pin here.
Teaching fixture, not production code
The module below is a labeled, compact example.
It mixes parsing, policy, and writes on purpose.
Do not copy it into a live importer unchanged.
# import_users.py — messy on purpose
from __future__ import annotations
import csv
import io
from typing import Callable, Dict, List, Tuple
Writer = Callable[[dict], None]
def import_users(csv_text: str, write: Writer) -> dict:
accepted = 0
rejects: List[str] = []
reader = csv.DictReader(io.StringIO(csv_text))
for raw in reader:
email = (raw.get("email") or "").strip().lower()
name = (raw.get("name") or "").strip()
role = (raw.get("role") or "member").strip().lower()
if not email or "@" not in email:
rejects.append("bad_email")
continue
if not name:
rejects.append("missing_name")
continue
if role not in {"member", "admin"}:
rejects.append("bad_role")
continue
write({"email": email, "name": name, "role": role})
accepted += 1
return {"accepted": accepted, "rejects": rejects}
Three policies sit inside that loop today.
Email must be present and contain one "@".
Name must be non-empty after strip.
Role defaults to member, then must match a set.
The writer is a callback, which helps tests.
The defaults still live next to the I/O.
That mix is what later extracts will try to clean.
Step 1 — freeze a fixture table
Build one CSV that hits every branch once.
Include a blank email, a missing name, and a bad role.
Include mixed-case email and a missing role field.
email,name,role
Ada@Example.com,Ada Lovelace,
,No Email,member
bad@example.com,,member
ok@example.com,Ok User,root
bob@example.com,Bob Admin,admin
Store that text as a constant in the test module.
Do not reread a moving file from disk during asserts.
The fixture is part of the pin, not a living dataset.
Step 2 — record outcomes once
Run the importer against a recording writer.
Print the summary dict and the written records.
Treat that printout as the first gold snapshot.
# tools/record_import.py — labeled example, unexecuted here
from import_users import import_users
CSV = """email,name,role
Ada@Example.com,Ada Lovelace,
,No Email,member
bad@example.com,,member
ok@example.com,Ok User,root
bob@example.com,Bob Admin,admin
"""
written = []
summary = import_users(CSV, written.append)
print(summary)
print(written)
Command to capture the snapshot:
python tools/record_import.py > /tmp/import_gold.txt
Expected gold, derived from the fixture rules above:
{'accepted': 2, 'rejects': ['bad_email', 'missing_name', 'bad_role']}
[{'email': 'ada@example.com', 'name': 'Ada Lovelace', 'role': 'member'}, {'email': 'bob@example.com', 'name': 'Bob Admin', 'role': 'admin'}]
Ada lowercases and fills the default role.
The blank email row rejects as bad_email.
The empty name rejects as missing_name.
root is not in the role set, so it rejects.
Bob is the second accepted write.
If your local run differs, stop the extract.
The gold must match the code you will change.
Do not “fix” behavior during characterization.
Optional hash pin, recorded after the printout:
import hashlib
import json
def gold_hash(obj) -> str:
blob = json.dumps(obj, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest()
print(gold_hash(written)) # record once; pin the hex later
Do not invent the digest before you run it.
Print it from the unrefactored module only.
Paste that hex into the test in the next step.
Step 3 — lock the gold in tests
# test_import_users_char.py
from import_users import import_users
CSV = """email,name,role
Ada@Example.com,Ada Lovelace,
,No Email,member
bad@example.com,,member
ok@example.com,Ok User,root
bob@example.com,Bob Admin,admin
"""
def test_accepted_count_and_reject_codes():
written = []
summary = import_users(CSV, written.append)
assert summary["accepted"] == 2
assert summary["rejects"] == [
"bad_email",
"missing_name",
"bad_role",
]
def test_normalized_writes():
written = []
import_users(CSV, written.append)
assert written == [
{
"email": "ada@example.com",
"name": "Ada Lovelace",
"role": "member",
},
{
"email": "bob@example.com",
"name": "Bob Admin",
"role": "admin",
},
]
def test_writer_key_set():
written = []
import_users(CSV, written.append)
assert len(written) == 2
for row in written:
assert set(row) == {"email", "name", "role"}
Run the pin before any helper moves:
python -m pytest test_import_users_char.py -q
All three tests must pass on the unrefactored module.
That green run is the only license to extract.
A red pin means the gold is wrong, not the later patch.
Step 4 — extract one classifier
Move classification only. Keep the loop and writer.
Return a tagged result. Do not write inside the helper.
def classify_row(raw: dict) -> Tuple[str, object]:
email = (raw.get("email") or "").strip().lower()
name = (raw.get("name") or "").strip()
role = (raw.get("role") or "member").strip().lower()
if not email or "@" not in email:
return ("reject", "bad_email")
if not name:
return ("reject", "missing_name")
if role not in {"member", "admin"}:
return ("reject", "bad_role")
return ("accept", {"email": email, "name": name, "role": role})
Wire it with the smallest loop change:
def import_users(csv_text: str, write: Writer) -> dict:
accepted = 0
rejects: List[str] = []
reader = csv.DictReader(io.StringIO(csv_text))
for raw in reader:
tag, payload = classify_row(raw)
if tag == "reject":
rejects.append(str(payload))
continue
write(payload) # type: ignore[arg-type]
accepted += 1
return {"accepted": accepted, "rejects": rejects}
Re-run the same three tests after the move.
If any assertion changes, revert the extract immediately.
Do not tighten email rules in the same patch.
Decision table
| Observation after extract | Action |
|---|---|
| Gold counts and codes unchanged | Keep the extract |
| Reject order changed | Revert; order was pinned |
| Default role vanished | Revert; default belongs in classify_row
|
| Writer gained extra keys | Revert; schema was pinned |
| Encoding mismatch on the fixture | Fix the fixture, not the policy |
| New tests required to go green | Extract was too wide; shrink it |
Use the table as a stop rule.
An extract that needs new tests is too wide.
Add characterization first, then retry a smaller move.
Drafting the tests, not the rewrite
Branchy importers make the test file tedious to type.
A coding model can turn a gold printout into asserts.
It should not rewrite import_users in the same pass.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option, which is enough to draft the characterization file from a pasted gold snapshot. Ask for the test module only, then land the classifier extract as a human-sized diff.
Limitations
Characterization locks bugs in place on purpose.
A wrong default role will survive the extract.
Fix behavior later, in an explicit outcome-changing patch.
This workflow does not replace a written spec.
It is a brake on structure edits, not a design method.
Teams still changing the schema should not freeze gold yet.
CSV dialects vary by spreadsheet export.
A fixture that omits quoting will miss a branch.
Add one quoted-comma row before you claim coverage.
The writer stub hides uniqueness and transaction rules.
Duplicate emails are not in the fixture above.
Do not extract a uniqueness check you never pinned.
Who should not use this
Skip this order on greenfield modules with no callers.
Write the validator first when nothing yet depends on it.
Skip it when current behavior is known harmful.
Do not pin a parser that drops security-related flags.
Stop and write a spec for those paths instead.
Skip it when the change must alter outcomes.
Then the gold is a before-record, not a keep-record.
Replace the asserts, or mark the old pin as expected-fail.
Close
Pin the import table. Extract one classifier.
Leave reads and writes where they already work.
If the gold moves, the change was not small.
Top comments (0)