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
constnote: {
id:number;
title:string;
}
note= {
id: number
id: 7,
title: string
title: "first" };
constroutes=notesController({
notes: NoteStore
notes: { find: (
owner: string
owner,
id: number
id) => (
owner: string
owner==="ada"&&
id: number
id===7?
constnote: {
id:number;
title:string;
}
note:undefined) },
});
test("returns the note it finds", () => {
constctx=testCtx({
params: {
readonly id: 7;
}
params: {
id: 7
id: 7 },
user: {
readonly id: "ada";
}
user: {
id: "ada"
id: "ada" } });
expect(routes.get.
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
user:User;
readonlyparams: {
id:number;
};
}) => Note
handler(ctx)).toEqual(
constnote: {
id:number;
title:string;
}
note);
});
test("refuses a note that is someone else's", () => {
constctx=testCtx({
params: {
readonly id: 7;
}
params: {
id: 7
id: 7 },
user: {
readonly id: "grace";
}
user: {
id: "grace"
id: "grace" } });
expect(() => routes.get.
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
user:User;
readonlyparams: {
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
constcookies: {
readonlysecret:"a test secret";
readonlysign:readonly ["session"];
}
cookies= {
secret: "a test secret"
secret: "a test secret",
sign: readonly ["session"]
sign: ["session"] } asconst;
test("signing in sets a signed session", () => {
constctx=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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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.
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.
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:
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 () => {
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.
: 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(
constsessions: {
find: (token:string) => {
id:string;
} |undefined;
}
sessions)] },
handler: (ctx: {
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt: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
constrequest:RequestFn
request=serve(createApp({ routes: probe() }));
test("a known token becomes the user", async () => {
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:
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
constrequest:RequestFn
request=serve(
constapp: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.
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.