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();
});

NotecreateTestApp(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.

TipcreateTestApp(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.

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.

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.