Keelv0.86.0
Docs / Testing

Test your app by injecting requests — no server, no port, no network — and asserting on the response. testClient() wraps your app's Hono instance (which already does fetch-style injection) with verb helpers and fluent assertions.

The client

Build a client from an Application and fire requests:

import { test } from "node:test";
import { Application, Router, json, testClient } from "@shaferllc/keel/core";

async function makeApp() {
  const app = new Application();
  await app.boot([], { discoverConfig: false, config: { app: {} } });
  app.make(Router).get("/health", () => json({ ok: true }));
  return app;
}

test("health check", async () => {
  const client = testClient(await makeApp());
  const res = await client.get("/health");
  res.assertStatus(200).assertJson({ ok: true });
});

testClient() accepts an Application (built through a fresh kernel), an HttpKernel (use this if you need global middleware registered with kernel.use(...)), or anything with a request() (a built Hono instance).

Requests

Verb helpers cover the common methods; post / put / patch take a body that's JSON-encoded automatically:

await client.get("/users");
await client.get("/users?active=true");
await client.post("/users", { email: "a@b.com", name: "Ada" }); // sends JSON
await client.put("/users/1", { name: "Grace" });
await client.delete("/users/1");

// full control — pass a RequestInit for headers, custom bodies, etc.
await client.request("/users", { method: "POST", headers: { authorization: "Bearer x" }, body });

The response

Every call resolves to a TestResponse. The body is pre-buffered, so reads are synchronous and repeatable (no "body already consumed"):

const res = await client.get("/user");
res.status;            // 200
res.header("content-type");
res.text();            // the raw body
res.json<User>();      // parsed (sync — the body is already read)

Assertions

Assertions are chainable and throw a descriptive error (including the body) on mismatch:

res.assertStatus(201);
res.assertOk();                          // any 2xx
res.assertJson({ id: 1, email });        // deep-equals the JSON body
res.assertText("pong");
res.assertHeader("content-type", "application/json");
res.assertRedirect("/login");            // 3xx (+ optional Location)

// chain them:
(await client.post("/users", body)).assertStatus(201).assertJson({ id: 2, ...body });

Testing with middleware

When your test needs global middleware (sessions, request logging, auth), build the kernel yourself and hand it to testClient:

const app = await makeApp();
const kernel = new HttpKernel(app);
kernel.use(sessionMiddleware());
kernel.use(requestLogger());
const client = testClient(kernel);

Authenticated requests

The client's withX methods return a copy, so a client configured once can be reused without leaking into other tests:

const authed = client.withToken("tok_123"); // Authorization: Bearer tok_123

await authed.get("/me");
await client.get("/me"); // still anonymous
Method Sends
withToken(token) Authorization: Bearer <token>
withBasicAuth(user, pass) Authorization: Basic <base64>
withHeader(name, value) / withHeaders({…}) any header
withCookie(name, value) / withCookies({…}) a Cookie header
acceptJson() Accept: application/json

Forms and uploads

await client.form("/login", { email: "a@b.com", password: "s3cret" }); // url-encoded
await client.multipart("/avatar", { file: new Blob([png]), name: "ada" }); // file upload

More response assertions

res.assertOk(); // 2xx
res.assertCreated(); // 201
res.assertNoContent(); // 204
res.assertUnauthorized(); // 401
res.assertForbidden(); // 403
res.assertNotFound(); // 404
res.assertUnprocessable(); // 422
res.assertServerError(); // 5xx

assertJsonContains is a subset match — the one you usually want. It pins the fields the test is about and ignores the rest, so adding a field to a response doesn't break twenty tests:

res.assertJsonContains({ user: { email: "a@b.com" } });

assertJson still deep-equals the whole body, when that's what you mean.

res.assertSee("Welcome back"); // body contains
res.assertDontSee("Sign up");

res.assertHeader("content-type", "application/json");
res.assertHeaderMissing("x-debug");

res.assertCookie("session"); // was set
res.assertCookie("session", "abc123"); // ...with this value
res.assertCookieMissing("admin");

res.dump(); // print status, headers, body — when you're stuck

Validation

A failed validate() returns a 422 with per-field errors, so a test can assert on the field rather than the message:

const res = await client.post("/users", { email: "nope" });

res.assertValidationErrors("email", "password");
res.assertNoValidationError("name");

Test doubles

Keel's fakes swap out a real backend for a recording one, so a test can assert that something would have happened without it actually happening — no email sent, no card charged, no file uploaded.

Fake Replaces Assertions
fakeMail() the mailer assertSent, assertQueued, …
fakeQueue() the queue assertPushed, assertNothingPushed, …
fakeDisk() a storage disk assertExists, assertContents, …
events().fake() the emitter assertEmitted, assertNotEmitted, …
hash.fake() PBKDF2 — (just makes it fast)
const mailer = fakeMail();
const queue = fakeQueue();

await registerUser({ email: "ada@example.com" });

mailer.assertQueued((m) => m.subject === "Welcome");
queue.assertPushed(SendWelcome);

For anything else, swap() replaces a container binding:

