Lesson 4 · Verification
Testing a code generator
You asked to learn this. You are already doing three of the four things — this names them, and finds the gap they leave.
Testing a generator has one unusual asset and one unusual difficulty.
The asset: the output is a program, so you can run it. Most functions produce data you have to inspect; yours produces something that can be asked to justify itself.
The difficulty is the oracle problem — the general name for “how do you know the right answer?”. For a generator the right answer is a whole file of text, and nobody wants to write those by hand. Every technique below is a different way of dodging the need to know the exact right answer.
The four axes
1 · Golden tests (also: snapshot, characterisation, approval)
assert result.body == PythonSource("""\
class Note(BaseModel):
body: Annotated[str, StringConstraints(min_length=1)]
""")
Oracle: recorded. You ran it once, eyeballed the output, and froze it.
Catches: any unintended change to output, exactly and immediately. Superb regression net.
Misses: whether the frozen output was ever correct. A golden test records the
bug alongside the behaviour, with equal confidence. Michael Feathers' term characterisation
test is the honest one — it characterises what the code does, not what it should do.
2 · Execute the output
with executed(source, ModuleName(f"generated_{bound.root}")) as module:
generated_model = getattr(module, bound.root)
Oracle: the Python interpreter.
Catches: syntax errors, missing imports, unbound names, import-time explosions — the
entire class of bug where you emitted something that isn't a program. It is the cheapest high-value test
a generator can have, and most generators don't have it.
Misses: anything that runs but means the wrong thing.
3 · Metamorphic properties (round-trip is one)
assert result.generated == result.source # comparing _shape(...)
Oracle: derived. You don't state the answer — you state a relation between input
and output that must hold whatever the answer is. This one says: with no transformers applied,
the generated model has the same field shape as the model it came from.
Catches: semantic drift, at any input, without anyone writing an expected file.
Misses: exactly and only what the relation doesn't look at. Yours compares
annotation | metadata | is_required. It is blind to defaults, and blind to
how a default is produced — which is the tier-3 loss from Lesson 2, invisible to this test by
construction.
This is the technique with the best power-to-effort ratio and the one most people never
reach for, because it requires a shift: from “what should the answer be?” to “what must be
true of the answer?”. Round-trip (parse(print(x)) == x) is the famous instance, but
the family is much wider — idempotence, commutativity, monotonicity, invariance under renaming.
4 · Negative tests on the fragment boundary
test_corpus_unrepresentable.py: assert that unsupported input is refused, by the
specific error.
Catches: silent acceptance — the failure where you quietly emit something plausible for
input you don't actually support. For a tool whose whole discipline is a declared supported fragment,
this is the axis that keeps the declaration honest.
Misses: input you never thought to reject.
Exercise A — Name the axis
Four assertions. Which axis, and what is each one blind to?
The axis you don't have: generated inputs
All four axes above answer “is the output right?”. None of them answers “did we try the input that breaks it?” — and every input in your corpus is a hand-written string literal. You test the inputs you thought of.
Property-based testing closes that: generate the inputs, and check the metamorphic properties over all of them. Hypothesis is the Python one; it also shrinks a failing case to the smallest input that still fails, which for a generator is the difference between a 40-line repro and a 2-line one.
Here is why this matters concretely, and it's the sharpest thing in this lesson. Look at how your suite is partitioned:
| real modules, imported | synthetic IR, hand-built | |
|---|---|---|
| no transformers | test_corpus.py — all four axes |
— |
| transformers applied | nothing | test_transformers.py — assertions on IR only |
The bottom-left cell is empty. Real source goes through the loader with no passes; passes are
exercised on AnnotationText("Name") literals that never came from a file and are never
rendered, executed, or round-tripped.
Your corpus contains a module with note: "Tag | None" = None. Your transformer tests
contain partial_none(). The bug from Lesson 3 lives precisely in the cell where
those two never meet. It isn't a gap in rigour — each half is tested carefully. It's a gap in
the cross product, and cross products are exactly what hand-written cases don't cover and
generated ones do.
Hand-written tests cover the cases you imagined. Generated tests cover the combinations you didn't.
Extension: Lesson 9 takes this on properly — metamorphic relations as (transformation, relation) pairs, sixteen of them graded, and how to generate inputs that actually reach the loader rather than the IR.
Pass algebra — your question 4, properly
You said idempotence isn't enough, which is right, and is the interesting half of knowing it. Three properties, and they are not the same:
| Property | Statement | Buys you |
|---|---|---|
| idempotence | f(f(x)) == f(x) | Safe to apply twice. Nothing about order. You have test_partial_none_is_idempotent. |
| commutativity | f(g(x)) == g(f(x)) | This is the one you asked for. For a specific pair, order is irrelevant. |
| confluence | all orders reach one normal form | The global version. Also called Church–Rosser. Any schedule gives the same answer. |
So the answer: a set of passes needs to be pairwise commutative for order never to matter, and confluent for that to survive as the set grows.
And the second half of your question — do yours have it? No. Two counterexamples, both reachable from the public recipe API:
pipe(m, omit("a"), pick("a")) → UnknownFieldError: … has no field a
pipe(m, pick("a"), omit("a")) → class M: pass
pipe(m, partial_none(), partial_sentinel())
→ x: int | None | MISSING = MISSING
pipe(m, partial_sentinel(), partial_none())
→ x: int | MISSING | None = None
The second pair is the worrying one: no error, two different files, and the default differs — so the two orders produce payload models with genuinely different behaviour.
The move is not to force commutativity. Most real compilers aren't confluent either; LLVM's pass ordering is a known, documented, permanently-annoying fact of life. The move is to know which pairs commute, assert the ones that do, and document the ones that don't — because right now the recipe author has no way to find out except by trying it.
Exercise B — Do they commute?
For each pair, does swapping the order change the result?
Your question 10, cashed out
You said: round-trip and inverses, worth a test. Correct — and you've already built it
(_round_trip, _shape). Three properties are sitting one line away from the
harness you have:
| Generator idempotence | shape(gen(gen(M))) == shape(gen(M)) |
| Feed a generated file back in. With no transformers this must be a fixed point; if it isn't, the loader and the renderer disagree about something. Nearly free — you already import the generated module. | |
| Emission is total | for all recipes: exec(gen(M)) does not raise |
| Extend axis 2 across the transformer matrix, not just the empty pipeline. This alone finds the Lesson 3 bug. | |
| Passes preserve emitability | exec(gen(M)) ok ⟹ exec(gen(pass(M))) ok |
| A preservation property: a pass may change what the file means, but must never make it stop being a program. The strongest single invariant a pass pipeline can have, and the one worth generating inputs for. | |
Note what all three have in common: none requires anyone to write down an expected output. That's the whole trick of metamorphic testing, and it's why it scales to a generator where golden files don't.
Exercise C — Which axis catches it?
Four bugs. Pick the cheapest axis that would catch each. One has an uncomfortable answer.