Testing
Zerotal ships a complete testing toolkit built on Bun's test runner. It covers HTTP integration testing against a real running server, transactional database isolation, console-command testing, end-to-end browser tests, in-memory service fakes, plus factories and a data generator for arranging state.
# in your project root
bun test # run all test files
bun test --watch # re-run on change
bun test src/tests/PostTest.ts # a single file
Everything is importable from @zerotal/testing, which is installed with the
default skeleton. To add it to an existing project:
# in your project root
bun add -d @zerotal/testing
Setting up your app for testing
Do this once per project. Every test file — yours and the ones in the package guides — assumes it exists.
A test needs an application that is configured for tests rather than for
development: an in-memory database, a synchronous queue, a log-driver mailer, a
known session secret. Building that inline in every file goes stale the first
time you add a provider, so build it once in tests/helpers.ts and import it
everywhere:
// tests/helpers.ts
import { Application } from "zerotal";
import { DatabaseProvider } from "@zerotal/orm";
import { SessionProvider } from "@zerotal/session";
import { AuthProvider } from "@zerotal/auth";
import { createTestApp, type TestApp } from "@zerotal/testing";
export function createApp(setup?: () => void): Promise<TestApp> {
return createTestApp(
() =>
Application.create({ env: "test" })
.register([DatabaseProvider, SessionProvider, AuthProvider])
.useConfig({
database: { url: ":memory:" },
session: { driver: "cookie", secret: "test-secret", cookie: "session", ttl: 7200 },
queue: { driver: "sync", connection: ":memory:" },
}),
setup,
);
}
The API template scaffolds this file for you. Add a provider to your app and you add it here too — that one edit keeps every test in the suite honest.
From then on a test is two lines of setup:
// tests/http/posts.test.ts
import { test } from "bun:test";
import { createApp } from "../helpers.ts";
test("lists posts", async () => {
const app = await createApp();
const res = await app.get("/posts");
res.assertOk();
await app.close();
});
Note —
createTestApp(bootstrap, setup?)takes a bootstrap callback, not an application. It resets framework state, calls your callback, adopts the result as the current app, runssetup, and starts the server on a random port. Calling it without the callback will not compile.
Add a test script so the suite runs the same way everywhere:
// package.json
{
"scripts": {
"test": "bun test"
}
}
bun zt test runs the same files with APP_ENV=test already set, which is
what you want when a test boots the app through your own bootstrap/app.ts
rather than through createApp().
The toolkit
| Area | What it covers |
|---|---|
| HTTP Tests | Boot the app, send requests, assert on TestResponse; forms, uploads, auth, session. |
| Console Tests | Run CLI commands in-process with Artisan.call() and assert output/exit code. |
| Browser Tests | End-to-end Playwright tests against a live server (Flow/Inertia UIs). |
| Database | Migrations, transactional rollback per test, and assertDatabase*. |
| Mocking | Event/queue/notification/broadcast/storage/HTTP fakes, the test clock, and fake. |
| Flow Tests | Drive a component's own lifecycle in-process — no server, no browser. |
| Admin Tests | Mount a panel resource's List / View / Form pages with resource-aware assertions. |
Which test should I write?
- Asserting on a route's status, body, or side effects? Reach for an HTTP test — it exercises the full request lifecycle through a real server.
- Testing a form or a file upload? Still an HTTP test, but send it the way a
browser does:
postForm()ormultipart(), not a JSONpost(). A JSON body does not travel the same path. - Asserting on rows after an action? Pair the HTTP test with the
assertDatabase*helpers and per-test rollback. - Testing a CLI command? Use Console tests to run it in-process and assert on its output.
- Testing what a Flow component does? Use Flow tests — they drive the component's real lifecycle without a server or a browser, which is much faster than the HTTP or browser route.
- Verifying a rendered UI end-to-end? Use Browser tests.
- Need to confirm an email/job/notification/event happened without it happening? Install a fake and assert on what it captured.
- Behaviour that depends on time passing? Freeze the clock with
Carbon.freeze()instead of waiting.
Generating a test
# in your project root
bun zt make:test PostTest # tests/feature/PostTest.ts
bun zt make:test SlugTest --unit # tests/unit/SlugTest.ts
The feature stub boots the app through tests/helpers.ts, so a generated test
runs against the same configured application as the rest of the suite rather than
building its own.
A first test
createTestApp() boots your application, starts it on a random port, and returns a
TestApp client. Pair it with a factory to arrange data:
// tests/Feature/PostTest.ts
import { describe, it, beforeAll, afterAll } from "bun:test";
import { createTestApp, migrateDatabase, type TestApp, assertDatabaseHas } from "@zerotal/testing";
import { app } from "../bootstrap/app.ts";
import { UserFactory } from "../database/factories/UserFactory.ts";
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp(() => app);
await migrateDatabase(); // build the schema from database/migrations
});
afterAll(() => testApp.close());
describe("POST /posts", () => {
it("creates a post for an authenticated user", async () => {
const user = await UserFactory.create();
const res = await testApp.actingAs(user).post("/posts", { title: "Hello", slug: "hello" });
res.assertCreated();
await assertDatabaseHas("posts", { slug: "hello" });
});
it("rejects a post with no title", async () => {
const user = await UserFactory.create();
const res = await testApp.actingAs(user).asJson().post("/posts", { slug: "hello" });
res.assertUnprocessable().assertInvalid("title");
});
});
See HTTP Tests for the full TestApp and TestResponse API.
Tip —
createTestApp(bootstrap, setup?)takes an optional second callback that runs after the reset but before the server starts — register test-only routes there so they compile into the server.
Arranging data
- Factories — generate model records (
Factory.define,create,for,state,count). - Seeding — seed reusable fixtures shared by dev and tests.
- Database — keep tests isolated with per-test rollback.
Resetting framework state
// tests/Feature/SomeTest.ts
import { resetTestState } from "@zerotal/testing";
afterEach(() => resetTestState());
resetTestState() disposes the current Application and clears the Router, ORM
observers, global scopes, and state-machine callbacks, plus framework event
subscriptions. createTestApp() and testApp.close() call it for you, so suites
using those helpers don't need the explicit afterEach.
Pages render
A test that asserts a status code or an Inertia payload proves the server did its
job. It proves nothing about the component, and a page can throw on its first paint
while every such test passes — the route answers 200, the payload is correct, and
the failure happens in a browser the suite never opened.
An app shipped a blank page to production with 614 passing tests exactly that way:
a layout callback read page.props,
which the callback is not given.
renderPage() builds the component tree and lets whatever it throws escape:
// tests/pages.test.ts
import { renderPage } from "@zerotal/inertia/testing";
import Profile from "../resources/js/pages/profile";
test("profile builds", async () => {
await renderPage(Profile, { title: "Profile" }, { shared: SHARED });
});
It renders through Inertia's own <App>, so usePage(), <Head> and a persistent
layout all behave as they do in the browser — the layout is resolved and rendered
too, which is the case worth catching.
Two things to know:
- Seed the shared props. A component that destructures
authorflashthrows without them. That is a real failure and rarely the one you are testing for, so pass the shape yourInertia.share()actually sends. - It is not a DOM.
useEffectdoes not run and nothing clicks; this isrenderToString. For behaviour after paint, use the browser harness.
The React scaffold ships one of these covering every page it generates. Add a line when you add a page — the cost is one line and the bug it catches is a white screen your users find first.
bun test vs bun zt test
Both run the same files. bun zt test is a wrapper that sets up four things Bun's
runner does not, and each of them has cost somebody a day:
bun test | bun zt test | |
|---|---|---|
| Per-test timeout | Bun's default, 5000ms | 30000ms (--timeout, override with --timeout=) |
| Runtime check | none | refuses a Bun below the project's engines.bun |
| DB wiring | none | preloads @zerotal/testing/preload and passes ZT_DB_URL |
| Drivers | inherits .env | resets mail, queue, session and cache to their in-process defaults |
The drivers
Bun loads .env into every process it starts, so a test run inherits your local
setup — including the keys that decide whether a code path talks to something
real. A developer with MAIL_DRIVER=smtp pointed at a local Postfix gets a suite
where every path that sends mail opens an SMTP connection: one test that confirms
a payment and issues an invitation goes from milliseconds to a five-second
timeout, and fails only when run alongside its siblings. A teammate with no mail
server configured never sees it.
So bun zt test resets these for the child process:
| Key | Test value |
|---|---|
MAIL_DRIVER | log |
QUEUE_DRIVER | sync |
SESSION_DRIVER | cookie |
CACHE_DRIVER | memory |
Each is already the app config's default; only .env was overriding it. Two
things still win, and the command prints a line saying what it changed:
- A value set in the shell —
MAIL_DRIVER=smtp bun zt testis someone testing that path deliberately, and is left alone. .env.test— read bybun zt testand merged last, for anything a test run should configure for itself. (Bun only loads that file whenNODE_ENV=test, sobun teston its own does not see it.)
--keep-env skips the reset entirely and inherits .env as-is.
The timeout
Bun's default per-test timeout is 5000ms, and a suite that boots an app per file
exceeds it on a loaded machine — CI, or a laptop that has just run tsc. The
failures look like flakes, which is the expensive part: a flake gets re-run, and a
re-run passes.
bun zt test sets --timeout=30000. If you run bun test directly, pass it
yourself, because the two documented-looking alternatives do not work:
[test] timeoutinbunfig.toml— ignored.setDefaultTimeout()in a preload — applies to the first test file only. Bun re-imports the preload per file, but the setting does not survive.
The command-line flag is the only mechanism that covers hooks as well as tests,
which matters because it is usually a beforeAll that boots the app.
The runtime
engines.bun in your package.json is a floor, and until you enforce it, it is a
comment. The shell's bun and the project's can differ, and the difference between
two Bun releases is real and narrow: Intl formatting, the SQLite bindings and
node: compatibility all move. So a handful of currency or date assertions go red
and the rest pass, and you go looking for a bug in the code they touch, because
nothing in the failure says "wrong binary".
bun zt test refuses to run below the declared floor. Direct bun test runs get the
same check as a warning if you load the preload:
# bunfig.toml
[test]
preload = ["@zerotal/testing/preload"]
timeout = 30000 # note: currently ignored by Bun — pass --timeout on the command line
Set ZT_ALLOW_RUNTIME_MISMATCH=1 to downgrade the refusal to a warning while you
are mid-upgrade.
@zerotal/core/runtime
The checks behind the two paragraphs above, exported so a script or a test of your
own can make the same assertion. zt runs both at the top of every command; the test
preload runs the floor check as a warning.
| Export | Signature | What it answers |
|---|---|---|
declaredBunFloor | declaredBunFloor(cwd): { range, manifest } | null | The nearest engines.bun up the tree from cwd. |
runtimeBelowFloor | runtimeBelowFloor(cwd?): RuntimeFloor | null | Is this process below that floor? null when it is met or none is declared. |
runtimeBelowFloorMessage | runtimeBelowFloorMessage(floor): string | The explanation to print — both versions, the manifest, and the way out. |
installedBunVersion | installedBunVersion(cwd): { version, manifest } | null | The Bun in node_modules, if the project installs one as a package. |
declaresBunDependency | declaresBunDependency(cwd): boolean | Whether the project asked for that package, or acquired it as a transitive peer. |
runtimeMismatch | runtimeMismatch(cwd?): RuntimeMismatch | null | Does the running Bun differ from the installed one? Compared exactly — a patch is a binary. |
runtimeMismatchMessage | runtimeMismatchMessage(mismatch): string | The explanation for that one. |
runtimeMismatchAllowed | runtimeMismatchAllowed(): boolean | Whether ZT_ALLOW_RUNTIME_MISMATCH is set. |
bunBinary | bunBinary(): string | The binary to spawn a child with — process.execPath, never the name PATH resolves. |
RUNTIME_MISMATCH_ESCAPE | "ZT_ALLOW_RUNTIME_MISMATCH" | The env var name, so a script can set it without hardcoding the string. |
RuntimeFloor is { running, required, manifest }; RuntimeMismatch is
{ running, installed, manifest }. Both name the file the second version came from,
because "which one is wrong" is the question you actually have.
Configuration is per-process, and bun test is one process
Zerotal resolves configuration once, at boot. bun test runs every file in the same
process, so whichever file boots the app first fixes the configuration for all of
them.
A test that sets an environment variable in its own beforeAll and then asserts on
the resulting behaviour passes alone and fails in the suite — or worse, passes in the
suite for a reason unrelated to what it claims to test:
// Passes alone. In a suite, the app may already be booted with CSRF on, and the
// three "rejects without a token" assertions below pass on a 419 they would have
// got anyway — never reaching the guard they name.
beforeAll(() => {
Bun.env.CSRF_DISABLED = "1";
});
Assert on the relationship rather than on a literal — the published origin equals the configured one, whatever it is — or boot a dedicated app for the case:
const app = await createTestApp({ config: { app: { url: "https://example.test" } } });
expect(page.canonical).toBe(config("app.url"));
Running the suite from a script
A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
bun test 2>&1 | tail -3 # the status is tail's. Always 0, however the suite went.
The suite is verbose enough that piping it somewhere is the natural thing to write, which
is what makes this worth saying: a deploy script written that way prints 1 fail and
carries straight on to upload and restart. Nothing is wrong with the output — it is the
$? behind it that belongs to the last command in the pipe.
Either turn the pipe honest, or do not pipe:
set -o pipefail # bash/zsh: the pipeline fails if any stage does
bun test 2>&1 | tail -3
# or keep the status and the output separately
bun test > test.log 2>&1 || { tail -20 test.log; exit 1; }
set -e alone does not cover it — the pipeline succeeded, as far as the shell is
concerned.
References
The most-used members exported from @zerotal/testing. Each area's page documents
its full surface.
| Member | Signature | Description |
|---|---|---|
createTestApp | (bootstrap: () => Application | Promise<Application>, setup?: () => void) => Promise<TestApp> | Boot the app on a random port and return a client. |
TestApp#actingAs | (user: { id: number | string }) => this | Authenticate subsequent requests as user. |
TestApp#post | (url: string, body: unknown, headers?) => Promise<TestResponse> | Send a JSON POST request. |
TestApp#close | () => Promise<void> | Stop the server and reset state; call in afterAll. |
assertDatabaseHas | (table: string, where: Record<string, unknown>) => Promise<void> | Assert a matching row exists. |
assertDatabaseMissing | (table: string, where: Record<string, unknown>) => Promise<void> | Assert no matching row exists. |
assertDatabaseCount | (table: string, expected: number, where?) => Promise<void> | Assert the row count for a table. |
migrateDatabase | (options?: MigrateDatabaseOptions) => Promise<string[]> | Build the schema from the project's migrations. |
refreshDatabase | (options?: RefreshDatabaseOptions) => void | Wrap each test in a transaction and roll back. |
resetTestState | () => void | Dispose the app and clear framework/ORM state. |
Factory.define | (Model, (f) => FactoryPayload<T>) => Factory<T> | Define a reusable model factory. |
fake | typeof fake | South-African-flavoured random data generator. |
fakeFile | typeof fakeFile | Real PNG/JPEG/GIF/PDF files for upload tests. |
Types
TestResponseContext is what an assertion receives, SessionDecoder reads the session out of a
response so a test can assert on it, and FakeFile / TestFileInput / TestFormValue are the
shapes a multipart submission takes in a test.
Next steps
- HTTP Tests — the full
TestAppandTestResponseAPI. - Database Tests — migrations and per-test rollback.
- Console Tests — run CLI commands in-process.
- Mocking — fakes for events, queue, notifications, broadcasts, storage, and outbound HTTP, plus the test clock.
- Flow Tests — drive a component in-process, with no server.
- Admin Tests — mount a panel resource's pages.