Skip to content

Testing

Tests use two helpers from @tetsujs/core/testing, both under bun test. testCtx() builds a context to call a handler directly, as a function. serve() starts the application on a real server for everything around the handler — routing, hooks, validation, the error format — since Bun’s router is only reachable through a socket.

A controller is a function of its dependencies, so the test hands in its own, and testCtx() builds the context from the parts the handler reads:

import { expect, test } from "bun:test";
import { HttpError } from "@tetsujs/core";
import { testCtx } from "@tetsujs/core/testing";
const
const note: {
id: number;
title: string;
}
note
= {
id: number
id
: 7,
title: string
title
: "first" };
const routes = notesController({
notes: NoteStore
notes
: { find: (
owner: string
owner
,
id: number
id
) => (
owner: string
owner
=== "ada" &&
id: number
id
=== 7 ?
const note: {
id: number;
title: string;
}
note
: undefined) },
});
test("returns the note it finds", () => {
const ctx = testCtx({
params: {
readonly id: 7;
}
params
: {
id: 7
id
: 7 },
user: {
readonly id: "ada";
}
user
: {
id: "ada"
id
: "ada" } });
expect(routes.get.
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
readonly params: {
id: number;
};
}) => Note
handler
(ctx)).toEqual(
const note: {
id: number;
title: string;
}
note
);
});
test("refuses a note that is someone else's", () => {
const ctx = testCtx({
params: {
readonly id: 7;
}
params
: {
id: 7
id
: 7 },
user: {
readonly id: "grace";
}
user
: {
id: "grace"
id
: "grace" } });
expect(() => routes.get.
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
readonly params: {
id: number;
};
}) => Note
handler
(ctx)).toThrow(HttpError);
});

The parts are what the handler would have received: params after validation (a number, since the schema converts it) and user from the route’s hook. Leave one out and the call does not compile.

The rest is filled in. ctx.req is a request to http://test/; pass your own as req when the handler reads it. ctx.out collects the status, headers and cookies the handler sets. ctx.server throws when touched: code that needs it, such as ctx.server.requestIP(), is tested through serve(). Only ctx.server.timeout() does nothing, since a unit test has no connection to time out.

A handler that sets a signed cookie, or reads one with signedCookie(), needs the application’s cookie options. Pass them as the second argument:

import { expect, test } from "bun:test";
import { testCtx } from "@tetsujs/core/testing";
const
const cookies: {
readonly secret: "a test secret";
readonly sign: readonly ["session"];
}
cookies
= {
secret: "a test secret"
secret
: "a test secret",
sign: readonly ["session"]
sign
: ["session"] } as const;
test("signing in sets a signed session", () => {
const ctx = testCtx({
params: {}
params
: {} }, {
cookies?: CookieOptions | undefined

The application's cookies option: with it, a cookie the code sets on ctx.out.cookies is signed as it would be, and signedCookie() opens one the request carries.

cookies
});
signIn.
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => void
handler
(ctx);
expect(ctx.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.get("set-cookie")).toMatch(/^session=s1\.[^;]+/);
});

A route without path parameters still gets params: {}. With the options, signedCookie() opens a cookie sent in the cookie header of the req you pass; without them, it throws and says what is missing.

serve() starts the application on a free port and returns a function that sends requests to it. It takes a path and fetch’s options:

import { describe, expect, test } from "bun:test";
import { serve } from "@tetsujs/core/testing";
const
const request: RequestFn
request
= serve(createApp({ routes: notesController() }));
describe("notes", () => {
test("a note is found by its id", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/notes/1");
expect(
const res: Response
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(200);
});
test("a note is created from JSON", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/notes", {
method?: string | undefined

A string to set request's method.

method
: "POST",
headers?: HeadersInit | undefined

A Headers object, an object literal, or an array of two-item arrays to set request's headers.

headers
: { "content-type": "application/json" },
body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
: JSON.stringify({
title: string
title
: "first" }),
});
expect(
const res: Response
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(201);
});
});

The server stops by itself: serve() registers an afterAll where it is called, and Bun runs it when that scope ends: the file, the describe, or the test. Outside bun test, call request.stop() or stopServers().

Bun runs afterAll hooks in the order they were registered, so an afterAll of yours registered after serve() runs once the server has stopped. A teardown that still needs the server uses { stop: false }, shown below, and calls request.stop() last.

Not in beforeAll: Bun runs an afterAll registered inside a hook as soon as the hook returns, so the server would stop before the first test. A request to a server that has stopped throws, saying what stopped it. Await the setup at the top level of the file instead, and call serve() after it:

import { serve } from "@tetsujs/core/testing";
const
const db: Database
db
= await openDatabase();
const
const request: RequestFn
request
= serve(createApp({ routes: notesController({
db: Database
db
}) }));

Or keep the hooks and stop the server yourself: with { stop: false }, it runs until request.stop().

import { afterAll, beforeAll } from "bun:test";
import type { RequestFn } from "@tetsujs/core/testing";
import { serve } from "@tetsujs/core/testing";
let
let request: RequestFn
request
: RequestFn;
beforeAll(async () => {
const
const db: Database
db
= await openDatabase();
let request: RequestFn
request
= serve(createApp({ routes: notesController({
db: Database
db
}) }), {
stop?: boolean | undefined

Whether the server stops by itself, with an afterAll registered where serve() is called. On by default.

Off, the server runs until

RequestFn.stop

or

stopServers

. That is what a server started in beforeAll needs: Bun runs an afterAll registered inside a hook as soon as the hook returns, before any test. So does one a teardown of the file's own still talks to: Bun runs afterAll hooks in the order they were registered, and the one serve() registers comes first.

stop
: false,
});
});
afterAll(() =>
let request: RequestFn
request
.stop());

