Testing
@fougere/testing rests on two ideas, and neither is a testing technique.
Part of a test suite has no reason to be written. Judge.row has a closed list of
refusals — unknown field, missing field, read-only, immutable, the shape, a named codec —
and the four axes say who writes what and when. So the cases can be computed from the
entity. A test typed by hand becomes false the day the entity moves and nobody hears
about it; a derived one moves with it.
A test's level of reality does not belong to it. That is the gesture Fougere already
applies to topology: remotes: sits outside the frond, and the same code runs in-process
or behind JSON-RPC. Here, what is real and where it runs are stated outside the test
file, which says only what it expects.
pnpm add -D @fougere/testing
// vitest.config.ts — the whole file
import { fougereTest } from '@fougere/testing/vitest';
export default fougereTest();
Where a test sits says what it is about
fronds/catalog/tests/pricing.test.ts catalog is real, its neighbours are not
tests/contracts.test.ts every frond is real, together
A directory is a declaration — the reading the scan already performs on entities/ and
handlers/. So testApp() takes no argument at all:
await using app = await testApp();
expect(app.fronds.map((frond) => frond.name)).toEqual(['catalog']);
The project starts at the first ancestor holding a fougere.config.ts or a fronds/, and
the subject is the segment after fronds/. A sub-directory below tests/ carries
nothing, and neither does a test's title: a name is prose, and prose deciding how an app
is wired is the hidden runtime the doctrine refuses.
Rows live in SQLite :memory: — a real engine, so the DDL runs, the CHECK constraints
exist and transactions are real, and each call gets a database of its own. Stating root
or fronds yourself wins over the position, the same precedence a config key has over a
convention everywhere else.
The cases come from the entity
// tests/contracts.test.ts — the whole file
import { testApp, checkAll } from '@fougere/testing';
checkAll(await testApp());
It names no entity: the list comes from the scan. A list of imports is a second copy of
what the project already declares, and it goes stale the day an entity is added — silently,
because a missing test cannot fail. One entity at a time reads
checkContract(app, Product).
✓ Product — the contract it declares
✓ a valid body ✓ sku shorter than min ✓ cents below minimum
✓ a key outside the contract ✓ sku longer than max ✓ cents above maximum
✓ not an object at all ✓ name absent ✓ status outside the stated set
✓ sku absent ✓ name of the wrong type ✓ createdAt supplied on an update
Not one case is written. They are enumerated from five readings that already exist —
Visibility.input for what may come in, Judge.onAbsent for what is required,
Lifecycle.of(field).immutable for patch mode, the shape for the bounds, and the key
outside the contract. Change a min: in the entity and the cases follow.
A case carries the path of the field at fault and never the message: we know which field must be refused because we are the ones who broke it, and naming the message would make the list a second judge to keep in step by hand.
checkOutput costs nothing to state — Visibility.output already answers it, so a field the
boundary closes has no business in any response:
password: text({ boundary: 'writeOnly' }) // in, never out
What it does not prove
A test derived from Post can never say that Post is wrong. It proves the
realizations agree. What must break when the declaration moves is elsewhere and
already built: fougere migrate. And business intent —
publishing sets the status to published — is always written by hand.
Sampling a valid body
sampleInput(Product) // → { sku: 'gDXv8gBIU', name: '…', cents: 750868 }
sampleInput(Product, { sku: 'ABC-01' }) // what you impose, verbatim
sampleInput(Product, {}, { seed: 7 }) // pinned
The value itself is not ours to invent: a shape is JSON Schema, so
json-schema-faker honours minLength, enum, format, pattern and items. What is
ours is which fields belong in a body — Visibility.input, the one reader of the boundary
and lifecycle axes the façade and the form already stand on.
The seed defaults to a value derived from the entity name, so two runs agree and a failure
can be replayed. A ref() is refused by name rather than generated: it points at a row
that must exist, and a made-up id points at nothing.
The same function seeds a dev database and sets the scene for a browser scenario.
Doubles
Only what is already declared replaceable can be stood in for — a port. "I need a mock here" becomes a design signal rather than a reflex.
await using app = await testApp({ stub: [Pricing] });
app.stub(Pricing).total.mockReturnValue(4242);
await run({ entity: 'product', op: 'quote' }, invocation);
expect(app.stub(Pricing).total).toHaveBeenCalledWith(1000);
The double is proposed, not written: its methods are read from the port's prototype, so it carries exactly what the port carries and gains a new one the day the port does. What it returns is yours to state — a service's return type is a bare TypeScript type, erased at runtime, with no declared fields to build from. That is the line the whole package sits on: what Fougere's vocabulary declares can be derived, arbitrary code cannot.
A port nobody answers under is refused by name, for the reason ports: refuses a key that
matched nothing.
Nothing can stand in for what a handler fetches itself — a fetch, a library imported at
the top of the file, the clock. Fougere resolves by the signature, so it replaces only
what the signature declares, and the model's answer for an outside call is already to make
it a port.
The doors agree
checkDoors(app, Product); // local · REST · GraphQL · RPC
The five CRUD operations, reads and writes. A write creates a different row at each door, so what is compared is what the caller SENT — comparing values would compare clocks.
The repo claims everywhere that REST, GraphQL and RPC are three projections of one contract. This is what checks it, on the rows and on the refusals both — a refusal is where doors diverge most, and where each is most tempted to answer in its own words.
Each door's envelope is taken off before comparing: they wrap differently by construction, and comparing wrappers would compare protocols. The GraphQL field is not recomputed but asked of the schema — a list is the field whose type is the entity's list type, a find is the one of the entity's own type that takes an id.
For a handler-specific invariant, checkDoorContract(app, Entity, cases) runs the same
hand-written cases through all four doors. Fougere uses it to pin optional-input semantics:
omitted foo?: T arrives as undefined, foo: T | null keeps explicit null, and
foo?: T | null keeps those two states distinct.
Facts
await run({ entity: 'post', op: 'publish' }, invocation);
expect(app.announced(PostPublished)).toMatchObject([{ id, title }]);
Watching costs nothing: Emissions.announce hands every fact to the carrier, so a test
sits in the carrier's seat and adds no second dispatcher — the one thing Emissions
refuses to become. A fact travels under its registration key (postPublished), and both
spellings are accepted.
Its dual needed nothing at all — app.deliver is already the carrier's door, and it waits
for every subscriber and rejects if one refuses:
await app.deliver('postPublished', { id: 'p1', title: 'Delivered', at: … });
Drift between fronds
The one place the gradient genuinely lies: the code is identical in-process and split, but
one side may have aged. fougere sync wrote the consumer's copy three weeks ago, the
producer moved on, and it still compiles.
const drift = driftOf(mine, theirs, 'blog');
explain(drift);
// blog.post — gone: publish
// blog.post — title: its bounds moved
// postPublished — + authorId (required) → re-sync and deploy the readers, THEN the sender
Both cards come from rpc.discover, and diff names what separates them. Read in one
direction on purpose: a producer serving more than the consumer knows is not drift, it
is a producer that moved forward without breaking anyone.
On facts the line says the order out loud, because a fact is judged strictly: a reader that has not been re-synced refuses what the sender now announces.
The browser
The rung the others cannot reach: a form that states no rule of its own.
formFieldsOf(Product, 'product')
<input id="sku" name="sku" type="text" required minlength="3" maxlength="12">
Membership comes from Visibility.input, requiredness from the lifecycle axis, and the bounds
from the shape — under the names the browser already enforces. So the page contains no
validation code at all: the browser refuses a two-character sku, and the same body sent
straight to the door is refused by the judge. Local judge = remote judge is watched here
rather than argued; every other rung can only simulate it inside one process.
Playwright drives it and starts the server itself (webServer in playwright.config.ts).
A worked example: demos/test-gradient, pnpm e2e.
A frozen clock
created(), updated() and create: 'now' are realized in one place —
applyCreate/applyUpdate — which is what makes the instant substitutable at all:
const release = freezeClock('2026-01-02T03:04:05Z');
A value, not an interception: nothing there is a channel, and a stable instant is a fact
about the run rather than a hook on Date.
Load
// scripts/load.ts
import { writeFileSync } from 'node:fs';
import { testApp, loadScript } from '@fougere/testing';
const app = await testApp();
writeFileSync('load.js', loadScript(app, { door: 'http://127.0.0.1:3000/_fougere/call' }));
await app.dispose();
k6 run load.js
The generated scenario covers every operation the app serves, with a valid body for
each, and its JSON-RPC envelope comes from frameCall rather than a hand copy that will
go on claiming to be JSON-RPC the day the format moves.
What stays yours: the weights, the stages and the thresholds. The scan knows which
operations exist; it knows nothing about the traffic they receive. An operation given
weight: 0 becomes a decision visible in the file instead of an omission nobody can see.
Regenerate rather than patch.