Generated negative fixtures

Derive the 401/403/404/400 matrix, per-field boundary violations, and capture-chained CRUD flow suites from your OpenAPI spec.

Goal: stop hand-writing the boring-but-critical rejection tests. If your OpenAPI spec documents what the server must reject, gen-fixtures can derive the whole negative matrix — unauthenticated, wrong-scope, unknown-id, per-field validation, and per-field boundary cases — as .crust.ts files that test-fixture runs like any other fixture. And for every qualifying collection it also derives a CRUD flow suite: a capture-chained .pipes file that creates, reads, updates, deletes, and checks the tombstone.

The shape of it

# Spec in; one <tag>.gen.crust.ts per OpenAPI tag out, plus flow suites
gen-fixtures ./openapi.json
# generated 23 cases across 1 files -> /app/tests/gen
# generated 1 CRUD flows -> /app/tests/gen/flows/flows.gen.pipes

# Run the negative matrix like any other fixtures
test-fixture 'tests/gen/*.gen.crust.ts' -j8 -t5000

# Run the generated CRUD flows — the sibling setup module is auto-detected
test-pipes tests/gen/flows/flows.gen.pipes

The --out directory is deleted and recreated on every run — never edit generated files; change the spec or the setup module and regenerate.

The regen-and-diff workflow

Because generation is deterministic, the generated directory works like a lockfile: check it in, regenerate on spec changes, and read the diff as a review artifact. A new required field shows up as new 400 cases; a loosened maxLength shows up as a deleted boundary case. If the diff surprises you, the spec change did something you didn’t intend — you caught it before the server did.

That discipline assumes the diff shows what changed — and the emitted style is the receiving project’s own for exactly that reason: a biome pre-commit hook that normalises generated files to the repo’s formatter is what used to make every regeneration diff the whole matrix. gen-fixtures therefore looks up the nearest biome.json (walking up from --out, then from the working directory when the out tree is outside the project) and emits the .crust.ts fixtures and flows.gen.setup.ts in the full style biome would apply to them — indentStyle: "tab" emits a tab, "space" emits indentWidth spaces, lineWidth governs where long calls and import lists wrap, javascript.formatter.quoteStyle the quote choice (a layer that overrides a top-level formatter, as biome applies it), and the order of imported names: biome 2.x’s organizeImports assist is ON by default, so names come out in the natural order the assist sorts with, and only an explicit "off" or assist.enabled: false keeps the historical code-point sort. Every absent key takes biome’s own default (space, 2, width 80, double, assist on), and a value biome itself rejects is treated as absent.

With the emitted files already at the formatter’s fixed point, the pre-commit hook and a post-generation biome check --write are a no-op over the matrix instead of a whole-matrix rewrite — the generated form is the checked-in form. A biome.json that is present now resolves to that full style, defaults included, rather than to the historical form (0.3.2’s “a config at biome’s own default keeps the 2-space form byte for byte” held while it read only the indent), so the first regeneration after upgrading converges: a matrix whose committed files already went through a biome pass diffs empty, and one committed raw churns once — wrapping, quote choice, import order — before settling. Only no biome.json on either walk keeps the historical 2-space form byte for byte and stays silent: zero behaviour change for non-biome repos. A non-default indent is disclosed on stdout (gen-fixtures: emitting with tab indent (biome.json at <path>)), an unparseable config is reported and treated as absent so a broken formatter can never fail a generation, and .pipes files are untouched: their lines start at column 0.

gen-fixtures ./openapi.json
git diff --stat tests/gen         # what did the spec change actually change?

In CI, the same idea catches a stale checked-in matrix:

crust -c 'gen-fixtures ./openapi.json'
git diff --exit-code tests/gen    # fails the job if someone changed the spec without regenerating

Regenerating a spec that predates newer derivation rules yields a purely additive diff — existing case names, order and bodies are untouched, so the diff stays reviewable.

What gets derived

For every operation the spec documents:

