Property-Based Testing · Lesson 01
Model the Resource, Not the Code
A CRUD resource is a Map with a complicated implementation. That makes it the
easiest place to start with model-based testing. A command endpoint, like Figma's operations on a frame, looks
similar but needs different properties. By the end you can write the harness for one CRUD resource and say
which properties a command endpoint has.
Terms
- Property
- A statement that must hold for every generated input.
- SUT
- System under test. Here: the real repository or endpoint, with its database, ORM and caches.
- Model
- A much simpler implementation of what the SUT is supposed to do. fast-check: “not a carbon copy of the system but a simplified representation of it.”
- Command
- One generated operation.
check(model)says whether it may run now;run(model, real)applies it to both and asserts they agree. - Shrinking
- After a failure, the tool removes steps and simplifies values until it has the shortest sequence that still fails.
- Metamorphic
- A property that relates two calls instead of predicting one. Change the input in a known way, and the output must change in a known way. Hughes: “Related calls return related results.” Example: adding a filter must return a subset. Lesson 02 covers it in depth.
- Oracle
- Whatever tells the test the right answer. In model-based testing, the model is the oracle.
Why a CRUD resource is a good fit
Goldstein et al. interviewed 30 engineers at Jane Street who use PBT daily. The most common kind of property, used by 17 of 30, was differential or model-based: compare the code to a simpler reference. One participant summarised when PBT is worth it:
“[PBT is] most useful when… you have a really good abstraction with a complicated implementation.”
A soft-deletable resource with a unique slug fits this exactly. The abstraction fits in a few lines:
a map from id to fields, a deletedAt per entry, and one rule about slugs. The implementation is a SQL
table, a partial unique index, an ORM, a cache and a serialiser. All the bugs live in the implementation.
The harness for one resource
type Row = { slug: string; name: string; deletedAt: Date | null };
type Model = { rows: Map<string, Row>; ids: string[] }; // ids in creation order
const live = (m: Model) => [...m.rows.entries()].filter(([, r]) => !r.deletedAt);
const slugTaken = (m: Model, slug: string) => live(m).some(([, r]) => r.slug === slug);
// A target is usually a row the model has seen (deleted ones included), sometimes an id that never existed.
type Target = { existing: number } | { unknown: string };
const target = fc.oneof(
{ weight: 9, arbitrary: fc.nat().map((existing) => ({ existing })) },
{ weight: 1, arbitrary: fc.uuid().map((unknown) => ({ unknown })) },
);
const NEVER_CREATED = '00000000-0000-4000-8000-000000000000';
const resolve = (m: Model, t: Target) => {
const id = 'unknown' in t ? t.unknown
: m.ids.length ? m.ids[t.existing % m.ids.length] : NEVER_CREATED;
return { id, row: m.rows.get(id) }; // row undefined → expect notFound
};
class Create implements fc.AsyncCommand<Model, ProjectApi> {
constructor(readonly input: { slug: string; name: string }) {}
check = () => true;
async run(m: Model, api: ProjectApi) {
const res = await api.create(this.input);
if (slugTaken(m, this.input.slug)) return assert.equal(res.kind, 'conflict');
assert.equal(res.kind, 'ok');
m.rows.set(res.id, { ...this.input, deletedAt: null });
m.ids.push(res.id);
}
toString = () => `create(${this.input.slug})`;
}
class Delete implements fc.AsyncCommand<Model, ProjectApi> {
constructor(readonly t: Target) {}
check = () => true;
async run(m: Model, api: ProjectApi) {
const { id, row } = resolve(m, this.t);
const res = await api.delete(id);
if (!row || row.deletedAt) return assert.equal(res.kind, 'notFound');
assert.equal(res.kind, 'ok');
row.deletedAt = new Date();
}
toString = () => `delete(${JSON.stringify(this.t)})`;
}
class List implements fc.AsyncCommand<Model, ProjectApi> {
check = () => true;
async run(m: Model, api: ProjectApi) {
const res = await api.list();
assert.deepEqual(res.map((p) => p.id).sort(), live(m).map(([id]) => id).sort());
}
toString = () => 'list';
}
// + Get, Patch, Restore — same shape
const slug = fc.constantFrom('a', 'b', 'A'); // few values → collisions are common
const commands = [
fc.record({ slug, name: fc.string() }).map((x) => new Create(x)),
target.map((t) => new Delete(t)),
fc.constant(new List()),
];
test('projects behave like the model', () =>
fc.assert(fc.asyncProperty(fc.commands(commands), (cmds) =>
fc.asyncModelRun(async () => {
await resetDb();
return { model: { rows: new Map(), ids: [] }, real: new ProjectApi(db) };
}, cmds)), { numRuns: 50 }));
There are three design choices in this code. Each one is easy to get wrong:
- The slug generator draws from three values. With
fc.string(), two random slugs would almost never be equal, and the conflict path would never run. Narrow generators make the interesting cases common.'A'is there to test your case-sensitivity rule. Deletetakes aTarget, not an id. Nine times in ten it is an index thatresolveturns into a row the model has seen, including deleted ones. That is howdelete → deletegets generated. One time in ten it is a random UUID that never existed, which covers the plain 404 path. A fully random id would almost never hit a real row.- The model never calls your service code. Every expected result comes from the
Map. If the model calls the code under test, it can only ever agree with it.
Known failures go in front of the random runs as fixed sequences. asyncModelRun accepts
any list of commands:
test('regression: restore into a taken slug', () =>
fc.asyncModelRun(setup, [new Create({ slug: 'a', name: '' }), new Delete({ existing: 0 }),
new Create({ slug: 'a', name: '' }), new Restore({ existing: 0 })]));
Exercise 1 · Bug hunt
Find the defect in the test
Each snippet is a command someone wrote for the harness. Click the line with the defect, or press no defect. Some snippets are correct.
The contrast: a command endpoint
Now a different kind of endpoint. A design editor, in the style of Figma, has one endpoint per document:
POST /frames/:frameId/commands
{ "mutationId": "c1-42", "type": "reparent", "node": "rect-7", "newParent": "group-2", "index": 0 }
{ "mutationId": "c1-43", "type": "move", "node": "group-2", "dx": 10, "dy": 0 }
{ "mutationId": "c1-44", "type": "delete", "node": "group-2" } // deletes the subtree
In Data Synchronisation 01, this was a mutator receiving mutations. Here the question is what to test.
The difference from CRUD is where the complexity is. In the CRUD resource the intended behaviour is simple and the storage is complicated. In the command endpoint the intended behaviour is the complicated part. A reparent has to keep the tree acyclic, update indices among siblings and convert child coordinates into the new parent's space. A model that predicts the exact result of every reparent would be a second implementation of the editor. That is the case fast-check warns about: the test compares the code with a copy of itself.
So for command endpoints you stop predicting the exact result and check relations instead. These are Wlaschin's patterns, and none of them needs to know the right answer:
| Wlaschin's pattern | CRUD resource | Command endpoint |
|---|---|---|
| The test oracle (model) | Main tool: a Map predicts every response | Only for coarse facts, e.g. which node ids exist |
| Some things never change (invariant) | Live slugs are unique | Main tool: the tree is acyclic; every node has exactly one parent; ids are unique |
| There and back again (inverse) | export → import gives the same row | command → undo gives the original document |
| Different paths, same destination (commutativity) | Rarely useful | Commands on disjoint subtrees give the same document in either order |
| Metamorphic (relate two calls) | Adding a filter gives a subset; page size doesn't change the concatenated pages | Moving a group moves every descendant by the same offset |
| The more things change… (idempotence) | PUT twice = once | Replaying a command with the same mutationId = applying it once |
The harness itself works the same way for both. A command endpoint still gets random command
sequences and shrinking. The difference is in run: it asserts invariants and relations instead of
comparing against a predicted value.
class Reparent implements fc.AsyncCommand<Model, FrameApi> {
constructor(readonly node: Target, readonly parent: Target) {}
check = () => true;
async run(m: Model, api: FrameApi) {
const [n, p] = [resolve(m, this.node).id, resolve(m, this.parent).id];
const before = await api.document();
const res = await api.command({ type: 'reparent', node: n, newParent: p, index: 0 });
const after = await api.document();
assertTreeInvariants(after); // acyclic, one parent each, ids unique
if (res.kind === 'rejected') assert.deepEqual(after, before); // rejection changes nothing
else assert.equal(parentOf(after, n), p); // postcondition, not a full prediction
}
}
The reparent assertion is weaker than a full prediction. It doesn't say where the node
ends up in coordinates. It does catch the bugs that matter most, though: cycles, lost nodes and partial writes.
Hughes calls a check like the last line a postcondition: a relation between a single call's arguments and
its result.
Exercise 2 · Interleaved recognition
Name the property
CRUD and command endpoints are mixed together. For each situation, type the pattern that gives the strongest cheap property: oracle/model, invariant, inverse, commutativity, idempotence, metamorphic, or none if example tests are the better tool. Press Enter to check.
Exercise 3 · Scenario
The overnight failure
The frame harness fails in CI. After shrinking, the report is:
Property failed after 31 runs, shrunk 14 times Counterexample: [reparent(#1 → #0), reparent(#0 → #1)] AssertionError: assertTreeInvariants: cycle detected (group-1 → group-0 → group-1)
A teammate proposes deleting assertTreeInvariants from
Reparent.run and adding an example test instead: “reparent A under B, then B under A, expect
409”. Write down what's right about the proposal and what's wrong with it.
Right: the shrunk sequence should become a pinned regression test. It's deterministic and runs on every commit. It also documents the rule (“reparenting into your own descendant is rejected”) more clearly than a randomly generated case.
Wrong: removing the invariant from the harness. The pinned test covers one way to create a
cycle, with depth 2. The invariant covers all of them, for example a cycle of depth 3
(A→B, B→C, C→A), or a cycle reached through paste or group commands added
next quarter. Keep both: the example test stops this bug from coming back, and the invariant keeps looking for
new ones.
Also notice that the shrunk failure has two steps. That's typical. Bugs found from sequences are usually short once shrunk, even when the original failing run was 30 steps long.
Exercise 4 · Recall
From memory
Write before revealing.
To keep it
Tomorrow, without the lesson open, write down on paper the three commands
Create, Delete, List for a resource from your own codebase, including
the generators. Then compare with the code above. In three days, take one command endpoint from your codebase and
list its invariants. Where you can't think of one, the endpoint's contract is probably unclear, and that is
worth knowing on its own.
Sources: Goldstein et al. 2024 · fast-check: model-based testing · Wlaschin: choosing properties · Hughes: How to Specify It! · all resources
← Status · Next: 02 · Relate Two Calls →