A server starts in under a millisecond, so building the application per file or per test, on a database in memory, is cheap. See Structuring an application.

A test that signs in and then acts as that user needs the session carried from one request to the next. request.client() returns a client with a cookie jar that does what a browser would:

import { expect, test } from "bun:test";
test("a session lasts until it is ended", async () => {
const
const client: Client
client
=
const request: RequestFn
request
.client();
await
const client: Client
(path: string, init?: ClientInit) => Promise<Response>
client
("/session", {
method?: string | undefined

A string to set request's method.

method
: "POST",
json?: unknown

The body, serialized with JSON.stringify and sent as application/json.

json
: {
email: string
email
: "ada@example.com",
password: string
password
: "correct horse" },
});
expect(
const client: Client
client
.
cookies: CookieJar

The cookies this client holds.

cookies
.get("session")).toBeDefined();
expect((await
const client: Client
(path: string, init?: ClientInit) => Promise<Response>
client
("/me")).
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(200);
await
const client: Client
(path: string, init?: ClientInit) => Promise<Response>
client
("/session", {
method?: string | undefined

A string to set request's method.

method
: "DELETE" });
expect(
const client: Client
client
.
cookies: CookieJar

The cookies this client holds.

cookies
.get("session")).toBeUndefined();
expect((await
const client: Client
(path: string, init?: ClientInit) => Promise<Response>
client
("/me")).
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(401);
});
  • The jar keeps the cookies responses set, sends each where its Path matches, and forgets one that is deleted or expires. Domain is ignored and Secure cookies go over plain http.
  • A signed cookie is held as it arrived, signature included. client.cookies.set() plants a value, to send a forged or stale session.
  • json sends a value as JSON with its content-type; body is sent as given, a malformed body included.
  • Headers are a record. A header set to null is not sent, so { cookie: null } is a request without the session. Headers passed to request.client({ headers }) go with every request.
  • A redirect is returned, not followed, so the test sees the 303 and the cookie it set. redirect: "follow" follows it, and loses the cookies set along the way.

Clients do not share a jar. Make one per test.

A hook runs inside a request, so test it through one: mount it on a small route that returns what the hook added, and serve that.

import { expect, test } from "bun:test";
import { controller, createApp, route } from "@tetsujs/core";
import { serve } from "@tetsujs/core/testing";
const
const sessions: {
find: (token: string) => {
id: string;
} | undefined;
}
sessions
= {
find: (token: string) => {
id: string;
} | undefined
find
: (
token: string
token
: string) => (
token: string
token
=== "ada-token" ? {
id: string
id
: "ada" } : undefined) };
const probe = controller("Probe", () => ({
me: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/me"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/me",
hooks: { beforeParse: [authenticate(
const sessions: {
find: (token: string) => {
id: string;
} | undefined;
}
sessions
)] },
handler: (ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
}) => User

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
}
ctx
) =>
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
}
ctx
.
user: User
user
,
}),
}));
const
const request: RequestFn
request
= serve(createApp({ routes: probe() }));
test("a known token becomes the user", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/me", {
headers?: HeadersInit | undefined

A Headers object, an object literal, or an array of two-item arrays to set request's headers.

headers
: {
authorization: string
authorization
: "Bearer ada-token" } });
expect(await
const res: Response
res
.json()).toEqual({
id: string
id
: "ada" });
});
test("an unknown one is refused", async () => {
expect((await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/me")).
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(401);
});

Logic worth testing on its own, such as parsing a token, belongs in a plain function the hook calls, tested by calling it.

Errors no onError hook mapped, and handlers that break their response contract, go to the application’s reportError, or to console.error without one. A test that provokes one on purpose would print it. captureErrors() collects those lines for each test of its describe and restores the console afterwards. Call it in the describe body, not inside a test:

import { describe, expect, test } from "bun:test";
import { captureErrors } from "@tetsujs/core/testing";
describe("a failing store", () => {
const
const errors: CapturedErrors
errors
= captureErrors();
test("answers 500 and reports what failed", async () => {
expect((await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/notes/1")).
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(500);
expect(
const errors: CapturedErrors
errors
.
lines: string[]
lines
.join("\n")).toContain("[tetsu]");
});
});

An application with its own reportError sends reports there instead; the test passes one that collects them.

Checking responses against the OpenAPI document

Section titled “Checking responses against the OpenAPI document”

assertDescribed() from @tetsujs/openapi/testing checks that a response a test provoked is one the OpenAPI document declares for its operation: the status, and a body that fits it. See @tetsujs/openapi.

serve() listens on IPv4 and IPv6 together, as Bun.serve does by default, so an IPv4 client is reported as ::ffff:127.0.0.1. To test code that compares addresses as a plain IPv4 client, listen on IPv4:

import { serve } from "@tetsujs/core/testing";
const
const request: RequestFn
request
= serve(
const app: App<RoutesOf<readonly []>>
app
, {
hostname?: string | undefined

The address to listen on. Bun's default when absent.

The default listens on both IPv4 and IPv6, and reports a client that connects over IPv4 as ::ffff:127.0.0.1: "127.0.0.1" is how a test becomes an IPv4 client, to check what compares against 127.0.0.1 — a trusted proxy, an allow-list. The request function goes to the address the server listens on.

hostname
: "127.0.0.1" });

Behind a proxy explains the mapped form.

Every request in a test comes from the same address, so a rate limit keyed by the address counts them all in one bucket. Build the application per test, or pass the limiter in as a dependency the test controls.