Seed & test
Seeding the dev database and the test patterns that actually catch regressions.
Last updated Jul 20, 2026
Seed
cd apps/server && bun run seedThe seed lives in apps/server/scripts/seed.ts. It creates:
- A super-admin user (
[email protected]/admin1234). - One organization with the admin as owner.
- The default role rows (Owner / Admin / Member) with a permission grant per role drawn from the
@app/sharedcatalog.
Re-running the seed is idempotent: existing rows are upserted, not duplicated.
The seed is also registered as Prisma's seed command (prisma.seed in apps/server/package.json), so bunx prisma db seed runs it and bunx prisma migrate reset re-seeds automatically after resetting the schema.
For local feature development, a seed grants you a logged-in admin in seconds — no need to walk through the register flow each time.
Test layers
The boilerplate runs three test layers, each exercising a different surface:
Domain & application — Vitest, in-memory
Pure unit tests for aggregates and use cases. No Prisma, no Express. Inject in-memory fakes for repositories and the event bus.
test('User.create rejects empty email', () => {
const result = User.create({ email: '', /* ... */ });
expect(result.isErr()).toBe(true);
expect(result.error.code).toBe('USER_EMAIL_INVALID');
});Run: bun run test --filter=@app/server (Vitest). These should be the largest tier.
Infrastructure — Vitest with a real DB
Repository tests run against a real Postgres (or sqlite for speed if your queries are portable). They're worth the extra friction for the mapping layer — the row-to-aggregate translation is where row-shape regressions hide.
The events module also runs with the real InMemoryEventBus rather than a stub so subscribe/publish ordering is exercised.
HTTP — supertest
Mount the actual router with a test container. Assert status codes, response shapes, and behavioral pairs:
- "user without grant gets 403, user with grant gets 200"
- "missing field returns 422 with
issues[].pathpointing at the missing key" - "second POST with the same idempotency key returns the original response, not a duplicate"
These tests are valuable when each test couldn't pass if the production code stopped doing the right thing.
End-to-end — Playwright
tests/ at the repo root. Boots the full app and clicks through real flows:
register, log in, complete onboarding, subscribe via the fake billing provider.
Runs on Node (not Bun — see tests/CLAUDE.md), serial (workers: 1), with a
fresh Postgres schema isolated per run.
CI wiring:
- Per-PR gate — the
e2e-goldenjob in.github/workflows/ci.ymlruns a golden-path subset (bun run e2e:golden: bootstrap, register, onboarding, billing, dashboard) on the desktop project. It usesE2E_AUTOSTART=1so Playwright boots server + client itself against the Postgres/Redis services. Fast (~4-7 min) and blocking — a regression in these flows turns the PR red. - Nightly full suite —
.github/workflows/e2e-nightly.ymlruns all ~36 specs (both projects) against the fulldocker-compose.dev.ymlstack, which additionally provides the BullMQ worker and MailHog. The golden subset excludes those because the autostart path doesn't boot the worker/MailHog; the worker/email-dependent specs live in the nightly run.
Run the gate locally with bash scripts/ci-local.sh --e2e (needs Postgres +
Redis up), or the subset directly with bun run e2e:golden.
What "challenging" means
The CLAUDE.md rule: a test that wouldn't fail when the logic regresses isn't worth writing.
Coverage theater looks like:
test('createUser returns user', async () => {
const result = await createUser({ email: '[email protected]', name: 'A', password: 'p' });
expect(result.isOk()).toBe(true);
});If the use case starts returning the wrong user, this test still passes. Replace it with one that asserts the user was actually persisted (via the fake repo), or one that asserts the user.created event fires with the right payload.
BullMQ end-to-end
Skip ioredis-mock for BullMQ — it doesn't run BullMQ's Lua. Either:
- Bring up a real Redis (Docker or Railway-staging) and run the suite there, or
- Unit-test the producer (asserts the wrapper called
queue.addwith the right payload) and the processor (asserts it does the right thing for a given job) separately.
Most of the time option 2 is enough.
Locale and i18n
When testing a controller that returns translated copy, set Accept-Language on the test request. The server-side i18n runtime in apps/server/src/i18n/ resolves the dictionary from headers; tests should not hit the default locale by accident.