Testing
The example app (PulsN) is tested at two levels, and the split is deliberate:
- Unit tests — pure logic, run by Bun's built-in
bun:testrunner. Fast, no database, no browser. They live next to the code as*.test.tsundersrc/. - End-to-end tests — real browser flows driven by Playwright,
run against a running dev server and a seeded Postgres. They live under
e2e/.
If you come from Django, the mapping is almost one-to-one — unit tests are your
TestCases (only the runner is bun:test, not unittest), and the e2e suite is
Selenium/Playwright against a database fixture. The one piece without an obvious
Django analogue is the shared testIds registry: a typed table of
data-testid strings imported by both the components and the specs, so a renamed
selector breaks the build, not a test at runtime. More on that below.
The two runners, and why they're separate
The app's package.json defines them as two scripts:
// example/package.json
{
"scripts": {
"test": "bun test src", // unit only — bun:test over src/**/*.test.ts
"test:e2e": "playwright test", // end-to-end — the e2e/ suite
},
}bun run test is scoped to src on purpose. Bun's test runner discovers every
*.test.ts it can reach, and Playwright specs are also *.spec.ts/*.test.ts
files — but they import { test } from "@playwright/test", which is a different
test than bun:test's. If Bun tried to execute an e2e/ spec it would load
Playwright's runner out of context and fail. Pointing bun test at src keeps the
two worlds from colliding: unit tests never need a browser or a database, so they
must never pull one in. That's also why CI can run bun run test as a hard gate on
every PR (see In CI) while the slow, full-stack Playwright run stays
best-effort.
Unit tests (bun:test)
The runner is built into Bun — no Jest, no Vitest, no config. The API is the
familiar describe / test / expect, imported from bun:test:
import { describe, expect, test } from "bun:test";What gets unit-tested is pure logic: the deterministic core of a server-fn
helper, with the database and the network factored out. The canonical example is
src/lib/admin/admin.test.ts, which covers the generic CSV ↔ row mapping and the
import-planning logic the admin area uses. It calls the real descriptor-driven
helpers (genericToCsv / genericFromCsv from #/lib/admin/derive, planImport
from @tikab-interactive/fusion-import-export/plan) and asserts on their return
values — no DB, no Playwright:
// example/src/lib/admin/admin.test.ts
const taskStatus = getResource("processnTaskStatus");
describe("generic CSV mapping", () => {
test("toCsv → fromCsv round-trips a row (lower-cased keys, typed back)", () => {
const row = { id: 1, name: "To Do", localeKey: "todo", sortOrder: 2, isDefaultNew: true };
const csv = genericToCsv(taskStatus, row);
expect(csv.localekey).toBe("todo");
expect(csv.sortorder).toBe("2"); // serialized to a string
expect(csv.id).toBeUndefined(); // id has inCsv: false
const back = genericFromCsv(taskStatus, csv);
expect(back?.sortOrder).toBe(2); // typed back to a number
});
});Two things are worth copying from this file. First, it imports the real
production descriptor (getResource("processnTaskStatus")) rather than a mock, so
the test doubles as a check that the admin registry is wired correctly — a misnamed
column would fail here. Second, every case is a pure function of its inputs:
planImport(...) is handed an in-memory existing map and an inline CSV string and
asserted to classify rows into new / update / skip / error. Nothing touches
Postgres, so the suite runs in milliseconds.
The rule of thumb: if a server-fn handler has a non-trivial pure core — a mapper, a classifier, a validator, a normalizer — lift that core into a plain function and unit-test it directly. The DB round-trip and the auth check around it are the e2e suite's job.
End-to-end tests (Playwright)
E2E specs live under example/e2e/ and run with bunx playwright test (or the
test:e2e script). The configuration is example/playwright.config.ts:
import { defineConfig, devices } from "@playwright/test";
// End-to-end tests against the dev server.
//
// Prerequisites — a seeded local database:
// docker compose up -d && bun run db:migrate && bun run db:seed
// The ten seeded logins — admin andre.holmstrom.tikab@p4o.se plus nine other
// …@p4o.se users — all share the password "password123". The roster is in
// scripts/seed/shared.ts; the credential constants live in e2e/helpers.ts.
//
// Set E2E_BASE_URL to run against an already-running server (local or deployed)
// instead of letting Playwright start the dev server.
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: "list",
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: process.env.E2E_BASE_URL
? undefined
: {
command: "bun run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
timeout: 120_000,
},
});A few decisions baked in here:
- One worker, not parallel. The specs share one database, and several create or mutate rows (sign-ups, admin imports). Serial execution keeps them from racing each other on shared seed state.
- Playwright boots the dev server itself (
webServer.command: "bun run dev") unless you point it at an already-running one withE2E_BASE_URL. Locally it reuses a server you already have up; in CI it always starts its own. trace: "on-first-retry"captures a full Playwright trace the first time a test retries, so CI flakes are debuggable after the fact.
The seeded database
E2E tests are not hermetic — they run against the real seeded dataset, the same one a developer sees when they open the app. You produce it with:
docker compose up -d # Postgres (+ MinIO, etc.)
bun run db:migrate # drizzle-kit migrate
bun run db:seed # bun scripts/seed.tsbun run db:seed runs scripts/seed.ts, a thin entry point over the modular seed
under scripts/seed/. It creates, among much else, the ten named Tikab logins —
the real people who sign in to the demo. They are defined in scripts/seed/shared.ts
as TIKAB_USERS, created through Better Auth (so the passwords are bcrypt-hashed and
can actually log in), and they all share one password:
export const PASSWORD = "password123";
export const SITE_ADMIN = { name: TIKAB_USERS.andre.name, email: TIKAB_USERS.andre.email } as const;Every login is an …@p4o.se address (the register's real domain — there is no
example.com anywhere). The seed also inserts ~655 non-loginable register people and
~1000 faker noise users, but only the ten TIKAB_USERS have an account row, so only
they can sign in. After seeding, seed.ts also rebuilds the universal search
index so search works on first boot.
The helpers: signIn, signUp, and friends
Specs don't re-implement the login dance — they import it from e2e/helpers.ts. The
file exports the seed credentials as constants (SEED_PASSWORD, ADMIN_EMAIL, USER_EMAIL
— all …@p4o.se) and a small set of flows. The core one is signIn:
export async function signIn(page: Page, email: string, password: string) {
await gotoStable(page, "/login");
await fillField(page, testIds.auth.email, email);
await fillField(page, testIds.auth.password, password);
await page.getByTestId(testIds.auth.submit).click();
// Auth lands on the app's default authed page (/home).
await expect(page).toHaveURL(/\/(home|hello)$/, { timeout: 20_000 });
}So a typical spec is just "sign in, then assert on the authed app":
test("sign in with valid credentials reaches the authed app", async ({ page }) => {
await signIn(page, USER_EMAIL, SEED_PASSWORD);
// The header user menu only renders inside the authed app shell.
await expect(page.getByTestId(testIds.userMenu.trigger)).toBeVisible();
});The helper set is small and worth knowing:
| Helper | What it does |
|---|---|
signIn(page, email, password) | Go to /login, fill the form, submit, assert you reach the app |
signUp(page, { name, … }) | The same for /signup (name + email + password + confirm) |
signOut(page) | Open the header user menu and log out, asserting return to /login |
gotoStable(page, path) | Navigate and wait for hydration (see the gotcha below) |
fillField(page, testId, val) | Fill a Mantine field by test id, input-or-wrapper (gotcha below) |
uniqueEmail() | A fresh e2e-…@p4o.se per call, so signup specs never collide |
Each Playwright test runs in a fresh browser context (isolated cookies), so there's no need to sign out between tests — a new test starts logged-out.
The testIds registry — one source of truth for selectors
This is the piece a Django/React newcomer should internalize. Every data-testid
the app uses lives in one typed object, testIds, in
fusion-ui/src/test-ids.ts:
// fusion-ui/src/test-ids.ts
export const testIds = {
auth: {
form: "auth-form",
email: "auth-email",
password: "auth-password",
submit: "auth-submit",
error: "auth-error",
},
userMenu: { trigger: "user-menu-trigger", logout: "user-menu-logout" },
search: { field: "search-field", panel: "search-panel", item: "search-item" /* … */ },
// …admin, files, conversationHeader, notifications, sandbox.*, etc.
} as const;
export type TestIds = typeof testIds;The registry is exported from fusion-ui on the
@tikab-interactive/fusion-ui/testIds subpath — pure constants, no React — so a
Playwright spec can import it without dragging the component tree in. The same object
is consumed on both sides of every assertion:
- The fusion-ui components set the attribute from it: the auth form renders
data-testid={testIds.auth.submit}, the search paneltestIds.search.panel, and so on. (Thesandbox.*group is the one exception — those ids are set by the example app's demo pages, but the strings still live in this one registry so the app, the components, and the tests all agree.) - The specs query with it:
page.getByTestId(testIds.auth.submit),panel.getByTestId(testIds.search.item).
Because it's as const and typed, a typo or a rename is caught by the
typechecker, not by a red test hours later. Rename submit → submitButton in the
registry and every component and spec that still says testIds.auth.submit fails to
compile. This is the structural reason a selector can't silently drift out of sync
with the UI — there is exactly one string, in one place, and TypeScript guards every
reference to it.
This is the analogue of Django's "don't hardcode URLs, use reverse('name')"
discipline, applied to test selectors: a named, typed constant instead of a brittle
string literal scattered across files.
The gotchas
Two sharp edges show up in almost every spec. Both are handled by the helpers, so you mostly inherit the fix for free — but you need to know they exist.
1. Wait for hydration before you act
The app server-renders, then hydrates on the client to wire up the SPA router and
form handlers. If a test clicks a link before hydration finishes, it triggers a
native navigation/form-submit and skips the client router entirely — the test
then asserts against the wrong page. The fix: the root component sets
data-hydrated="true" on <html> in a mount effect, and gotoStable waits for that
marker before returning:
export async function gotoStable(page: Page, path: string) {
await page.goto(path);
await page.waitForLoadState("domcontentloaded");
await page.waitForFunction(() => document.documentElement.dataset.hydrated === "true", null, {
timeout: 20_000,
});
}Always navigate with gotoStable, not raw page.goto, unless you are
specifically testing pre-hydration behaviour (e.g. the auth.spec.ts case that
asserts an anonymous page.goto("/hello") redirects to /login — there, the server
redirect is the whole point).
2. The Mantine fillField quirk
Mantine renders inputs inconsistently from a test's point of view: on a TextInput
the data-testid lands on the <input> element itself, but on a PasswordInput (and
some composite fields) it lands on a wrapper around the input. A plain
getByTestId(id).fill(value) works for the first and throws for the second.
fillField papers over the difference by looking for a nested input first:
export async function fillField(page: Page, testId: string, value: string) {
const el = page.getByTestId(testId);
const nested = el.locator("input, textarea");
if (await nested.count()) await nested.first().fill(value);
else await el.fill(value);
}Use fillField for every Mantine field (it also covers <textarea>, e.g. the
CSV box in the import sandbox). Reach for raw .fill() only on a plain native input
you control directly.
In CI
CI runs in fusion-meta/.github/workflows/ci.yml. The example app is not part of
the repo-root install — it's a standalone Bun project that links the fusion-*
packages as source through tsconfig paths. So it has its own job
(example:), which first lays out the sibling package repos next to the checkout
(the source must be present — published tarballs aren't enough for source linking),
then runs the gates.
The hard gates — any one failing reds the build — are the four scripts every package defines (see Conventions), run in order:
# .github/workflows/ci.yml (the `example` job)
- run: bun run lint # oxlint
- run: bun run fmt:check # oxfmt --check
- run: bun run typecheck # tsgo --noEmit
- run: bun run test # bun test src ← the unit suiteA Postgres 17 service is attached to the job (published on 5433, matching
.env.local.example), which lets a single Playwright slice run too — but that step is
deliberately best-effort:
- name: Migrate + seed + auth smoke (Playwright)
continue-on-error: true # a slow/full-stack boot can't red the gate
run: |
bunx playwright install --with-deps chromium
bun run db:migrate
bun run db:seed
bunx playwright test e2e/auth.spec.ts # the lightest end-to-end sliceThe reasoning: lint / format / typecheck / unit-test are deterministic and cheap, so
they stay blocking. Booting the whole stack (dev server + DB + migrate + seed) for
a browser flow is heavier and occasionally flaky, so it runs continue-on-error and
only over auth.spec.ts — enough to catch a broken login wiring without letting a
slow boot fail an otherwise-green PR.
Running the whole thing locally
The four hard gates, exactly as CI runs them:
cd example
bun run lint
bun run fmt:check
bun run typecheck
bun run test # the bun:test unit suiteThe full e2e suite (needs a seeded DB and a dev server — Playwright will start the dev server for you):
docker compose up -d
bun run db:migrate
bun run db:seed
bun run test:e2e # or: bun run test:e2e:ui for the Playwright inspectorTo iterate against a server you already have running, set E2E_BASE_URL and
Playwright will skip booting its own:
E2E_BASE_URL=http://localhost:3000 bun run test:e2eAdding a test
A unit test
Drop a *.test.ts next to the code, import from bun:test, and test the pure
core. Keep the DB and network out — that's the e2e suite's job.
// example/src/lib/<area>/<thing>.test.ts
import { describe, expect, test } from "bun:test";
import { normalizeKey } from "#/lib/<area>/<thing>";
describe("normalizeKey", () => {
test("trims and lower-cases", () => {
expect(normalizeKey(" ToDo ")).toBe("todo");
});
});It's picked up automatically by bun run test — no registration, no config.
An e2e test
Add a *.spec.ts under e2e/ (or a subfolder like e2e/sandbox/), reuse a helper
to get authenticated, and query with a testIds selector — never a raw string. The
shape every spec follows:
// example/e2e/<feature>.spec.ts
import { expect, test } from "@playwright/test";
import { testIds } from "@tikab-interactive/fusion-ui/testIds";
import { ADMIN_EMAIL, SEED_PASSWORD, gotoStable, signIn } from "./helpers";
test.describe("<feature>", () => {
test("does the thing", async ({ page }) => {
await signIn(page, ADMIN_EMAIL, SEED_PASSWORD); // or USER_EMAIL for a non-admin
await gotoStable(page, "/<route>"); // navigate AND wait for hydration
await expect(page.getByTestId(testIds.<group>.<id>)).toBeVisible();
});
});If your feature needs a new selector, add it to the testIds registry in
fusion-ui/src/test-ids.ts first, set the attribute from it in the component, and
only then reference it from the spec — so the one-source-of-truth invariant holds.
For a worked tour of the surfaces these specs cover — auth, the conversation-first home, universal search, the admin area, and one spec per package — see the Sandbox (the per-capability pages each spec drives) and the example app tour.