CaseWhen it’s emittedAsserts
401 without credentialsThe op documents a middleware 401 — a 401 response whose description matches /not authenticated|log in/i. Public endpoints (login documents 401 for bad credentials) get no case.status: 401
403 as non-memberThe op is scope-gated (see scopeParam below) and documents 403. An authenticated outsider — valid credentials, no membership — makes the call.status: 403
404 with unknown idNon-scope-gated ops with path params that document 404 — an authorised caller requests a random uuid.status: 404
400 per body fieldThe op has a JSON request body and documents 400. One case per required field missing, one per field with the wrong JSON type, one per enum violation.status: 400 and the canonical { error, code: "validation", fieldErrors } body naming the field
400 per boundarySame precondition — one case per violated minLength / maxLength / minimum / maximum / pattern, for required and optional fields. Nullable fields count: both the zod-style anyOf: [X, {type: "null"}] wrapper and OpenAPI 3.1’s "type": ["string", "null"] union are unwrapped to the real schema first, so a nullable field gets its boundary cases under either spelling.same as above
400 extra propertyThe body schema has additionalProperties: false.status: 400, code === "validation"

Every 400 case perturbs a schema-valid base body — required fields are synthesized from the schema (format-aware: emails, uuids, dates, and values built from the field’s own pattern — classes, quantifiers and groups, each verified against the regex before it is used) so exactly one thing is wrong per case. The wrong-type case for a nullable field is a value of a genuinely wrong type, never null: the union permits null, so sending it would assert nothing. Authz cases (401/403) also carry a valid body, because routes that validate before the auth middleware would otherwise 400 ahead of the status under test.

Response schemas ride along — output.schema

When the spec documents a response schema for a case’s expected status, the generated fixture also carries it as output.schema — the reserved test-fixture key that checks response-body conformance with per-field pointer failures. A spec whose 400 response documents the {error, code, fieldErrors} shape yields cases like:

output: {
  status: 400,
  data: (d: { code?: string; fieldErrors?: Record<string, unknown> }) =>
    d.code === "validation" && d.fieldErrors !== undefined && "name" in d.fieldErrors,
  schema: {"type":"object","required":["error","code","fieldErrors"],"properties":{"error":{"type":"string"},"code":{"type":"string","enum":["validation"]},"fieldErrors":{"type":"object"}}},
},

So the matrix doesn’t just prove the server rejects — it proves the rejection body matches the documented error contract. Cases whose expected status documents no schema (a bare 401: { description: … }) get no schema key — nothing is invented.

The boundary matrix, case by case

Given one schema —

{
  "type": "object",
  "additionalProperties": false,
  "required": ["name", "kind"],
  "properties": {
    "name":     { "type": "string", "minLength": 3, "maxLength": 40 },
    "kind":     { "type": "string", "enum": ["widget", "gadget"] },
    "priority": { "type": "integer", "minimum": 1, "maximum": 5 },
    "sku":      { "type": "string", "pattern": "^[A-Z]{3}-[0-9]{4}$" }
  }
}

— the POST operation alone yields this matrix (real case names from a generated run):

POST /things without credentials -> 401
POST /things missing required 'name' -> 400
POST /things wrong type for 'name' -> 400
POST /things missing required 'kind' -> 400
POST /things wrong type for 'kind' -> 400
POST /things invalid enum for 'kind' -> 400
POST /things too short 'name' -> 400          # minLength: sends a 2-char name
POST /things too long 'name' -> 400           # maxLength: sends 41 chars
POST /things below minimum 'priority' -> 400  # optional fields get cases too
POST /things above maximum 'priority' -> 400
POST /things pattern violation 'sku' -> 400
POST /things unexpected extra property -> 400 # additionalProperties: false

