Skip to main content
zerotal

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, runs setup, 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

AreaWhat it covers
HTTP TestsBoot the app, send requests, assert on TestResponse; forms, uploads, auth, session.
Console TestsRun CLI commands in-process with Artisan.call() and assert output/exit code.
Browser TestsEnd-to-end Playwright tests against a live server (Flow/Inertia UIs).
DatabaseMigrations, transactional rollback per test, and assertDatabase*.
MockingEvent/queue/notification/broadcast/storage/HTTP fakes, the test clock, and fake.
Flow TestsDrive a component's own lifecycle in-process — no server, no browser.
Admin TestsMount 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() or multipart(), not a JSON post(). 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 auth or flash throws without them. That is a real failure and rarely the one you are testing for, so pass the shape your Inertia.share() actually sends.
  • It is not a DOM. useEffect does not run and nothing clicks; this is renderToString. 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 testbun zt test
Per-test timeoutBun's default, 5000ms30000ms (--timeout, override with --timeout=)
Runtime checknonerefuses a Bun below the project's engines.bun
DB wiringnonepreloads @zerotal/testing/preload and passes ZT_DB_URL
Driversinherits .envresets 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:

KeyTest value
MAIL_DRIVERlog
QUEUE_DRIVERsync
SESSION_DRIVERcookie
CACHE_DRIVERmemory

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 test is someone testing that path deliberately, and is left alone.
  • .env.test — read by bun zt test and merged last, for anything a test run should configure for itself. (Bun only loads that file when NODE_ENV=test, so bun test on 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] timeout in bunfig.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.

ExportSignatureWhat it answers
declaredBunFloordeclaredBunFloor(cwd): { range, manifest } | nullThe nearest engines.bun up the tree from cwd.
runtimeBelowFloorruntimeBelowFloor(cwd?): RuntimeFloor | nullIs this process below that floor? null when it is met or none is declared.
runtimeBelowFloorMessageruntimeBelowFloorMessage(floor): stringThe explanation to print — both versions, the manifest, and the way out.
installedBunVersioninstalledBunVersion(cwd): { version, manifest } | nullThe Bun in node_modules, if the project installs one as a package.
declaresBunDependencydeclaresBunDependency(cwd): booleanWhether the project asked for that package, or acquired it as a transitive peer.
runtimeMismatchruntimeMismatch(cwd?): RuntimeMismatch | nullDoes the running Bun differ from the installed one? Compared exactly — a patch is a binary.
runtimeMismatchMessageruntimeMismatchMessage(mismatch): stringThe explanation for that one.
runtimeMismatchAllowedruntimeMismatchAllowed(): booleanWhether ZT_ALLOW_RUNTIME_MISMATCH is set.
bunBinarybunBinary(): stringThe 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.

MemberSignatureDescription
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 }) => thisAuthenticate 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) => voidWrap each test in a transaction and roll back.
resetTestState() => voidDispose the app and clear framework/ORM state.
Factory.define(Model, (f) => FactoryPayload<T>) => Factory<T>Define a reusable model factory.
faketypeof fakeSouth-African-flavoured random data generator.
fakeFiletypeof fakeFileReal 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 TestApp and TestResponse API.
  • 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.