Testing
- Category: Engine
A Point0 app is a normal fullstack app, so you test it however you already test fullstack apps — there is no Point0-specific test framework to learn. Two facts matter, and the rest of this page is just one way to organize around them:
- It runs in a real browser. The built (or dev) app serves real HTML over HTTP, so you can drive it end-to-end with Playwright (or any browser driver) exactly like any other web app.
- You can also call a point's server loader directly, in-process, with full
types.
point.fetchServer(input)runs the point's server loader and hands back its typed output — no socket, no HTTP framing.point.fetchServerDetaileddoes the same but returns a discriminated{ output } | { error } | { redirect }so you can assert on failures. Both run insideengine.withFetch(...), against theengineyou already export.
That second point is the one worth remembering — it lets you unit-test backend logic against your real engine without booting a server:
// run a query's server loader in-process, no server, typed output
import { engine } from '@/engine'
import { getMeQuery } from '@/modules/auth/api'
import { expect, test } from 'bun:test'
test('anonymous user gets null "me"', async () => {
const result = await engine.withFetch(async () => {
return await getMeQuery.fetchServer(undefined, { headers: {} })
})
expect(result.me).toBeNull() // => the loader's typed output, no HTTP framing
})That is the whole framework surface for testing: engine.withFetch,
engine.fetch, point.fetchServer / fetchServerDetailed. The runner above is
Bun's bun:test — not Jest or Vitest — but nothing here is tied to it.
The layout below is a pattern to copy, not generated output.
This page is one example, not the way
Everything below — the four-kind unit / dom / int / e2e taxonomy, the
file suffixes, the src/test/setup/* preloads, the fixtures and cleanDb
helpers — is how start0, our boilerplate, happens to
lay its tests out. None of it is Point0 API. Copy what fits, ignore the rest,
or structure your tests completely differently. Point0 only gives you the
runtime hooks; you wire the test harness yourself.
The four kinds
Pick the lightest kind that can catch the bug.
| Kind | Suffix | Spins up | For |
|---|---|---|---|
| unit | *.unit.test.ts | plain Bun | a util, a pure function, a schema |
| dom | *.dom.test.tsx | happy-dom + Testing Library, no browser | React components and hooks |
| int | *.int.test.ts | engine + Prisma in-process, no server | endpoint / backend behavior |
| e2e | *.e2e.test.ts | real server + real browser (Playwright) | full user flows |
Each kind loads its own setup via Bun's --preload, and the file suffix is how
Bun's filter picks the suite. start0 runs them all under NODE_ENV=test against
a separate test database:
bun run test # unit → dom → int → e2e (build) in order
bun run test:unit
bun run test:dom
bun run test:int
bun run test:e2e:dev # see e2e section for the three server modesEach script is a Bun test run filtered by suffix, with the matching setup preloaded:
// package.json — start0
"test:int": "cross-env NODE_ENV=test bun test --pass-with-no-tests --preload ./src/test/setup/int.ts .int.test.",
"test:dom": "cross-env NODE_ENV=test bun test --pass-with-no-tests --preload ./src/test/setup/dom.ts .dom.test.",
"test": "bun test:unit && bun test:dom && bun test:int && bun test:e2e:build",The trailing .int.test. is Bun's filename filter; --preload runs the setup
before any test file. start0 also wires a bunfig.toml [test] preload
dispatcher that reads the test file's name and loads the matching setup, so a
bare bun test path/to/x.dom.test.tsx gets its setup without going through a
script.
int — run one point's loader (engine.withFetch + fetchServer)
This is the default for backend tests: execute one point's server loader, get its typed output, no HTTP request, no socket. Two pieces:
point.fetchServer(input, fetchOptions?)runs the point's server loader and returns the loader's output. It throws on a loader error or a redirect.engine.withFetch(callback)is the wrapper you run it inside. It swaps the fetch that points use under the hood so the loader runs against this engine in-process instead of hitting the network. CallfetchServerinside the callback.
import { engine } from '@/engine'
import { ideaListQuery } from '@/features/idea/api'
import { expect, test } from 'bun:test'
test('returns the newest ideas first', async () => {
const result = await engine.withFetch(async () => {
return await ideaListQuery.fetchServer({}) // input is typed from the query
})
expect(result.items.map((i) => i.sn)).toEqual([third.sn, second.sn, first.sn])
})Simulate auth: pass headers
The second argument is FetchOptions — a standard RequestInit (so headers,
method, body, …) plus a transform?: boolean flag. Pass headers to run
the loader as a specific user, or {} for anonymous:
await getMeQuery.fetchServer(undefined, { headers: {} }) // anonymous
await getMeQuery.fetchServer(undefined, { headers: user.headers }) // signed-inuser.headers here comes from a test fixture that creates a real user — that
fixture is a start0 helper, shown in fixtures
further down, not a Point0 API.
Assert on errors: fetchServerDetailed
fetchServer throws on error, which is what you want when you just need the
value. When you want to assert on the error instead, use
fetchServerDetailed — same execution, but it returns a discriminated result
({ output } on success, { error } on failure, { redirect } on redirect)
rather than throwing:
test('rejects anonymous users', async () => {
const result = await engine.withFetch(async () => {
return await ideaCreateMutation.fetchServerDetailed(
{ title: 'Nope', content: 'Nope' },
{ headers: {} },
)
})
expect(result.error?.code).toBe('UNAUTHORIZED') // your AppError's code
expect(await prisma.idea.count()).toBe(0) // and nothing was written
})Int tests run in the same process as the engine, so you read the database
directly through Prisma to verify side effects (prisma.idea.count(),
findUniqueOrThrow) — there is no server to ask. Ownership and authorization
gates are the highest-value cases:
test('forbids non-authors', async () => {
const author = await createTestUser()
const intruder = await createTestUser({ index: 1 })
const created = await seedIdea({ authorId: author.id, title: 'Untouchable' })
const result = await engine.withFetch(async () => {
return await ideaUpdateMutation.fetchServerDetailed(
{ sn: created.sn, title: 'Hijacked', content: 'Hijacked' },
{ headers: intruder.headers },
)
})
expect(result.error?.code).toBe('FORBIDDEN')
const inDb = await prisma.idea.findUniqueOrThrow({
where: { sn: created.sn },
})
expect(inDb.title).toBe('Untouchable') // unchanged
})What to test: write int tests for states that do something that can break — authorization gates, ownership checks, data rules. Skip plumbing like pagination; tests written just to have tests only grow the codebase.
The int setup: engine.prepare()
Before any in-process fetch, the engine must be prepared once. start0 does it in the int preload:
// src/test/setup/int.ts
import { engine } from '@/engine'
// int tests have only the server, so point pages at the server origin too
process.env.CLIENT_URL = process.env.SERVER_URL
await import('@/preload') // load env + register points before the engine reads them
// Prepare the engine once for the whole run. Prisma connects lazily on the first
// query, so this touches no database and opens no port.
await engine.prepare()
export {}engine.prepare() makes the engine read its registered points and ready its
server side — the same step engine.serve() does for you automatically; here
you call it by hand because there is no serve(). Skip it and fetchServer /
engine.fetch throw
Engine server is not prepared. Please call await engine.prepare() first. It
does not open a port or touch the DB. (Compare engine-runtime
for serve / prepare / preload.)
int — drive a raw request (engine.fetch)
When the unit under test is the request pipeline — middleware, an action
endpoint, a status code, a 404 — use engine.fetch(request, init?). It runs a
real Request through the engine in-process and returns a Response, no socket
opened:
const response = await engine.fetch('http://localhost:3001/zxc/123', {
method: 'POST',
})
expect(response.status).toBe(201)
expect(await response.json()).toEqual({ id: '123' })The first argument is a string | URL | Request; the second is a RequestInit.
Use a full absolute URL — the router needs an origin to parse path and
method. The port value is arbitrary in-process; only the path and method matter.
A not-found request flows through the same way:
const response = await engine.fetch('http://localhost:3001/zxc/123', {
method: 'PUT',
headers: { Accept: 'application/json' },
})
expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({ message: 'Not Found' })For assertions on the transport layer beyond the body — which point matched,
headers, the raw error — engine.fetchDetailed(...) returns the full result and
engine.fetch is just (...).response.
fetchServervsfetch:engine.fetchexercises the whole HTTP pipeline (routing, middleware, serialization).point.fetchServerruns one loader directly and hands back its typed value — fewer moving parts, better types. Reach forfetchServerto test a point's logic,engine.fetchto test the request path around it.
When the engine is created with a required ctx, engine.fetch's overload makes
requiredCtx a mandatory option
(engine.fetch(url, { requiredCtx, ...init })). Most apps (start0 included)
require none, so you pass just the URL and an optional RequestInit.
The transform flag
FetchOptions also carries transform?: boolean, default true. When on, the
fetch round-trips input and output through the point's
transformer — so rich types (dates, and anything else the
transformer handles) survive the serialize / deserialize across the request,
in-process tests included. Set transform: false to send and receive plain JSON
instead. Leave it on unless a test asserts specifically on the raw wire shape.
fixtures: seed and authenticate
int tests need real rows and real sessions. These are start0 helpers — patterns to replicate, not framework API:
// create a user straight in the DB; get back the row + ready auth headers + login()
const user = await createTestUser()
const other = await createTestUser({ index: 1 }) // a second, distinct user
const { headers } = await user.login() // refresh headers via a real sign-increateTestUser writes through better-auth's test helpers, rereads the row
through Prisma for full types, and returns { ...user, headers, login }.
seedIdea inserts domain rows. Between tests, truncate everything:
import { cleanDb } from '@/modules/seed/utils'
beforeEach(async () => {
await cleanDb() // TRUNCATE every public table except _prisma_migrations
})cleanDb is guarded by throwIfNotSafeToDestroyDb: it refuses unless
NODE_ENV !== 'production', HOST_ENV === 'local', and DATABASE_URL contains
localhost — so a test run can never wipe a staging database. Migrate the test
DB before running int or e2e:
cross-env NODE_ENV=test prisma migrate dev # start0: bun run prisma:migrate:testdom — components without a browser
dom tests render React with happy-dom and Testing Library — fast, no browser. The setup has one sharp edge worth copying verbatim:
// src/test/setup/dom.ts
import { GlobalRegistrator } from '@happy-dom/global-registrator'
import { afterEach } from 'bun:test'
if (!GlobalRegistrator.isRegistered) {
GlobalRegistrator.register()
}
// NO static import of @testing-library/*. Those packages are CommonJS — they
// capture document.body at module-load time — and static imports get hoisted
// ahead of any function body, so we keep them behind a dynamic import() that
// fires only AFTER GlobalRegistrator.register() has run.
const rtl = await import('@testing-library/react')
afterEach(() => rtl.cleanup())With that setup preloaded, a dom test renders React and asserts on the DOM with Testing Library — no point manager, no engine, no browser:
// stepper.dom.test.tsx
import { Stepper } from '@/ui/stepper'
import { act, render, screen } from '@testing-library/react'
import { expect, test } from 'bun:test'
test('increments on click', () => {
render(<Stepper start={1} />)
expect(screen.getByTestId('value').textContent).toBe('1')
act(() => screen.getByRole('button', { name: 'add' }).click())
expect(screen.getByTestId('value').textContent).toBe('2')
})This tier is for plain React — components and hooks. A point's component
needs the point manager around it to render, so exercise that through an int
or e2e test (which boot the engine or the real app), not here.
e2e — the real app in a browser (Playwright)
e2e drives the running app through a real browser. Playwright is used as a
library (playwright + playwright/test's expect); Bun is still the runner.
A new browser context per test keeps sessions isolated:
import { routes } from '@/generated/point0/routes'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
test,
} from 'bun:test'
import { chromium, type Browser, type Page } from 'playwright'
import { expect } from 'playwright/test'
let browser: Browser
let page: Page
beforeAll(async () => {
browser = await chromium.launch()
})
// browser.newPage() opens its own context, so every test starts clean
beforeEach(async () => {
page = await browser.newPage()
})
afterEach(async () => {
await page.close()
})
afterAll(async () => {
void browser.close()
})
describe('smoke e2e', () => {
test('home page available', async () => {
await page.goto(routes.home.abs()) // navigate via generated routes, not hand-written URLs
await expect(page.locator('body')).toContainText('Welcome')
})
})Navigate via generated routes, never literal URLs.
routes.home.abs() is the absolute URL (origin from the engine's client URL);
routes.signOut() is the path only — both come from
@/generated/point0/routes, so bun run generate (or setup) must have run
first or the import is empty.
The app runs in a separate process, so the browser is the only way in — you cannot reach its DB or engine from the test. Set up state by driving the UI:
// a shared flow helper — sign a fresh user up through the real form
export const signUpViaUi = async (
page: Page,
{ prefix }: { prefix: string },
) => {
const email = `${prefix}-${Date.now()}@example.com` // unique per call
await page.goto(routes.signUp.abs())
const form = page.locator('#sign-up-email-form')
await form.locator('[name="email"]').fill(email)
await form.locator('[name="password"]').fill('12345678')
// ...
await form.locator('button[type="submit"]').click()
await expect(page.locator(`a[href="${routes.signOut()}"]`)).toBeVisible() // wait for the session
return { email }
}For a second, unauthenticated visitor inside one test, open another page from
the same browser in a try/finally:
const anonymous = await browser.newPage()
try {
await anonymous.goto(routes.ideaList.abs())
await anonymous.getByRole('link', { name: title }).click()
await expect(anonymous.getByRole('link', { name: 'Edit idea' })).toHaveCount(
0,
)
} finally {
await anonymous.close()
}GOTCHA: prefer
page.goto(route)over clicking a header link when a toast or overlay may sit over it — an overlapping success toast can swallow the click.
Three ways to get a server
e2e needs a running app; the setup chooses how it starts, by env flag:
bun run test:e2e:dev # start the app in dev mode, no build — fast, the default locally
bun run test:e2e:build # build first, then run the built server — closest to prod (what `bun run test` uses)
bun run test:e2e:no-run # don't start anything; connect to a server you already runFor the fastest iteration, run the server yourself once and reuse it across runs:
bun run dev:test # NODE_ENV=test dev server, in one shell
bun run test:e2e:no-run # tests connect to it, in another — no per-run bootEach script is the same Bun e2e run with a different flag the e2e setup reads:
E2E_TESTS_BUILD=true builds then runs dist/server/index.server.js;
E2E_TESTS_NO_RUN=true skips launching; the default runs point0 dev. (There
is no point0 serve/start command — the built server is run by executing
dist/server/index.server.js directly. See build and
deploy.)
What the e2e setup does
src/test/setup/e2e.ts boots the app and gates the tests on health. The shape
worth copying:
import { killPort } from '@point0/engine/port'
// 1. free the ports first (a public engine helper)
await killPort([
Number(process.env.CLIENT_PORT),
Number(process.env.SERVER_PORT),
])
// 2. spawn the app as a child process (point0 dev, or the built server, or nothing)
const mainProcess = Bun.spawn(runCommand, {
stdout: 'inherit',
stderr: 'inherit',
env: { ...process.env, NODE_ENV: 'test' },
})
// 3. kill it when this process exits — afterAll + plain signal listeners, so an
// interrupted run never orphans the app (signal-exit doesn't hold under Bun)
afterAll(() => mainProcess.kill())
process.on('exit', () => mainProcess.kill())
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.once(signal, () => {
mainProcess.kill()
process.exit(1)
})
}
// 4. wait until the app is actually serving before any test runs
await waitForApiToBeHealthy()
await waitForClientToBeHealthy()The health gate is a small pattern: add a plain health action and poll it until the app answers.
// expose a health endpoint…
export const apiHealthAction = root.lets
.action('GET', '/api/health')
.action(async () => new Response('OK', { status: 200 }))
// …and poll it (and the client HTML) before running tests
const isApiHealthy = async () =>
(await fetch(serverEnv.SERVER_URL + apiHealthAction.route.get())).okReference
Test-time engine API
| Call | Returns | Use for |
|---|---|---|
engine.prepare() | Promise<void> | ready the engine once before any in-process fetch (no port, no DB) |
engine.withFetch(cb) | cb's return | wrap in-process point fetches so loaders run against this engine |
point.fetchServer(input, fetchOptions?) | the loader's typed output | run one point's server loader; throws on error / redirect |
point.fetchServerDetailed(input, fetchOptions?) | { output } | { error } | { redirect } | same, but assert on error / redirect instead of throwing |
engine.fetch(request, init?) | Promise<Response> | drive a raw HTTP request through the pipeline in-process |
engine.fetchDetailed(request, init?) | detailed result | as above, plus matched point / scope / raw error |
killPort(ports) | Promise<void> | free ports before spawning the app (@point0/engine/port) |
fetchOptions is RequestInit & { transform?: boolean }. Both fetchServer*
also take a third { outputType? } option ('data' by default; other values
shape the result for SSR) — not needed for ordinary tests.
Test kinds and scripts
| Kind | Suffix | Setup spins up | Default run |
|---|---|---|---|
| unit | *.unit.test.ts | nothing | bun run test:unit |
| dom | *.dom.test.tsx | happy-dom + Testing Library | bun run test:dom |
| int | *.int.test.ts | engine (prepare) + Prisma, no server | bun run test:int |
| e2e | *.e2e.test.ts | real server + Playwright browser | bun run test:e2e:dev |
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️