Mock, Test, Load, Tail: One Binary Instead of Eight Dependencies
crust: spec-driven mocks, generated fixtures, load gates, and log tooling in one compiled Bun binary — and it never reports a false pass.
The setup tax
Think about what it actually takes to test one HTTP API properly.
You want integration tests, so you install a test runner, an HTTP client, and an assertion library, then spend an hour making the TypeScript transform, the config file, and the CI reporter agree with each other. You want load numbers, so you install a second tool — a separate binary with its own bundled JavaScript runtime and its own config dialect. You want to develop the frontend before the backend is done, so you add a mock server — a third tool, with stub files to maintain. You want to check that a write actually landed in the database, so you wire up a client driver and a connection fixture, or bring in testcontainers. And when something fails you’re back to four terminal panes of interleaved log soup.
Here is a perfectly normal devDependencies block for that stack, before a single test exists:
{ "devDependencies": { "jest": "…", "ts-jest": "…", "@types/jest": "…", "supertest": "…", "@types/supertest": "…", "pg": "…", "testcontainers": "…", "@stoplight/prism-cli": "…" } }
Plus jest.config.js, plus a k6 binary outside the package manager entirely, plus a directory of mock stubs. Every tool has its own grammar, its own config, its own failure modes. The afternoon is gone and you haven't asserted anything yet.
I got tired of assembling this pile for every project, so I built one tool that covers the whole surface. It’s called crust, and I published it this week.
One binary, no dependencies, no config file
crust is a pipeline-first devops toolkit built on Bun, shipped as one compiled binary. Install is a single command, and either channel resolves the same prebuilt artifact and verifies its published sha256 before it is ever executable — no clone, no Bun on the target machine:
curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash # or npm i -g @lariocpt/crust
The dependency story is not “few dependencies.” The package.json has no dependencies key at all — every import in the source is a Bun or Node builtin. There is also no project config file. No crust.config.js, no init wizard: test runners point at a glob, and the only per-suite boilerplate is an optional five-line setup module that seeds environment variables.
Everything rides on one mental model. Every crust line is a pipeline: a source produces a stream, transforms reshape it, a sink consumes it. Shell commands and TypeScript lambdas are equal citizens on the same |, and :3000/path is shorthand for localhost. Which means a load test looks like this:
range(0, 10000) | parallel 100 | GET :3000/health | expect 200 | stats
Learn that one grammar and you have learned the fixture runner, the load runner, the mock-server assertions, and the log tooling — they are all the same stream.
One more thing before the tour: crust was not designed on a whiteboard. It was dogfooded privately against a real 97-operation production API — 406 generated fixtures, spec-driven mocking for two client apps — and along the way it surfaced 169 genuine spec-conformance findings and three production bugs in the app it was built to test.
A CRUD suite in five lines
The conventional version first. With Jest and supertest, a create-read-delete flow is a file of ceremony — and this is the terse ecosystem option; the Postman/Newman version of the same flow is a few hundred lines of exported collection JSON:
// buildings.test.ts — after jest.config.js and ts-jest are appeased import request from "supertest"; let token: string, id: string; beforeAll(async () => { token = await login(); }); it("creates a building", async () => { const res = await request(BASE).post("/api/buildings") .set("authorization", `Bearer ${token}`) .send({ name: "Court", floors: 3 }) .expect(201); id = res.body.building.id; }); it("reads it back", async () => { await request(BASE).get(`/api/buildings/${id}`) .set("authorization", `Bearer ${token}`).expect(200); }); // …plus DELETE, the 404 check, and a pg client for the row assertion
In crust, that whole suite is a .pipes file: one pipeline per line, run in order, chained with capture:
# smoke.pipes — a whole CRUD suite with request chaining, no framework {"name": "Court", "floors": 3} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture BID (b => b.building.id) GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 200 sql "SELECT count(*)::int AS c FROM buildings WHERE name = 'Court'" | assert (r => r.c === 1) {} | DELETE $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 204 GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 404
Five lines: create, read it back, verify the database row, delete, confirm the tombstone. capture BID writes the created id into the environment so $BID expands on every later line. test-pipes 'tests/**/*.pipes' runs it; -o report.xml emits JUnit for CI. The setup boilerplate, in full:
// smoke.setup.ts export default async () => { process.env.BASE = "http://localhost:3000"; process.env.TOKEN = await loginAndGetToken(); };
When a case needs real code — generated emails, custom matchers, a database side-effect check — it graduates to a .crust.ts fixture module:
export default { name: "signup lands a users row", setup: () => ({ email: `u-${crypto.randomUUID()}@t.dev` }), input: (ctx) => ({ url: "http://localhost:3000/signup", method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: ctx.email, password: "pw-123456" }), }), output: { status: 201, data: async (d, ctx) => (await db.userByEmail(ctx.email)) !== null, }, teardown: (ctx) => db.deleteByEmail(ctx.email), };
The entire fixture API is one convention: zero-argument functions are resolved, one-argument functions in input receive the setup context, functions in output are matchers over the actual value, and async matchers are awaited — so a DB assertion is one await away. The same runner does parallelism and stress:
test-fixture "tests/api/**/*.crust.ts" -j 8 -o reports/api.junit.xml test-fixture tests/api/checkout.crust.ts -n 500 -j 32 -o reports/stress.json
-n 500 turns any fixture into a 500-iteration stress run with p50/p95/p99, and under JUnit output each iteration is its own test case, so CI points at the exact failing one.
Generated, not written
Those 406 fixtures I mentioned? I didn’t write them. gen-fixtures derives, from an OpenAPI spec, the cases your server must reject:
gen-fixtures ./openapi.json test-fixture 'tests/gen/*.gen.crust.ts' -j8 test-pipes tests/gen/flows/flows.gen.pipes
For every documented operation it emits a negative matrix — 401 with no credentials, 403 as an authenticated outsider, 404 for unknown ids, and a 400 case per body field: missing required, wrong type, bad enum, too short, too long, out of numeric bounds, pattern violation, unexpected extra property. Perturbations are applied to a schema-valid base body with format-aware synthesis, so exactly one thing is wrong per case. It also generates full CRUD flow suites (create → read → update → delete → 404), chained with capture.
The nearest comparison is Schemathesis, which is excellent — but property-based: it fuzzes randomly per run. gen-fixtures output is deterministic and byte-stable, which changes how you use it: check the generated files in, regenerate after a spec change, and review the git diff like any other code. And the docs are honest about the boundary: SQL assertions are not derivable from a spec — those you write by hand in your own .pipes.
The database is just another stage
You saw it in the CRUD suite. The pytest equivalent means a driver dependency, a conftest.py, a connection fixture, and teardown discipline:
def test_building_row_exists(db): # `db` = a fixture you maintain with db.cursor() as cur: cur.execute("SELECT count(*) FROM buildings WHERE name = %s", ("Court",)) assert cur.fetchone()[0] == 1
In crust, sql is a builtin over Bun's SQL client — it needs $DATABASE_URL and nothing else, and it streams one item per row:
sql "SELECT count(*)::int AS c FROM buildings WHERE name = 'Court'" | assert (r => r.c === 1) sql "SELECT status FROM orders WHERE id = $1" $OID | assert (r => r.status === "paid")
Two design details earn their keep here. The $1 placeholder survives crust's env expansion untouched — a digit can't start an env var name — and binds to the trailing argument, which does expand, so parameterized queries and environment chaining coexist on one line. And assert deliberately fails on an empty upstream. A lambda maps, filter drops, but assert refuses to be reached by nothing — because SELECT returning zero rows into a passing test is the classic silent lie of database assertions.
Load testing CI can actually gate on
The incumbent here is k6, and credit where due: it’s a fine tool, and if you need distributed load from a fleet, use it. But it is a separate binary with its own runtime, and a threshold lives in a config block:
import http from "k6/http"; export const options = { scenarios: { steady: { executor: "constant-arrival-rate", rate: 100, timeUnit: "1s", duration: "30s", preAllocatedVUs: 50 } }, thresholds: { http_req_duration: ["p(95)<200"] }, }; export default function () { http.get("http://localhost:3000/health"); }
crust gives you four load shapes in the grammar you already know:
Get Lariocpt Dev’s stories in your inbox
Join Medium for free to get updates from this writer.
# fixed volume: N requests, K at a time range(0, 999) | parallel 50 | GET :3000/health | expect 200 | stats # paced: arrival RATE for a DURATION, concurrency capped by parallel load 30s 100/s | parallel 50 | GET :3000/health | expect 200 | stats # ramp: comma-separated phases, one stats stream across all of them load 10s 50/s, 30s 200/s | parallel 100 | GET :3000/health | stats --every 5 # POST load: ticks are {n, phase, scheduledAt, lagMs} — build bodies from them load 10s 20/s | (t => ({name: "user" + t.n})) | parallel 8 | POST :3000/users | expect 2xx | stats
There is no threshold syntax, because there doesn’t need to be one — stats yields a plain object and assert takes a real predicate, so a CI gate is just composition, and a baseline comparison is one async lambda:
load 30s 100/s | parallel 50 | GET :3000/health | stats | assert (s => s.p95 < 200) | assert (s => s.rps > 80) load 10s 100/s | parallel 50 | GET :3000/health | stats --out load/last.json | assert (async s => { const b = await Bun.file("load/baseline.json").json(); return s.p95 < 2 * b.summary.p95 })
The artifact is written before the gate runs, so a failed gate still leaves the JSON behind for CI upload. On the property-management API this runs against, the deployed instance holds GET /api/health at 53ms p95 across 200 requests at concurrency 20, and the same fixture runner stress-tests the write path — signup, which does an argon2id hash plus two inserts, sits at 180ms p95 across 100 iterations at 16-way concurrency, with the status distribution printed alongside.
What I care about most, though, is what the numbers refuse to do:
- A gate that measured nothing fails.
statsover an empty stream is taggedempty: trueandassertrefuses it — because{count: 0, p95: 0}satisfiess => s.p95 < 200, a run that issued zero requests used to be a passing run. - Percentiles are bucketed and report the bucket’s upper bound — never faster than reality — with exact
countand mean, and constant memory, so a multi-hour soak is safe. stats.rpsis the measured rate, never the target. If downstream saturates, crust reportstarget 3000 ticks — emitted 2868, dropped 132 … achieved 95.6/sinstead of quoting percentiles for a rate it didn't sustain — the coordinated-omission trap, closed structurally.
And plainly: this is single-process smoke-load and soak tooling, not a distributed rig. When you outgrow it, k6 is right there.
A mock server that survives restarts
Mocking is where the tool count usually explodes. Prism mocks an OpenAPI spec but holds no state; json-server holds state but knows nothing of your spec; WireMock does both if you feed it Java and a directory of stub-mapping JSON files, one per behavior. crust’s mock server is one builtin whose flags are a ladder:
mock-server ./openapi.json -p4000 # example-first mock mock-server ./openapi.json -p4000 --stateful # + in-memory CRUD mock-server ./openapi.json -p4000 --state ./m.sqlite # + persistent CRUD (implies --stateful) mock-server ./openapi.json -p4000 --validate # + reject bad requests (422) mock-server ./openapi.json -p4747 --proxy http://localhost:3001 # conformance proxy
Responses are example-first, schema-fallback, straight from the spec. --stateful means what you POST is what you GET back. --state moves that store into sqlite or postgres: it survives restarts, and — my favorite part — the table shape is a public contract, so your pipeline can assert against the mock's own persisted state:
{"thing": {"name": "x"}} | POST :4010/api/things | expect 201 | (r => r.json()) | capture TID (t => t.thing.id) sql "SELECT json_extract(doc, '$.name') AS name FROM crust_mock_state WHERE collection = '/api/things' AND id = '$TID'" | assert (r => r.name === "x")
Notice the wrapped body: the mock detects entity envelopes ({"thing": {…}}) deterministically and round-trips them, so capturing t.thing.id works exactly as it would against the real API. It can be seeded from a file, armed to fail on demand (PUT /__crust/override — a scoped one-shot 429 for the request your app owns, where no test-controlled header can reach), and with --validate, spec-violating requests get a 422 — deliberately distinguishable from your real API's own 400s.
The --proxy mode inverts the whole tool: put the spec in front of a real backend, forward everything untouched, and record spec violations in both directions. The conformance gate is, of course, a pipeline:
GET :4747/__crust/violations | (v => v.violations.filter(x => x.direction === "response")) | assert (a => a.length === 0)
That mode is where the 169 spec-conformance findings came from.
Every log, one stream
The usual multi-service dev experience is four terminals, or docker compose logs -f painting undifferentiated text at you. crust treats logs as data. Native tail follows any number of files, merging lines into one stream as they arrive, with rotation handled per file:
tail -F services/*/access.log tail -F app.log | grep ERROR
(The -n cut is a bounded backward read — tail -n 10 on a multi-gigabyte log reads a few kilobytes.) For processes rather than files, procs is the one dev tail: it spawns your services and streams every stdout/stderr line as a {proc, stream, line} object, not text:
procs({api: "bun run dev", worker: "bun run worker", db: "docker compose logs -f postgres"}) | (l => l.line) | pino-pretty --colorize procs({api: "bun run dev", worker: "bun run worker"}) | filter (l => l.stream === "stdout") | (l => l.line) | pino-pretty --colorize --minimumLevel warn
Because lines are objects, filtering by service or stream is a lambda, and pretty-printing is just handing the text to pino-pretty (your local node_modules/.bin is on PATH, npm-run style). The full procs spec goes further — per-process env, readiness and liveness probes, dependency ordering, restarts with backoff, and process-group teardown so grandchildren don't outlive the pipeline. And anything that writes to a pipe becomes a source via stdin:
docker logs -f my-app | crust -c 'stdin | (l => JSON.parse(l)) | filter (e => e.level >= 40) | (e => e.msg)' journalctl -f -o json | crust -c 'stdin | (l => JSON.parse(l)) | filter (e => e.PRIORITY <= 3) | (e => e.MESSAGE)'
Search logs without restarting the tail
The grep loop is familiar: run the tail, grep it, realize the pattern is wrong, Ctrl-C, run it again — losing the buffered past every time. logs holds the source open once and lets you iterate. Every line typed at its prompt is ordinary pipeline grammar, run first over the buffered history, then over the live stream until Ctrl-C:
logs tail -n 0 -F app.log logs procs({web: "bun run dev", api: "bun api.ts"}) logs docker logs -f my-container
A session:
logs> grep ERROR # matches from the buffer, then live ones -- live -- ^C logs> json on logs[json]> filter (e => e.level >= 40) | stats --every 5 -- live -- ^C logs[json]> search timeout search: 2 matching item(s) of 4211 buffered logs[json]> buffer 50000 buffer: resized 10000 → 50000 (kept 4211 item(s))
json on parses string items at query time and counts what stayed raw — the ring always stores raw lines, so json off is a lossless revert, and crust suggests the mode when a buffer looks like NDJSON but never auto-enables it, because silently changing the item type under a lambda you already typed would be a false picture. search always prints its count, zero included. Even the caveats are stated: a fragment runs twice (buffer, then live), so side-effecting stages fire twice.
The docs name the boundary themselves: the buffer is an in-memory ring, “not indexed scrollback (that’s lnav’s job)”, and per-line JS lambdas won’t match angle-grinder-class throughput. logs optimizes for iterating without restarting the source, not gigabytes per second.
Skills that can’t rot
If you code with Claude, the usual way to teach it a new tool is pasting docs into context and hoping it guesses the flags. crust ships its own agent skills — five SKILL.md guides covering pipelines, API testing, load testing, the mock server, and procs — embedded in the binary:
skills list skills install # write to ./.claude/skills/<name>/SKILL.md skills install --global
The part I’ve not seen elsewhere: every example line inside those skills is checked against the live grammar by crust’s own test suite (crust --check parses a line without running it — the same linter validates the README and the docs site on every build). If the grammar changes, the skills go red before your agent does. Install is idempotent, and a copy you've edited locally is refused without --force. Your agent gets current, machine-verified knowledge of the tool — not a stale prompt.
The rule underneath: never report a false pass
The design rule that shaped all of this: crust must never report a false pass. A test tool that exits 0 on failure is worse than a slow one. The changelog’s Fixed section reads like a confession of every way tooling quietly lies, hunted down one by one:
test-pipesreported a false pass when a suite yielded zero runnable lines — it exited 0 and wrote a green<testsuites tests="0">. It now exits 2 …A
$refinsideoutput.schemaerrors loudly instead of passing vacuously.
Before v0.2, a lambda that threw under parallel was silently dropped — an entire load run whose handler always threw reported success. A capture that captured nothing turned into a baffling empty string three lines later; now it fails on the spot. The reference keeps a nine-row table of exactly what makes a line fail — three of those rows used to exit 0. This is the actual pitch, more than any feature: the numbers crust prints are ones it is structurally unable to fake.
What crust is not
crust is not a login shell and not a bash replacement — plain shell lines are delegated to sh -c untouched, and there is no job control. It's pre-1.0, so minor versions may carry breaking changes, and the docs keep an honest "Limits" list: no heredocs, no positional script args, no Ctrl-R yet. There's more in the binary than fits here — wait :3001/health --timeout 30s replaces hand-rolled curl-sleep loops in CI, verify-web-links crawls a sitemap for broken links and meta regressions, and any globally-installed npm package whose default export is a function auto-dispatches as a pipeline stage.
One install, zero dependencies, one grammar from first smoke test to soak run:
curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash # or npm i -g @lariocpt/crust
Docs live at lariocpt.github.io/crust-website, source and issues at github.com/lariocpt/crust. If it saves you the afternoon it was built to save me, I’d like to hear about it.





