API smoke tests

One-liners, capture-chained CRUD .pipes suites with DB asserts and negative blocks, and .crust.ts fixtures — API smoke tests at three altitudes.

Goal: prove an HTTP service does what it claims — creates persist, reads round-trip, deletes tombstone, and the API rejects what it must — with pipelines instead of a test framework.

This recipe works at three altitudes. Pick by how much each test needs to know:

  • One shell line — the whole test is self-contained: body, headers, one assertion.
  • A .pipes file — a suite of shell lines, run in order, chained with capture, with SQL/assert lines checking what earlier lines did.
  • .crust.ts fixtures (the TypeScript API) — a single test needs multi-step logic: generated tokens, per-fixture setup/teardown, structural matchers.

The one-liner

# read yields each file's CONTENTS (a bare glob would POST the paths)
read fixtures/*.json | POST :3000/users | expect 201

read fixtures/*.json is a pipeline with one item per matched file — the file’s whole contents, which POST :3000/users sends as the request body, per item. expect 201 drains the stream and fails the pipeline (exit 1) naming the mismatch count. When the exact status doesn’t matter, class matchers work too: expect 2xx accepts anything 200–299 (3xx, 4xx, 5xx likewise). Add auth with -H (URLs, -H values and JSON literals are $VAR env-expanded):

read fixtures/*.json | POST $BASE/api/users -H "authorization: Bearer $TOKEN" | expect 201

Reach for the one-liner when the test has no memory: fire, assert, done. The moment a later request needs a value from an earlier response, move up a rung.

Request chaining — capture

capture NAME (fn) runs fn on each item and writes the result to process.env.NAME. Because crust parses each line right before running it, every later line sees $NAME — in the REPL, in crust -c scripts, and in .pipes files. That one primitive turns “a pile of one-liners” into “a flow”.

The classic first capture is the auth token:

crust -c '{"email": "smoke@test.dev", "password": "pw-123456"} | POST :3000/api/login | assert (r => r.status === 200) | (r => r.json()) | capture TOKEN (b => b.token)
GET :3000/api/things -H "authorization: Bearer $TOKEN" | expect 200'

Line 1 logs in, parses the body, and captures the token. Line 2 — a separate pipeline — expands $TOKEN like any env var. Captures happen at run time and $VAR expansion at parse time, which is why chaining works across lines but never within one.

Two honesty rules keep captures from lying to you:

  • A nullish captured value fails the pipeline immediatelycrust: capture TOKEN: got undefined from (b => b.token) — item 1 — instead of expanding to a baffling "" three lines later.
  • An empty upstream also fails. A capture that captured nothing is a bug, not a pass.

Mind the names: capturing into TOKEN, BASE, or PATH overwrites those for the rest of the run.

A full CRUD flow in -c

crust -c '{"email": "smoke@test.dev", "password": "pw-123456"} | POST :3000/api/login | (r => r.json()) | capture TOKEN (b => b.token)
{"name": "Court", "kind": "widget"} | POST :3000/api/things -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture THING_ID (b => b.thing.id)
GET :3000/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 200
{"priority": 5} | PATCH :3000/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 200
DELETE :3000/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 204
GET :3000/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 404'

Create → read → update → delete → read-after-delete, and the tombstone 404 is asserted too. crust -c is fail-fast across lines: the first failing line stops the script and its exit code is the script’s exit code.

When a flow like this stops being a throwaway and becomes the smoke suite, don’t leave it in a shell script — that’s exactly what .pipes files are for.

A suite of one-liners — .pipes files

A .pipes file is one shorthand fixture pipeline per line# comments and blank lines skipped. test-pipes runs the lines sequentially, in order — on purpose, because suites interleave requests with capture chains and sql/assert lines about what the previous line did:

# smoke.pipes — CRUD with chaining, DB cross-checks, and a negative block
{"name": "Court", "kind": "widget"} | POST $BASE/api/things -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture THING_ID (b => b.thing.id)
GET $BASE/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 200

# prove the write actually landed — not just that the API said 201
sql "SELECT count(*)::int AS c FROM things WHERE name = 'Court'" | assert (r => r.c === 1)

{"priority": 5} | PATCH $BASE/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | (r => r.json()) | assert (b => b.thing.priority === 5)
DELETE $BASE/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 204
GET $BASE/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 404
sql "SELECT count(*)::int AS c FROM things WHERE id = $1" "$THING_ID" | assert (r => r.c === 0)

# negative block — the API must REJECT these
GET $BASE/api/things | expect 401
{"kind": "widget"} | POST $BASE/api/things -H "authorization: Bearer $TOKEN" | expect 400
GET $BASE/api/things/00000000-0000-0000-0000-000000000000 -H "authorization: Bearer $TOKEN" | expect 404

The negative block is not decoration. A suite that only checks happy paths will pass against a server whose auth middleware silently fell off. expect 401 on the bare-request line is the test that catches it. If your API scopes resources per tenant, add the 403 line too — an authenticated outsider hitting a resource they have no membership in — or better, generate the whole rejection matrix from your spec.

Run one file, or a tree of them:

test-pipes smoke.pipes
test-pipes 'tests/**/*.pipes' -b -t5000
  PASS  tests/smoke.pipes:2  {"name": "Court", "kind": "widget"} | POST $BASE/api/things -H "authorization: Bear  (2.0ms)
  PASS  tests/smoke.pipes:3  GET $BASE/api/things/$THING_ID -H "authorization: Bearer $TOKEN" | expect 200  (0.5ms)
  ...