The rules that keep the matrix sane:

  • Boundary cases run in fixed per-field order (too short, too long, below, above, pattern), so regeneration diffs are stable.
  • An object whose properties live in a union branch is built from that branch. A node declaring type: "object" alongside a oneOf/anyOf used to produce {} — the explicit type sent it straight to the object case, which composes through allOf only. The same node without a type worked, which is what kept it hidden.
  • The mirror shape too. The node declares its properties once and each branch says only which of them this form needs — urlbox’s RenderRequest is {oneOf: [{required: ["url"]}, {required: ["html"]}], properties: {url, html, format, …}}, which is how a two-form POST gets written. A branch was read only when the node owned nothing at all, so that one composed to {}: the base body satisfied no branch, crust’s own validator answered anyOf, and every negative case of the operation went behind a notice blaming crust’s value — urlbox generated no cases from its entire spec. Now a branch is read when the composed view cannot satisfy one. A branch requiring nothing is satisfied by any object, and a branch the node’s own required already satisfies is not read: on a oneOf, adding the other alternative makes the body match two branches and break the very thing being composed for. Across the 23 corpus specs that write it, the notices blaming crust’s body went 23 → 0 and the operations left with no negative cases at all went 21 → 0, cases rose 5,888 → 5,996 and no spec lost one; the 52 union-body specs that write some other shape gained 109 cases between them and lost no test, which is the control that whatsapp’s audio/image body still composes exactly as before.
  • exclusiveMinimum/exclusiveMaximum are honoured in both spellings — 3.0’s boolean modifier on minimum/maximum, and 3.1’s number — so {maximum: 1, exclusiveMaximum: true} is never answered with 1, the one value it excludes. Where a format and a pattern are both declared, the format constant is used only if it satisfies the pattern: format: email beside a pattern whose TLD is 2–5 letters rejects gen@crust.fixture, which has seven.
  • The field-name heuristics yield to anything the schema actually states — an explicit pattern, an explicit format, and equally a length bound: client_id: {type: "string", maxLength: 20} never mentions uuid, and the 36-character one does not fit. An explicit format is different: there the schema asked for the value itself, and a maxLength too small for it is the spec contradicting itself, which crust leaves visible.
  • The enum member picked satisfies the schema’s own declared type — real specs write {type: "string", enum: [true, false]}. And allOf is merged as an intersection: properties and required are unioned across the branches, and where two bounds disagree the stricter governs (minLength: 3 beside a branch’s minLength: 40 means 40). That reaches the shape where a node carries the type and leaves its refinements to branches.
  • Formats the generator has no constant of its own for — uri/url above all — fall through to the same defaults the mock uses; its four fixed constants (email, uuid, date, date-time) keep their values so existing matrices do not churn. An explicit pattern outranks the field-name heuristics: a field called job_id carrying ^job-[0-9]{3}$ gets a value matching the pattern, not the stable uuid, because a guess from a name must not beat what the schema says.
  • The valid base body honours the schema’s own bounds — maxLength, minLength, minItems/maxItems, minimum/maximum. Every 400-case perturbs that body, so where it was already invalid the expected 400 could arrive for the wrong reason: a false pass, which is the one thing crust must never produce. The fixed format values are deliberately unchanged, since checked-in matrices are CI-diffed against a regeneration.
  • A wrong-type case is only generated when the value is actually rejected. A field whose schema constrains nothing — {properties: {…}} with no type, a description-only node — has no wrong value, so such a case would assert -> 400 for a request a correct API answers 200: a test that fails against a correct implementation. 6,101 were derivable from a 4,138-spec corpus. crust owns the validator, so it asks it rather than guessing.
  • A missing type is inferred from the keywords present, sharing one implementation with the mock — so {minLength: 3} with no type now yields the boundary case it silently skipped before.
  • A body crust cannot fill carries no cases it cannot attribute. The pattern sampler returns a knowingly-wrong placeholder for a pattern it cannot read, which in a base body made a case about name invalid in subnetId. The 400 then names subnetId, so the case fails a correct implementation or passes for a reason unrelated to its own name; 1,799 of the 164,473 request-body fields in a 4,138-spec corpus were in that state. Most of them were readable all along — crust was refusing groups. Reading (?:…), (a|b) and (…){n} took the specs that were still losing cases from 186 declined fields down to 11, with nothing that used to build falling back; what remains is a lookahead, \A/\Z anchors, and a JavaScript regex literal written into pattern, where JSON Schema takes the leading slash as a literal and nothing can satisfy it. The base body is now checked with crust’s own validator and every case whose 400 could name a field other than the one it asserts is dropped — keeping the cases about the invalid field itself, and the 401/403/404 cases, whose gate answers before the body is read. What was dropped is printed (gen-fixtures: 2 case(s) skipped for POST /things …): a smaller suite is a fact to report, not a silence to ship.
  • A drop notice says whose fault it is. Some specs allow no value at all: {type: integer, format: int32, enum: ["0 (StandardHDD)", "1 (StandardSSD)", "2 (PremiumSSD)"]} is how one real spec writes a disk type — enum members as YAML strings beside an int32 — and {type: string, enum: [true, false]} is how another writes a switch. No generator fills those fields, so the notice names the spec’s own contradiction and where it sits (… the SPEC contradicts itself: 'vmSpecs' — /diskType: enum ["0 (StandardHDD)", …] fails type …) rather than blaming crust’s value, which would send you to open an issue against the wrong repo. Fields where crust’s own value is what breaks keep that wording, and a body with both says both. Only keywords that MUST hold are followed, so an unsatisfiable optional field is not reported (a base body omits optional fields) and a union counts as the spec’s only when every branch is unsatisfiable. Across the 39 APIs-guru specs that emit these notices, the mentions naming crust’s value went 68, then 24 once a body declared across allOf branches was read as one body, then 7. The summary also ends with note: N operation(s) left with no negative cases (32 of them across those specs): “40 cases dropped” and “this operation is uncovered” are different facts, and per-operation notices are the first thing a CI step greps away.
  • Every $ref is inlined once and shared, and inlining stops after 200,000 nodes. Copying at each occurrence turned one real few-MB spec into a 2.03 GB structure and ran out of memory; past the budget a $ref inlines as {}, exactly as a cyclic one does, and gen-fixtures says so.
  • A keyword written beside a $ref is read by the rule the mock reads it with, not by “everything wins”. Narrowing ones apply — enum, const, format, pattern, the bounds, example, default — that is how a spec restricts one use of a shared type. Structural ones do not: type, properties, items, required, additionalProperties, allOf/oneOf/anyOf, nullable, discriminator. Same reason the mock gives them up (they were illegal beside $ref before 3.1, so Swagger-2 conversions are full of leftovers), plus one specific to the generator: a generated case asserts against crust’s own validator, which resolves a $ref and validates the resolved node alone. britbox writes {$ref: ItvDeleteAccountRequest, type: string} in an openapi 3.0.0 document; that used to inline as a string still carrying the target’s required and properties — a node no value satisfies — so crust’s own base body failed crust’s own validator and all four of that spec’s JSON-body operations were dropped behind crust's own value for the body (type). They now generate 16 cases, missing required 'profileToken' among them. The rule costs as well as gives: digitalocean writes {$ref: …, required: [droplet_ids]}, and crust no longer fills that field, because its validator would not 400 without it either — a case built from the sibling would assert a 400 the mock answers 200. Measured across 4,138 APIs-guru specs: 2,274 put a keyword beside a $ref, 180 of them a structural one (2,003 sites — azure 104 specs, twilio 32; type 157, nullable 43), and of those 180, 118 are Swagger 2.0, 56 are openapi 3.0.x and only 6 are 3.1. Dialect is deliberately not part of the rule: 3.1 legalises siblings without making a conflicting type any less ambiguous, and honouring them there would put the two halves of crust back in disagreement over exactly those 6 specs.
  • maxLength above 4096 is skipped — checked-in files stay reviewable.
  • maximum: Number.MAX_SAFE_INTEGER is treated as an “unbounded” sentinel and skipped.
  • Zod-style nullable wrappers (anyOf: [X, {type: "null"}]) are unwrapped, so nullable fields get their boundary cases too.
  • The pattern case is deduped when the wrong-type case already sends an unparseable string.
  • The extra-property case asserts only status + code === "validation" — unknown-key naming in fieldErrors varies too much by server to pin.

