Testing (@zebra-web/testing)
@zebra-web/testing provides in-process testing: createTestApp drives requests through the entire pipeline without opening a socket (graph validation, middleware chain, DI scopes, error middleware), and createTestClient connects a contract client to the same in-process app. Tests run the exact composition your server runs.
Install
bun add @zebra-web/testingcreateTestApp
import { createTestApp } from "@zebra-web/testing";
const app = createTestApp();
// register exactly like a normal Zebra
app.get("/hello/:name", async (req) => ({ hello: req.params.name }));
// requests go through the pipeline (auto-boot)
const res = await app.request("/hello/world");
await res.json(); // { hello: "world" }TestApp adds two methods on top of Zebra:
| Method | Description |
|---|---|
request(path, init?) | prefixes http://test.local, dispatches, returns Response |
boot() | triggers prepare() explicitly (graph validation + plan compilation + freeze) |
requestauto-boots every time (idempotent).- A full URL (
http://...) is used as-is. - No socket, no
Bun.serve, no port — tests can run in parallel.
The composition-root pattern
Recommended: expose a composition root (a build function) from your app module and reuse it in tests:
// app.ts — shared by production and tests
export function buildForumApp(opts: ForumAppOptions = {}): Zebra {
// all registrations (DI, middleware, routes, ws, lifecycle hooks)
}
// app.test.ts
import { createTestApp } from "@zebra-web/testing";
import { buildForumApp } from "./app";
function makeApp() {
return createTestApp({
// createTestApp takes the same ZebraOptions — inject a container
// with mocks if buildForumApp accepts options
});
}
createTestApp(opts: ZebraOptions)accepts the same options asnew Zebra(opts)— tests can inject mocks via thecontaineroption (bind(IRepo).to(MockRepo),snapshot()/restore()for per-case isolation).
createTestClient
Connect a contract client to the in-process app — socket-free end-to-end type-safe tests:
import { createTestApp, createTestClient } from "@zebra-web/testing";
import { blogContract } from "./contract";
const app = createTestApp();
app.implement(blogContract, { blog: BlogService }, { ... });
const client = createTestClient(app, blogContract);
const created = await client.create({ body: { title: "T", content: "C" } });
const got = await client.get({ params: { id: created.id } });- Return types are identical to
createClient(ContractClient<R>). fetchis replaced byapp.request, exercising the full contract → implement → validate → serialize chain.- Error paths are testable too:
ClientError'scode/status/problemmatch production exactly.
With bun:test
import { describe, expect, test } from "bun:test";
test("create + get round-trip", async () => {
const app = createTestApp();
app.implement(blogContract, ...);
const client = createTestClient(app, blogContract);
const created = await client.create({ body: { title: "A", content: "B" } });
expect(created.id).toBeTypeOf("number");
const got = await client.get({ params: { id: created.id } });
expect(got.title).toBe("A");
});
test("validation error surfaces as typed ClientError", async () => {
const app = createTestApp();
app.implement(blogContract, ...);
const client = createTestClient(app, blogContract);
expect(client.create({ body: { title: "", content: "" } })).rejects.toMatchObject({
status: 422,
code: "validation_failed",
});
});Middleware / session tests
Middleware tests work exactly like production — app.use then drive with app.request:
import { sessionMiddleware } from "@zebra-web/session";
const session = sessionMiddleware({ secret: "test-secret" });
const app = createTestApp({ session: { resolver: session.resolver } });
app.use(session);
app.post("/login", async (req) => {
const s = getSession(req)!;
await s.set("userId", 1);
return { ok: true };
});
// replay the Set-Cookie to verify session persistence
const login = await app.request("/login", { method: "POST" });
const cookie = login.headers.get("set-cookie")!;
const me = await app.request("/me", { headers: { cookie } });Other testing tips
- Container snapshots:
container.snapshot()/restore()isolate bindings and instances between cases. req.ip: on the dispatch path (no Bun server),req.ipisundefined— the rate-limit middleware falls back to theanonymouskey, so test behavior is deterministic.- WebSocket: the upgrade path needs a real
Bun.serve(requestIP/upgrade); use a real server for ws integration tests.