Testing & Mocking
Test Katman procedures without starting a server.
Three patterns for testing Katman APIs, from lightweight unit tests to full HTTP-level integration tests.
1. callable() — unit testing
Test a single procedure in isolation. The compiled pipeline runs (guards, wraps, validation), but there is no HTTP involved.
import { } from "katman"
import { , , } from "vitest"
const = k.query(
z.object({ : z.number().optional() }),
({ , }) => .db.users.findMany({ : .limit }),
)
const = (, {
: () => ({
: createTestDB(),
}),
})
("listUsers", () => {
("returns users with limit", async () => {
const = await ({ : 2 })
().(2)
})
("rejects invalid input", async () => {
// @ts-expect-error — testing runtime validation
await (({ : "bad" }))..()
})
})callable() is the fastest way to test. Use it when you want to verify a procedure's logic and validation without worrying about HTTP serialization.
Testing guarded procedures
Guards run as part of the compiled pipeline. Provide the context they need:
const = k.mutation({
: [auth],
: z.object({ : z.string() }),
: ({ , }) => .db.users.create(),
})
const = callable(, {
: () => ({
: { : "Bearer test-token" },
: createTestDB(),
}),
})
it("creates a user when authenticated", async () => {
const = await ({ : "Alice" })
expect(.name).toBe("Alice")
})To test that a guard rejects correctly, omit the required context:
const = callable(createUser, {
: () => ({
: {},
: createTestDB(),
}),
})
it("rejects unauthenticated requests", async () => {
await expect(({ : "Alice" })).rejects.toThrow("UNAUTHORIZED")
})2. createServerClient() — integration testing
Test the full router as a single unit. The server client compiles the router once and lets you call any procedure by path — same as the real client, but in-process.
import { } from "katman/client/server"
const = (appRouter, {
: () => ({
: createTestDB(),
: { : "Bearer test-token" },
}),
})
describe("user flow", () => {
it("creates and lists users", async () => {
await .users.create({ : "Alice" })
await .users.create({ : "Bob" })
const = await .users.list({ : 10 })
expect().toHaveLength(2)
expect([0].name).toBe("Alice")
})
})This is the best pattern for integration tests. You exercise the full pipeline — guards, wraps, validation, routing — without starting a server or dealing with ports.
createServerClient() reuses the compiled handler for every call. It is fast enough to use in large test suites without worrying about setup overhead.
3. handler() + fetch() — HTTP-level testing
When you need to test the actual HTTP layer — status codes, headers, content negotiation, CORS — use handler() with the Fetch API:
const = k.handler(appRouter)
describe("HTTP behavior", () => {
it("returns 200 with JSON", async () => {
const = await (
new ("http://localhost/users/list", {
: "POST",
: { "Content-Type": "application/json" },
: .({ : 5 }),
})
)
expect(.status).toBe(200)
expect(.headers.get("content-type")).toContain("application/json")
const = await .json()
expect().toHaveLength(5)
})
it("returns 400 for invalid input", async () => {
const = await (
new ("http://localhost/users/list", {
: "POST",
: { "Content-Type": "application/json" },
: .({ : "not-a-number" }),
})
)
expect(.status).toBe(400)
})
it("negotiates MessagePack", async () => {
const = await (
new ("http://localhost/users/list", {
: "POST",
: {
"Content-Type": "application/json",
"Accept": "application/x-msgpack",
},
: .({ : 5 }),
})
)
expect(.headers.get("content-type")).toContain("application/x-msgpack")
})
})No server process, no port allocation. The handler() function takes a Request and returns a Response — the same Web API you use in production.
Which pattern to use
| Pattern | Best for | Speed | What it tests |
|---|---|---|---|
callable() | Unit tests | Fastest | Procedure logic, validation, guards |
createServerClient() | Integration tests | Fast | Full pipeline, routing, procedure interactions |
handler() + fetch() | HTTP tests | Medium | Status codes, headers, content negotiation |
Start with callable() for most tests. Move to createServerClient() when you need to test across procedures. Use handler() only when the HTTP layer matters.
What's next?
- Server —
callable()andhandler()reference - Client —
createServerClient()reference - Monorepo Setup — share types between packages for testing