Two practical notes from running these against real servers:

  • Local $refs are inlined; remote ones are not. A body written as $ref: "#/components/schemas/Req" is resolved and gets its full 400 matrix — checked against the published binary, which returned missing required 'profileToken', wrong type for 'profileToken' and unexpected extra property for exactly that shape. A body written as $ref: "./common.yaml#/components/schemas/Req" is not: crust never fetches anything at generation time, so that operation gets no base body and no 400/boundary cases, only the auth/404 ones, and nothing is printed about it. Bundle or inline external refs ($ref: "./…" or $ref: "https://…") before feeding a spec in.
  • Validate before you look up. The PATCH/PUT boundary cases hit a random uuid with an invalid body and expect 400. A handler that checks existence first will 404 and fail the case. Spec-conformant servers validate the body before touching storage — the generated matrix enforces that ordering.

Swagger 2.0: the body is a parameter

Swagger 2.0 has no requestBody. An operation’s body is a parameter with in: body, and the converter used to drop it — so a 2.0 document could get no 400 matrix at all, however carefully it documented its 400s, and the hint it printed (“the 400 matrix needs a JSON request-body schema plus a documented 400”) was precisely true and completely useless, because the spec had one in the other dialect. aiception.com emitted 5 cases; adafruit.com emitted 68 and not one of them about a body.

