Skip to content
Fusion

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:test runner. Fast, no database, no browser. They live next to the code as *.test.ts under src/.
  • 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:

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 with E2E_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.ts

bun 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:

example/scripts/seed/shared.ts
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:

example/e2e/helpers.ts
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":

example/e2e/auth.spec.ts
	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:

HelperWhat 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 panel testIds.search.panel, and so on. (The sandbox.* 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 submitsubmitButton 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:

example/e2e/helpers.ts
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:

example/e2e/helpers.ts
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 suite

A 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 slice

The 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 suite

The 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 inspector

To 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:e2e

Adding 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.