Testing¶
Katal provides a lightweight integration-testing kit that starts a real server on an ephemeral port and makes requests through a fluent HTTP client — no mocks required.
Components¶
| Export | Purpose |
|---|---|
AppFactory |
Starts and stops an Application on any free port |
RequestClient |
Fluent HTTP client for test requests |
TestResponse |
Typed wrapper around Response |
createTestToken |
Generate JWT tokens without running Auth |
FakeClock |
Deterministic clock for time-dependent code |
AppFactory¶
AppFactory.create(options?)¶
Creates a brand-new Application inside the factory:
import { AppFactory, Controller } from "katal";
import { describe, it, expect, afterEach } from "bun:test";
describe("Ping API", () => {
const factory = AppFactory.create({ host: "127.0.0.1" });
class PingController extends Controller {
async handle() {
return this.json({ ok: true });
}
}
factory.app.getRouter().get("/ping", PingController);
afterEach(() => factory.stop());
it("returns ok", async () => {
await factory.start();
const res = await factory.getClient().get("/ping");
const body = await res.json<{ ok: boolean }>();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
});
});
AppFactory.from(app, options?)¶
Wrap an existing Application instance (e.g. your configured app from app.ts):
import { AppFactory } from "katal";
import { app } from "../src/app";
const factory = AppFactory.from(app);
await factory.start();
const res = await factory.getClient().get("/healthz");
expect(res.status).toBe(200);
await factory.stop();
Options¶
| Option | Type | Default | Description |
|---|---|---|---|
host |
string |
"127.0.0.1" |
Bind address |
port |
number |
0 (ephemeral) |
Server port |
config |
Partial<AppConfig> |
{} |
Application config overrides |
RequestClient¶
Returned by factory.getClient(). All methods return TestResponse.
const client = factory.getClient();
// HTTP verbs
await client.get("/users");
await client.post("/users", { json: { name: "Alice" } });
await client.put("/users/1", { json: { name: "Alice Smith" } });
await client.patch("/users/1", { json: { name: "Alice" } });
await client.delete("/users/1");
RequestOptions¶
| Option | Type | Description |
|---|---|---|
json |
unknown |
Serialised as JSON body, sets Content-Type: application/json |
body |
BodyInit |
Raw body |
query |
Record<string, string \| number \| boolean \| null> |
Appended as URL search params |
headers |
HeadersInit |
Per-request headers |
authToken |
string |
Sets Authorization: Bearer <token> for this request only |
Auth helpers¶
// Set bearer token for all subsequent requests
client.withBearer(token);
// Clear bearer token
client.clearBearer();
// Per-request bearer
await client.get("/me", { authToken: token });
TestResponse¶
Returned by every client method:
const res = await client.get("/users");
res.status; // number
res.ok; // boolean (200–299)
await res.json<User[]>();
await res.text();
Auth in Tests¶
factory.withAuth(user, options?)¶
Generates a JWT and optionally sets it on the client:
const token = await factory.withAuth({ id: "u-1", role: "admin" });
const res = await factory.getClient().withBearer(token).get("/admin/stats");
createTestToken(user, options?)¶
Standalone helper when you don't need a running server:
import { createTestToken } from "katal";
const token = await createTestToken({ id: "u-1", role: "admin" });
// or with custom options:
const token2 = await createTestToken({ id: "u-1" }, { secret: "my-secret", expiresIn: "30m" });
FakeClock¶
Control Date.now() in code that injects a clock dependency:
import { FakeClock, InMemoryQueue } from "katal";
const clock = new FakeClock();
// Freeze at a specific timestamp
clock.freeze(Date.UTC(2026, 0, 1)); // 2026-01-01T00:00:00Z
// Advance time by N milliseconds
clock.advance(60_000); // now = 2026-01-01T00:01:00Z
// Read current fake timestamp
clock.now(); // number
// Reset to real Date.now()
clock.reset();
Inject into InMemoryQueue to test delayed jobs without sleeping:
const clock = new FakeClock();
clock.freeze();// freeze at current real time
const queue = new InMemoryQueue({ now: () => clock.now() });
await queue.enqueue("report", {}, { delayMs: 30_000 });
// Job not visible yet
expect(await queue.dequeue("report")).toBeNull();
// Advance past the delay
clock.advance(31_000);
// Now the job is available
const job = await queue.dequeue("report");
expect(job).not.toBeNull();
Replacing Services for Tests¶
Use the DI container to swap real services for fakes:
const factory = AppFactory.create();
// Replace mailer with a fake before starting
factory.app.instance("mailer", {
send: async (opts: any) => { /* no-op */ },
});
await factory.start();
See Also¶
Components¶
AppFactory: starts/stops anApplicationon an ephemeral local port.RequestClient: fluent HTTP client for test requests.createTestToken: fake auth helper for generating JWT tokens.FakeClock: deterministic clock helper for time-dependent logic.
Basic Usage¶
import { AppFactory, Controller } from "katal";
const factory = AppFactory.create({ host: "127.0.0.1" });
class PingController extends Controller {
async handle() {
return this.json({ ok: true });
}
}
factory.app.getRouter().get("/ping", PingController);
await factory.start();
const response = await factory.getClient().get("/ping");
const body = await response.json<{ ok: boolean }>();
await factory.stop();
Auth Helper¶
import { createTestToken } from "katal";
const token = await createTestToken({ id: "u-1", role: "admin" });
Use token in requests:
await factory.getClient().get("/me", { authToken: token });
Fake Clock¶
import { FakeClock } from "katal";
const clock = new FakeClock();
clock.freeze(Date.UTC(2026, 0, 1));
clock.advance(60_000);
const now = clock.now();
clock.reset();