1 file(s): 8 pass, 0 fail

--bail stops at the first failing line (across files); --timeout <ms> fails any line that runs longer. Exit codes: 0 all pass, 1 any fail, 2 no files / bad args. Quote the glob so crust expands it, not your outer shell.

Three behaviors that keep suites honest:

  • assert (x => expr) fails on falsy — and on an empty upstream. A SELECT that matches zero rows streams zero items, so a per-item predicate would never run; assert turns that into a failure (“no items reached”) instead of a silent pass.
  • Env expansion covers URLs, -H values, JSON literals and sql args (SQL positionals $1/$2 survive — a digit can’t start an env var name). Lambda and assert bodies are JS — use process.env.TOKEN there, not $TOKEN.
  • Any http line can carry a per-request --timeout <dur> (ms/s/m): GET $BASE/api/things/$THING_ID --timeout 2s | expect 200. A hung endpoint fails that line with GET <url>: timed out after 2000ms instead of hanging the run — sharper than the runner’s own --timeout <ms> flag, which is a per-line wall clock in integer ms. Typo’d --flags on http stages are loud errors, so a misspelling can’t silently drop the bound.

The setup module

Before a file runs, its setup module is imported and its default export awaited: pass --setup <module>, or drop a sibling <name>.setup.ts next to the .pipes file — it’s picked up automatically. Setup seeds process.env; that’s where $BASE and $TOKEN come from:

// smoke.setup.ts — sibling of smoke.pipes, picked up automatically
export default async () => {
  process.env.BASE = "http://localhost:3000";
  const res = await fetch("http://localhost:3000/api/login", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ email: "smoke@test.dev", password: "pw-123456" }),
  });
  process.env.TOKEN = (await res.json()).token;
};
test-pipes smoke.pipes                       # sibling smoke.setup.ts auto-detected
test-pipes smoke.pipes -s ./seed.ts     # or explicit

Per-file hermeticity

Each .pipes file gets a fresh pipeline context: builtin fns (sql, …) registered, but no init.ts and no shared alias state. And process.env is snapshotted before setup and restored when the file finishes — setup vars and captures never leak across files, between runs, or into your interactive session. Two files can both capture $THING_ID without stepping on each other, and a .pipes file behaves the same on every machine. That’s a contract, not an accident.

Wiring it into CI

The suite is one command with meaningful exit codes, so CI wiring is two lines — wait replaces the curl-sleep retry loop:

crust -c 'wait :3000/api/health --timeout 40s --interval 2s'
crust -c "test-pipes 'tests/**/*.pipes' -b -t5000"

The TypeScript API — .crust.ts fixtures

Reach for .crust.ts fixtures when a single test stops being expressible as one line: tokens are generated per fixture, matchers assert on structure, or the fixture needs its own setup/teardown. Each file is a normal TypeScript module that default-exports a fixture (or array) with input and output. Fields can be values or zero-argument thunks (resolved + awaited at run time). In output, a function with at least one parameter is a predicate matcher over the actual value.

Watch the arity. data: () => true reads like “any value here” but a zero-argument function is a thunk supplying the expected value, so it means “the body must equal true”. Since 0.2.4 that is a hard error naming the fix rather than a silent mis-comparison. Write (v) => ... for a predicate, or the literal directly.

setup()’s return value flows through the whole fixture: input/output may be functions of the context, unary input field functions receive it, and every matcher gets it as a second argument. Matchers may be async — the runner awaits them, so asserting the DB side effect of a request is one await away (stick to Bun builtins like Bun.SQL inside fixtures; the compiled runner doesn’t resolve third-party npm packages).

test-fixture 'fixtures/**/*.crust.ts'
test-fixture fixtures/users.crust.ts -o report.md

Matchers

In the output object you can mix exact values and predicates freely:

output: {
  status: 200,                                     // exact
  status: (s: number) => s < 300,                  // predicate
  data: { id: 42 },                                // exact (deep)
  data: (d: any) => d.id === 42 && d.email,        // predicate
  headers: {
    "x-request-id": (v: string) => /[a-f0-9-]{36}/.test(v),
  },
}

Predicates can also be async — useful for cross-checking against a database or another service.

Response-shape conformance — output.schema

schema is a reserved key in output: give it an inline JSON Schema (dereference $refs first) and the response body must conform. Field matchers assert values; schema asserts the shape — every required key, every type, every format, in one declaration:

// fixtures/create-user.schema.crust.ts
export default {
  name: "POST /users response conforms to the user schema",
  input: {
    method: "POST",
    url: "http://localhost:3000/users",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ name: "Lario", email: "lario@example.com" }),
  },
  output: {
    status: 201,
    schema: {
      type: "object",
      required: ["id", "name", "email", "tier", "createdAt"],
      properties: {
        id: { type: "string", format: "uuid" },
        name: { type: "string", minLength: 3 },
        email: { type: "string", format: "email" },
        tier: { type: "string", enum: ["free", "pro"] },
        createdAt: { type: "string", format: "date-time" },
      },
    },
  },
};

Violations fail the fixture with per-field JSON-pointer paths — every violation named, not just the first. Here’s a deliberately broken schema/response pair failing:

  FAIL  users.crust.ts  schema violation shows pointer paths  (6.1ms)
    output.data/plan: expected "required: missing required property 'plan'", got undefined
    output.data/name: expected "minLength: length 2 < minLength 3", got "xy"
    output.data/email: expected "format: does not match format 'email'", got "not-an-email"

The validator is the mock-server’s subset walker, so its never-invent-a-violation rule applies: unknown keywords pass, additionalProperties: false is not enforced, and a schema it can’t judge validates successfully. Functions inside a schema object are never invoked — it’s data, not matchers. gen-fixtures emits this key automatically for any case whose expected status documents a response schema.

Status matchers via expectStage

When you’re writing the pipeline in TypeScript directly, prefer expectStage:

expectStage(200);                        // exact status
expectStage("2xx");                      // class — "2xx" | "3xx" | "4xx" | "5xx"
expectStage((res) => res.status < 300);  // custom predicate

The TS-test ecosystem owns the name expect. Crust exports it as expectStage to avoid collisions when you import { expect as expectStage } in test files. From the shell line it’s just expect 201 — or expect 2xx.

Cross-check the database after a request

Status codes are necessary but not sufficient. The endpoint can return 201 and still write the wrong row — or no row at all. Crust ships a sql builtin backed by Bun’s SQL client (via $DATABASE_URL), so you can pipe a POST response straight into a SELECT and assert on the result with a normal predicate lambda.

The shape: POST → JSON → id → query → row → predicate. Each | is one transform.

# Set DATABASE_URL once per session
export DATABASE_URL=postgres://app:app@localhost:5432/app

# Create the user, then verify the row landed with the right values.
# `Bun.sql\`...\`` template tags safely parameterize ${id}.
# `assert` fails the pipeline on falsy — AND if no row reaches it.
{"name": "Lario", "email": "lario@example.com"} \
  | POST :3000/users \
  | (r => r.json()) \
  | (u => u.id) \
  | (async id => (await Bun.sql`select name, email from users where id = ${id}`)[0]) \
  | assert (row => row?.email === 'lario@example.com' && row?.name === 'Lario')

In a .pipes suite the same cross-check is cleaner — capture the id, then a plain sql line with the id as a parameter (see the suite above). The lambda-interpolation form is for one-liners, where there’s no later line to expand $THING_ID into.

Using the sql builtin directly

When the query is constant — no upstream interpolation needed — call sql as a stage. It uses Bun’s $DATABASE_URL client and streams one row per item as a source. As a transform, the static args become (query, ...params); the upstream item is not auto-bound, so reach for a lambda when you need to inject a value from the stream.

# Source — every recent user becomes one item downstream
sql "select id, email from users order by id desc limit 5" | (r => r.email)

# Transform with static params — last item passes through, params are constant
range(0, 0)
  | sql "select count(*) as n from users where email like $1" "%@example.com"
  | (rows => rows[0].n)

A .crust.ts fixture that does the same cross-check

If you’d rather keep the assertion declarative, do the DB check inside the fixture’s output predicate. Predicate functions can be async, so Bun.sql works natively.

// fixtures/create-user.crust.ts
export default {
  name: "POST /users persists the row",
  input: {
    method: "POST",
    url: "http://localhost:3000/users",
    body: { name: "Lario", email: "lario@example.com" },
  },
  output: {
    status: 201,
    data: async (d: { id: number }) => {
      const [row] = await Bun.sql`
        select name, email from users where id = ${d.id}
      `;
      return (
        row?.name === "Lario" && row?.email === "lario@example.com"
      );
    },
  },
};

Want to point at a different engine — Postgres vs. MySQL vs. SQLite vs. a read replica? See DB drivers.

Reports & exit codes

test-fixture 'fixtures/**/*.crust.ts' -j8 -o report.xml

Report format is picked from --out’s extension: .json, .md, .xml (JUnit — one testsuite per fixture file, one testcase per run, so CI points at the exact failing iteration), anything else is plain text. With no --out, you get a colored folder-grouped summary on stdout.

Exit codes:

  • 0 — all pass
  • 1 — any failure / error
  • 2 — no files matched, or bad args
  • Generated negative fixtures — derive the 401/403/404/400 matrix and capture-chained CRUD flow suites from your OpenAPI spec instead of writing them.
  • Stress testing — same fixtures, --count N for percentile reports; load scenarios for paced runs.
  • CI load gates — turn stats summaries into pass/fail thresholds.
  • Mock server — point smoke tests at an OpenAPI mock when the real service isn’t running.