Measured over the APIs-guru corpus with references resolved: 1,528 specs and 10,870 operations declare their bodies this way — 10,083 on the operation, 775 through $ref: #/parameters/…, 12 on the path item. A third of the corpus, every one Swagger 2.0. My first two scans said 1,443 and 10,083: they matched in == "body" and so never saw a body arriving by $ref. The control caught it — a “body-less” control spec whose generated tree changed.

The conversion now moves the parameter where both halves of crust read it, once, in swagger2to3. Across that population: cases 1,818 → 3,711, qualifying collections 0 → 68 (a 2.0 spec had never produced a CRUD flow), 38 specs off absolute zero, no spec losing a case, and no new drop notice blaming crust. The control is 120 Swagger-2 specs with no body parameter: 0 of 120 differ in their generated tree — hashed, not counted.

Two edges worth knowing, both of them controls in the suite:

  • consumes picks the media type, operation-level first, JSON preferred, and a declared non-JSON type is carried as declared. in: formData is not converted at all, because it becomes a form body and crust does not validate non-JSON request bodies — a case built from one would assert a 400 that the mock never returns. The control says exactly that: a non-JSON consumes generates nothing, precisely as the mock validates nothing.

  • A body declared on GET or HEAD stays a parameter in Swagger 2.0, and is treated as absent in 3.x (20 corpus specs / 270 GET operations declare one natively; 9 more do it as a 2.0 parameter: azure botservice/mariadb/mysql, hetras, n-auth, ticketmaster, illumidesk). A generated fixture fetches, and fetch refuses a body on those two methods outright — fetch() request with GET/HEAD/OPTIONS method cannot have body. — so the case would error rather than fail. Bun discards a GET body even when a raw client sends one, so there is no request crust could emit that any API would see. The operation’s other cases survive without the body; only the 400 matrix is not derived, and it is disclosed:

    gen-fixtures: 1 operation(s) document a request body on GET or HEAD — crust sends no body with
    those methods (fetch refuses the request), so their 400 matrix is not generated; 1 of them now
    have no negative cases at all

    Across those 29 specs, GET cases carrying a body went 15 → 0 and 408 of 414 remain — what crust can send on a GET it still sends, and the 6 that went could never have run. Those operations count toward the note: N operation(s) left with no negative cases aggregate (261 of 270 there), because a silently smaller suite is the failure mode this project ranks worst. Control: 120 specs with a body on POST/PUT/PATCH and none on GET/HEAD — 0 of 120 generated trees differ, hashed rather than counted.

One consequence to expect when you regenerate: the 401/403/404 cases of a 2.0 operation now send the body the spec declares instead of nothing, since a route may validate the body before it checks who is calling. Those cases carry a schema-valid body for exactly that reason.

Generated CRUD flows

Unless you pass --no-flows, qualifying collection paths also get a flow suite: --out/flows/flows.gen.pipes plus a sibling flows.gen.setup.ts that test-pipes auto-detects — zero extra flags to run. Here’s a generated file, line by line:

# GENERATED by crust gen-fixtures — DO NOT EDIT.
# CRUD flows derived from the OpenAPI spec; run with test-pipes (the sibling
# flows.gen.setup.ts is auto-detected and seeds $GEN_AUTH_HEADER/$GEN_URL_*;
# capture stages write $GEN_ID_* at run time).
# NOTE: SQL assertions are not derivable from a spec, so none are emitted —
# add DB-level checks in a hand-written .pipes file if you need them.

