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
.pipesfile — a suite of shell lines, run in order, chained withcapture, with SQL/assertlines checking what earlier lines did. .crust.tsfixtures (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 await readAll("fixtures/*.json")
.pipe(POST("http://localhost:3000/users"))
.pipe(expectStage(201))
.collect();
// On mismatch, expectStage rejects with ExpectError { item, index, matcher }. 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 immediately —
crust: 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. ASELECTthat matches zero rows streams zero items, so a per-item predicate would never run;assertturns that into a failure (“no items reached”) instead of a silent pass.- Env expansion covers URLs,
-Hvalues, JSON literals andsqlargs (SQL positionals$1/$2survive — a digit can’t start an env var name). Lambda andassertbodies are JS — useprocess.env.TOKENthere, 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 withGET <url>: timed out after 2000msinstead 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--flagson 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: () => truereads like “any value here” but a zero-argument function is a thunk supplying the expected value, so it means “the body must equaltrue”. 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 // fixtures/users.crust.ts
export default {
name: "GET /users/42 returns Lario",
input: {
method: "GET",
url: "http://localhost:3000/users/42",
headers: async () => ({
Authorization: `Bearer ${await Bun.jwt.sign({ sub: "42" }, "k")}`,
}),
},
output: {
status: 200,
data: { id: 42, name: "Lario" },
headers: {
"content-type": (v: string) => v.startsWith("application/json"),
},
},
}; 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 asexpectStageto avoid collisions when youimport { expect as expectStage }in test files. From the shell line it’s justexpect 201— orexpect 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') // The TS-API form composes the same stages with .pipe()
await Pipeline.of([{ name: "Lario", email: "lario@example.com" }])
.pipe(POST("http://localhost:3000/users"))
.pipe(expectStage(201))
.pipe(async (r) => (await r.json()) as { id: number })
.pipe(async (u) => {
const [row] = await Bun.sql`
select name, email from users where id = ${u.id}
`;
return row as { name: string; email: string } | undefined;
})
.pipe((row) => {
if (row?.email !== "lario@example.com") {
throw new Error(`DB mismatch: ${JSON.stringify(row)}`);
}
return row;
})
.collect(); 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) // Source equivalent
const emails = await Pipeline.of([])
.pipe(() => Bun.sql`select id, email from users order by id desc limit 5`)
.collect();
// Or use Bun.sql directly when you're already in TS:
const rows: Array<{ id: number; email: string }> = await Bun.sql`
select id, email from users order by id desc limit 5
`; 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 pass1— any failure / error2— no files matched, or bad args
Related
- 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 Nfor percentile reports;loadscenarios for paced runs. - CI load gates — turn
statssummaries into pass/fail thresholds. - Mock server — point smoke tests at an OpenAPI mock when the real service isn’t running.