swap(PaymentGateway, () => new FakeGateway());

Spies

The smallest double: a function that records how it was called.

import { spy, spyOn, restoreSpies } from "@shaferllc/keel/core";

const send = spy<[string], void>();
notify(send);

assert.equal(send.callCount, 1);
assert.ok(send.calledWith("hello"));

spyOn replaces a method on an object. It calls through by default — so you're observing, not stubbing — until you tell it otherwise:

const charge = spyOn(gateway, "charge"); // still really charges
charge.returns(receipt); // now it doesn't

restoreSpies(); // put every spied method back

Controlling time

Testing "this token expires in an hour" shouldn't take an hour.

import { freezeTime, timeTravel, restoreTime } from "@shaferllc/keel/core";

freezeTime("2026-07-11T12:00:00Z");

const token = await jwt.sign({ sub: "1" }, { expiresIn: "1h" });
assert.ok(await jwt.verify(token)); // valid now

timeTravel(61 * 60 * 1000); // an hour and a minute later
assert.equal(await jwt.verify(token), null); // expired

restoreTime();

freezeTime() mocks Date and Date.now(). It does not mock timers — a setTimeout still fires on the real clock — and new Date("2020-01-01") still parses normally. Only "what time is it now" is frozen.

Resetting state between tests

Keel's fakes, disks, queues, and cache are process-global, so one test can leak into the next. resetState() puts it all back:

import { resetState } from "@shaferllc/keel/core";

afterEach(() => resetState());

It restores every fake (mail, queue, disk, hash), unfreezes the clock, drops event listeners, empties the cache, and gives you a fresh lock store. It does not touch the database.

For that, truncate():

afterEach(() => truncate("comments", "posts", "users")); // children before parents

It deletes rows rather than rolling back a transaction, so it works on every driver (D1, Postgres, libSQL) instead of only the ones with savepoints.

Database assertions

Assert against the database directly, rather than through an endpoint:

import { assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount } from "@shaferllc/keel/core";

await client.post("/users", { email: "ada@example.com" });

await assertDatabaseHas("users", { email: "ada@example.com" });
await assertDatabaseHas("users", { active: 1 }, 1); // exactly one match
await assertDatabaseMissing("users", { email: "deleted@example.com" });
await assertDatabaseCount("users", 1);
await assertDatabaseEmpty("sessions");

A failure tells you what it looked for and how many rows the table actually holds.

Console tests

Run a command in-process — no subprocess, so it's fast and you can assert on it:

import { runCommand } from "@shaferllc/keel/core";
import { run } from "@shaferllc/keel/cli";
import { createApplication } from "../bootstrap/app.js";

const result = await runCommand(() => run(["node", "keel", "routes"], { createApplication }));

result
  .assertSucceeded() // exit code 0
  .assertOutputContains("GET  /users")
  .assertOutputMatches(/POST\s+\/users/);

You pass the command in, because a command needs an application, and only your app knows how to build one. That's also why run() takes a createApplication factory rather than importing one. Anything that prints and sets an exit code works, so this is equally good for testing a function you wrote yourself.

console.log/warn are captured as stdout and console.error as stderr; a command that throws is recorded as a failure rather than blowing up the test.

assertFailed(), assertExitCode(n), and assertErrorContains(text) cover the rest. result.stdout, result.stderr, and result.exitCode are there if you'd rather assert by hand.

Browser tests

Keel doesn't ship a browser driver — that's Playwright's job, and wrapping it would only put a thinner API in front of a better one.

The test client injects requests without a server, which is what makes it fast; a browser needs a real one. Start the app on a port, point Playwright at it, and tear it down:

import { serve } from "@hono/node-server";
import { chromium } from "playwright";

const server = serve({ fetch: new HttpKernel(app).build().fetch, port: 3001 });
const browser = await chromium.launch();

const page = await browser.newPage();
await page.goto("http://localhost:3001/login");
await page.fill("[name=email]", "ada@example.com");
await page.click("button[type=submit]");
await page.waitForURL("**/dashboard");

await browser.close();
server.close();

Everything else on this page — the fakes, freezeTime, resetState, the database assertions — works the same in a browser test, because it's the same process.

API reference

testClient(target)

testClient(target: Application | HttpKernel | { request(...) }): TestClient

Builds a TestClient. An Application is built through a fresh HttpKernel; pass a kernel to register global middleware first.

TestClient

Method Signature
get / delete (path, init?) => Promise<TestResponse>
post / put / patch (path, body?, init?) => Promise<TestResponse> — body JSON-encoded
request (path, init?) => Promise<TestResponse> — the low-level form

TestResponse

Body pre-buffered; reads are synchronous.

Member Notes
status the response status
header(name) a response header, or null
text() / json<T>() the body (raw / parsed)
assertStatus(n) / assertOk() status is n / any 2xx
assertJson(v) JSON body deep-equals v
assertText(s) / assertHeader(n, v) exact body / header match
assertRedirect(location?) 3xx, optionally to location
raw the underlying Response

All assertions return this (chainable) and throw on mismatch.