# flow: /things  (create -> read -> update -> delete -> read-after-delete)
{"name":"gen-value-x","kind":"widget"} | POST $GEN_URL_THINGS -H "$GEN_AUTH_HEADER" | assert (r => r.status === 201) | (r => r.json()) | capture GEN_ID_THINGS (b => b.thing.id)
GET $GEN_URL_THINGS/$GEN_ID_THINGS -H "$GEN_AUTH_HEADER" | assert (r => r.status === 200) | (r => r.json()) | assert (j => JSON.stringify(j).includes(process.env.GEN_ID_THINGS ?? " "))
{"name":"gen-value-x"} | PATCH $GEN_URL_THINGS/$GEN_ID_THINGS -H "$GEN_AUTH_HEADER" | expect 200
DELETE $GEN_URL_THINGS/$GEN_ID_THINGS -H "$GEN_AUTH_HEADER" | expect 204
GET $GEN_URL_THINGS/$GEN_ID_THINGS -H "$GEN_AUTH_HEADER" | expect 404
  • Line 1 — create. The body is the same schema-valid base body the 400 cases perturb. The status comes from the op’s lowest documented 2xx. The tail parses the response and captures the new id into $GEN_ID_THINGS — the generator derived where the id lives (b.thing.id) from the POST’s 2xx response example/schema.
  • Line 2 — read. Interpolates the captured id into the item URL and asserts the id appears in the response body (capture values live in process.env, which is how a JS predicate reads them).
  • Line 3 — update. Prefers PATCH over PUT when both exist; sends a valid partial body.
  • Line 4 — delete. Emitted only when the spec documents a DELETE.
  • Line 5 — tombstone. The read-after-delete 404 — emitted only when the item GET documents a 404 and a DELETE exists.

The generated flows.gen.setup.ts adapts your existing setup module — it calls shared() and headersFor(ctx, "member") to seed $GEN_AUTH_HEADER, and resolvePath per flow to seed $GEN_URL_<T>. Same contract, no extra exports.

Qualification — a collection path P gets a flow when: it has a POST with an application/json request schema and a documented 2xx; an item path P/{param} with at least one of GET/PUT/PATCH/DELETE exists; P carries no path params beyond the scope param (nested collections are skipped with a stdout notice); the created id’s location is derivable from the POST’s 2xx response — the media example, else schema.properties: top-level id, else the first object-valued property containing an id (not derivable → skipped with a notice naming the path); and the derived create body satisfies its own schema — a required field whose pattern crust cannot build would 400 on the flow’s very first step, so the flow is skipped with a notice instead of shipped, and flowOverrides is how you fill it.

gen-fixtures ./openapi.json --no-flows   # matrix only

Wiring flows into the suite — a flow file is a normal .pipes file, so it runs alongside your hand-written ones:

# everything at once: hand-written suites AND generated flows
test-pipes 'tests/**/*.pipes' -b -t5000

Each .pipes file is hermetic (env snapshotted/restored per file), so generated flows can’t leak $GEN_* vars into your hand-written suites — and since the spec can’t know your table names, add sql-assert lines in a hand-written .pipes file when you want the DB-level counterpart of a flow.

The setup module contract

Generated files are app-agnostic: they import only from your --setup module. It carries every app-specific detail — how to authenticate, what “scope” means, where the API lives. Required exports:

ExportContract
shared(): Promise<Ctx>Promise-cached scenario factory (users, roles, ids). Wired as every fixture’s setup, so it must cache — build once on first call, return the same promise afterwards (safe under --threads). It must also be lazy: no side effects at import time, because the generator imports the module at generation time just to read the scope config.
headersFor(ctx, role)Request headers for "member" (clears both auth and scope gates) or "outsider" (authenticated, no membership in the shared scope). Generated code never calls it with "none".
resolvePath(ctx, template)Takes the raw path template with {param} placeholders, substitutes scope params from ctx, fills any other {param} with a random uuid, and prefixes the API base URL. Returns the absolute request URL.
scopeParam: string | nullThe template param name that marks a path as scope-gated (e.g. "buildingId"). An op is scope-gated when its path’s first template param has this name. null disables 403 derivation.
scopeRoots?: string[]Optional path prefixes (e.g. "/api/buildings") whose immediately following first template param is the scope id even under a different name (/api/buildings/{id}).
JSON_HEADERSPlain unauthenticated JSON headers, used for the 401 cases.

