Testing
We don't mock the server. That's a deliberate choice — if you're testing an API, you should be making real HTTP requests against a real running server. Now that Bun includes fetch out of the box, this is trivially easy.
Test Structure
Each test file boots and stops the full server in beforeAll/afterAll. Tests use dynamic port binding (WEB_SERVER_PORT=0) so each file gets a random available port — no conflicts when running multiple test files. Use the useTestServer() helper to register these hooks in one line:
import { useTestServer } from "keryx/testing";
const getUrl = useTestServer();
test("status endpoint returns server info", async () => {
const res = await fetch(getUrl() + "/api/status");
const body = (await res.json()) as ActionResponse<Status>;
expect(res.status).toBe(200);
expect(body.name).toBe("server");
expect(body.uptime).toBeGreaterThan(0);
});Yes, this means each test file starts the entire server — database connections, Redis, the works. It's slower than unit testing with mocks, but you're testing what actually happens when a client hits your API. I'll take that tradeoff every time.
Test Helpers
The keryx/testing subpath exports helpers that cover the common test lifecycle:
useTestServer(opts?)— RegistersbeforeAll/afterAllhooks that callapi.start()andapi.stop(). Returns a getter that resolves the server URL (the URL isn't known until the port is bound, so it's a function, not a string). Options:clearDatabase(defaultfalse) — truncate all tables inbeforeAll(requires thedbinitializer).clearRedis(defaultfalse) —FLUSHDBon the current Redis database inbeforeAll(requires theredisinitializer). Opt in for tests that exercise pub/sub so messages from prior tests don't leak in.
tsconst getUrl = useTestServer({ clearDatabase: true, clearRedis: true });Need additional setup like inserting a seed user? Bun supports multiple
beforeAllblocks per file — add another one afteruseTestServer()that runs onceapi.start()has completed.serverUrl()— Returns the actual URL the web server bound to (with resolved port). Call afterapi.start().useTestServer()wraps this internally; reach for it directly only when you need manual lifecycle control.HOOK_TIMEOUT— A generous timeout (15s) forbeforeAll/afterAllhooks, since they connect to Redis, Postgres, run migrations, etc. Pass as the second argument tobeforeAll/afterAllwhen writing your own lifecycle hooks.buildWebSocket(opts?),createUser,createSession,subscribeToChannel,waitForBroadcastMessages— Higher-level helpers for WebSocket tests. See Testing WebSocket Connections below.waitFor(condition, { interval, timeout })— Polls a condition function until it returnstrue, or throws after a timeout. Use this instead of fixedBun.sleep()calls when waiting for async side effects like background tasks:
await waitFor(
async () => {
const result = await db.query(
"SELECT count(*) FROM jobs WHERE status = 'done'",
);
return result.count > 0;
},
{ interval: 100, timeout: 5000 },
);Running Tests
# all backend tests
cd example/backend && bun test
# a single file
cd example/backend && bun test __tests__/actions/user.test.ts
# full CI — lint + test both frontend and backend
bun run ciTests run non-concurrently to avoid port conflicts. Each test file gets the server to itself.
Making Requests
Just use fetch. Here's a typical test for creating a user:
test("create a user", async () => {
const res = await fetch(getUrl() + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "password123",
}),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.user.name).toBe("Test User");
});Nothing special — it's the same fetch you'd use in a browser or a Bun script.
Database Setup
Tests typically clear the database before running to ensure a clean slate — pass clearDatabase: true to useTestServer():
const getUrl = useTestServer({ clearDatabase: true });clearDatabase() truncates all tables with RESTART IDENTITY CASCADE. It refuses to run when NODE_ENV=production, so you can't accidentally nuke your production data.
You'll need a separate test database:
createdb keryx-testSet DATABASE_URL_TEST in your environment (or backend/.env) to point at it.
Testing Authenticated Endpoints
Most endpoints require a session. The pattern is: create a user, log in, then pass the session cookie on subsequent requests:
import { config } from "keryx";
test("authenticated request", async () => {
// Create a user
await fetch(getUrl() + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
password: "password123",
}),
});
// Log in
const sessionRes = await fetch(getUrl() + "/api/session", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "test@example.com",
password: "password123",
}),
});
const sessionBody =
(await sessionRes.json()) as ActionResponse<SessionCreate>;
const sessionId = sessionBody.session.id;
// Make an authenticated request
const res = await fetch(getUrl() + "/api/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: `${config.session.cookieName}=${sessionId}`,
},
body: JSON.stringify({ name: "New Name" }),
});
expect(res.status).toBe(200);
});The session ID comes from the login response, and you pass it as a Cookie header. This is the same cookie the browser would send automatically.
Testing WebSocket Connections
WebSocket tests connect to the same server and send JSON messages. The lowest-level pattern looks like:
test("websocket action", async () => {
const wsUrl = getUrl().replace("http", "ws");
const ws = new WebSocket(wsUrl);
await new Promise<void>((resolve) => {
ws.onopen = () => resolve();
});
const responsePromise = new Promise<any>((resolve) => {
ws.onmessage = (event) => resolve(JSON.parse(event.data));
});
ws.send(
JSON.stringify({
messageType: "action",
action: "status",
messageId: "test-1",
}),
);
const response = await responsePromise;
expect(response.messageId).toBe("test-1");
expect(response.name).toBe("server");
ws.close();
});For common flows — opening a socket, creating a user, logging in, subscribing to a channel, and collecting broadcasts — reach for the helpers exported from keryx/testing:
import {
buildWebSocket,
createSession,
createUser,
subscribeToChannel,
waitForBroadcastMessages,
} from "keryx/testing";
test("broadcast reaches subscribers", async () => {
const { socket, messages } = await buildWebSocket();
await createUser(socket, messages, "Marco", "marco@example.com", "abc12345");
await createSession(socket, messages, "marco@example.com", "abc12345");
await subscribeToChannel(socket, messages, "messages");
// ...trigger a broadcast...
const broadcasts = await waitForBroadcastMessages(messages, 1);
expect(broadcasts[0].message.body).toBe("hello");
socket.close();
});buildWebSocket() resolves once the socket's open event fires and exposes a live messages array that every subsequent handler populates. The action helpers assume the socket is fresh (they read from fixed indices in messages); subscribeToChannel matches the subscribe confirmation by content so it's resilient to presence broadcasts arriving out of order.
Testing Background Tasks
Use waitFor() to poll for side effects from background tasks:
test("cleanup task removes old messages", async () => {
// Insert test data...
// Enqueue the task
await api.actions.enqueue("messages:cleanup", { age: 1000 });
// Wait for the side effect
await waitFor(
async () => {
const remaining = await api.db.db.select().from(messages);
return remaining.length === 0;
},
{ interval: 100, timeout: 5000 },
);
});Gotcha: Stale Processes
If you're changing code but your tests are still seeing old behavior… you probably have a stale server process running from a previous dev session. This has bitten me more than once:
ps aux | grep "bun keryx" | grep -v grep
kill -9 <PIDs>Check for old processes whenever code changes aren't being reflected. It'll save you hours of debugging.