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 an Application on 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();