A minimal real module:

// tests/gen-setup.ts
const BASE = process.env.BASE ?? "http://localhost:3000";

export const JSON_HEADERS = { "content-type": "application/json" };
export const scopeParam = "buildingId";
export const scopeRoots = ["/api/buildings"];

// Promise-cached: every generated fixture shares ONE scenario.
let scenario: Promise<{
  buildingId: string;
  memberToken: string;
  outsiderToken: string;
}> | null = null;

export function shared() {
  scenario ??= (async () => {
    const member = await signupAndLogin("member@gen.test");
    const building = await createBuilding(member.token);
    const outsider = await signupAndLogin("outsider@gen.test"); // no membership
    return {
      buildingId: building.id,
      memberToken: member.token,
      outsiderToken: outsider.token,
    };
  })();
  return scenario;
}

type Ctx = Awaited<ReturnType<typeof shared>>;

export function headersFor(ctx: Ctx, role: "none" | "member" | "outsider") {
  if (role === "none") return JSON_HEADERS;
  const token = role === "member" ? ctx.memberToken : ctx.outsiderToken;
  return { ...JSON_HEADERS, authorization: `Bearer ${token}` };
}

export function resolvePath(ctx: Ctx, template: string) {
  const path = template
    .replace("{buildingId}", ctx.buildingId)
    .replace(/\{\w+\}/g, () => crypto.randomUUID()); // unknown ids -> 404 bait
  return `${BASE}${path}`;
}

The import specifier in generated files is rewritten relative to --out, so a path-like --setup works from anywhere; a bare package specifier is kept as-is.

The crust repo ships a complete runnable module to copy at examples/gen-setup.ts — the same shape as above with working fetch-based auth helpers to replace with your API’s flow.

crust does not ship ./tests/gen-setup.ts — that path is the default by convention in your repo. Run gen-fixtures without one and it says so, naming the path it looked at, the template to copy, and the --setup flag.

What a generated file looks like

// GENERATED by crust gen-fixtures — DO NOT EDIT.
import { JSON_HEADERS, headersFor, resolvePath, shared } from "../gen-setup.ts";

type Ctx = Awaited<ReturnType<typeof shared>>;

export default [
  {
    name: "POST /api/buildings without credentials -> 401",
    setup: shared,
    input: (ctx: Ctx) => ({
      url: resolvePath(ctx, "/api/buildings"),
      method: "POST",
      headers: JSON_HEADERS,
      body: "{\"name\":\"gen-value-x\"}",
    }),
    output: { status: 401 },
  },
  {
    name: "POST /api/buildings missing required 'name' -> 400",
    setup: shared,
    input: (ctx: Ctx) => ({
      url: resolvePath(ctx, "/api/buildings"),
      method: "POST",
      headers: headersFor(ctx, "member"),
      body: "{}",
    }),
    output: {
      status: 400,
      data: (d: { code?: string; fieldErrors?: Record<string, unknown> }) =>
        d.code === "validation" && d.fieldErrors !== undefined && "name" in d.fieldErrors,
    },
  },
];

Plain test-fixture fixtures — nothing magic. --threads, --timeout, --bail, and report formats all apply.

Tips

  • Descriptions are the trigger. The 401 derivation keys off the response description (/not authenticated|log in/i). If your middleware 401s aren’t matching, that’s the string to fix in the spec.
  • Run against the real server, not the mock. The whole point is proving the server rejects; mock-server accepts anything that routes — unless you boot it with --validate, in which case it rejects with 422, which still isn’t your server’s documented 400 contract.
  • Zero cases for an op means the spec is silent. Documenting the 400/401/403/404 responses — and the minLength/maximum/pattern constraints — is what buys you the tests.
  • API smoke tests — the positive-path counterpart: one-liners, capture-chained .pipes suites, .crust.ts fixtures.
  • Mock server — the spec’s other uses: mocking, request validation, and conformance-proxying.
  • Stress testing — the same runner at volume.