Testing
Two test suites, two commands:
- pnpm test — unit tests across every package. Pure CPU, no Postgres needed.
- pnpm test:integration — DB-backed tests for
@byline/clientand@byline/db-postgres. Runs against a dedicatedbyline_testPostgres database — neverbyline_dev.
CI runs both, in the same job, against a Postgres service container.
TL;DR
# One-time per machinecp packages/db-postgres/.env.example packages/db-postgres/.env # dev DBcp packages/db-postgres/.env.test.example packages/db-postgres/.env.test # test DBcp packages/client/.env.test.example packages/client/.env.test # client integration testscd postgres && ./postgres.sh up -d # start the containerpnpm db:init # create byline_dev (one-time)pnpm db:init:test # create byline_test (one-time)
# Every test runpnpm test # unit suites — no DBpnpm test:integration # integration suites — requires byline_testThe integration runner auto-migrates byline_test on startup (Drizzle's migrator is idempotent) and truncates every public table between test files. A crashed prior run can't leak state into the next.
What runs where
Package |
|
|
| ✅ vitest | — |
| ✅ vitest | — |
| ✅ vitest | — |
| ✅ vitest | — |
| ✅ vitest | — |
| ✅ vitest | — |
| ✅ vitest | ✅ vitest |
| ❌ no-op (every test needs a DB) | ✅ vitest |
Only @byline/client and @byline/db-postgres write to byline_test. Everything else is pure in-memory.
pnpm test (root) runs turbo run test. pnpm test:integration (root) runs turbo run test:integration --concurrency=1 — the concurrency flag serialises the two DB-backed suites so each one's per-file TRUNCATE doesn't wipe the other's seeded fixtures mid-run.
Two databases, two purposes
Database | Used by | Lifecycle |
|
| Created once, lives as long as you want, manual seed |
|
| Created once, wiped by the test runner between test files |
Both live in the same local Postgres container (postgres/docker-compose.yml). Same byline role. The split is logical, not physical — local Postgres is a dev tool.
Safety guards
Two layers prevent any test from ever pointing at the wrong database:
- Script-level (braces) —
packages/db-postgres/src/database/common.shparsesBYLINE_DB_POSTGRES_CONNECTION_STRINGand refuses to continue unless the derived database name ends in_devor_test.db_init.shanddb_init_test.shboth go through it. - Runtime (belt) —
assertTestDatabase()inpackages/db-postgres/src/lib/test-db.tsparses the connection string at the top of every test bootstrap and throws unless the DB name ends in_test. Imported by both the vitest globalSetup (packages/client/tests/_global-setup.ts) and the node:test bootstrap (packages/db-postgres/src/lib/test-bootstrap.ts).
Isolation strategy
- Migrate once per test run — vitest
globalSetupmigrates before any test file loads. Drizzle's migrator is idempotent so re-runs are cheap. - TRUNCATE between files —
setupFilestruncates every table inpublic(except__drizzle_migrations) withRESTART IDENTITY CASCADEvia abeforeAllat the top of each test file. Existing per-test track-and-clean code (e.g. the admin tests) stays in place as a belt; TRUNCATE is the braces. - No transaction-per-test — the storage code opens its own transactions; wrapping tests in one would break the lifecycle paths under test.
Both @byline/client and @byline/db-postgres use the same vitest config shape (globalSetup + setupFiles + fileParallelism: false + single-fork pool), so the isolation story is identical across packages.
CI
.github/workflows/ci.yml runs on every pull request and on direct pushes to develop / main. Two jobs:
- lint-and-typecheck —
pnpm install --frozen-lockfile→pnpm lint→pnpm typecheck. - test-suite — boots a Postgres service container with
byline_testpre-created, writes.env.testfiles from the job-level env block, then runspnpm test(unit) followed bypnpm test:integration. Both run in the same job so they share onepnpm install.
Both jobs skip when the head commit starts with chore(release): so version-bump pushes from pnpm version-packages don't trigger redundant runs. Tag pushes (git push --tags) and gh release create aren't listened to at all, so the local-only release flow stays silent.
concurrency: cancel-in-progress cancels superseded runs on the same branch — quick fix-up pushes don't queue behind older builds.
When branch protection is enabled in repo settings, CI becomes a hard gate with no workflow change required.
Running a single test
Both packages use vitest, so the invocation is the same shape:
# @byline/clientcd packages/client && pnpm vitest run --mode=integration tests/integration/client-read.integration.test.ts
# @byline/db-postgrescd packages/db-postgres && pnpm vitest run --mode=integration src/modules/storage/tests/storage-versioning.test.tsFilter by test name with -t:
pnpm vitest run --mode=integration -t "tampered"Watch mode (re-runs on file change):
pnpm test:watchEditor smoke suite (Playwright)
Browser-level happy paths over the admin document editor — the regression net for the surfaces unit tests structurally can't see (@byline/admin forms/fields, host-adapter server fns, richtext) and for Lexical / TanStack Start version bumps. Lives in apps/webapp/e2e/ with apps/webapp/playwright.config.ts. Scope is ~10–15 happy-path scenarios, not coverage (see the growth checklist at the top of apps/webapp/e2e/editor-smoke.spec.ts).
# One-time per machinecd apps/webapp && pnpm exec playwright install chromium
# Requirements: dev Postgres up, byline_dev migrated + seeded, and .env.local# carrying BYLINE_SUPERADMIN_EMAIL / BYLINE_SUPERADMIN_PASSWORDcd apps/webapp && pnpm tsx byline/seed.ts # if not already seeded
# Run (starts or reuses the Vite dev server on :5173)cd apps/webapp && pnpm test:e2ecd apps/webapp && pnpm test:e2e:ui # headed UI modeThe setup project signs in through the real form (keeping the sign-in flow itself under test — the surface the v3.5.1 form-GET leak lived on) and persists the session to e2e/.auth/admin.json for the other projects. Tests that mutate documents create their own document first, so reruns stay clean against a long-lived dev database.
Hydration caveat: interactions that land before React hydrates set native input values without reaching the form context, so the dirty-gated Save button never enables — and a pre-hydration submit falls back to the native form post. The suite waits for hydration via React fiber keys (waitForHydration in editor-smoke.spec.ts) before interacting; new scenarios should do the same after any full page load.
Agent-surface specs (Playwright)
The same Playwright run carries contract specs for the public agent-facing routes, alongside the editor smoke suite: e2e/sitemap.spec.ts (dynamic sitemap.xml with hreflang alternates), e2e/markdown.spec.ts (the .md document representations), and e2e/llms.spec.ts (the llms.txt index). These pin the served output of the markdown export surface — the format contract itself is documented in MARKDOWN-EXPORT.md and unit-pinned in packages/richtext-lexical and packages/core; the e2e specs cover the route/negotiation layer on top (locale prefixing, caching headers, the Accept: text/markdown redirect). Same requirements as above: seeded dev database, pnpm test:e2e.