Testing & CI
Testing & CI
What it does
Vitest for unit and integration tests, Playwright for end-to-end browser tests, all run on every push/PR by a GitHub Actions workflow (lint → typecheck → unit tests → build, plus parallel DB-backed integration and Playwright jobs).
How to configure it
Nothing to configure for the Docker-free unit suite. The DB-backed
integration suite needs a real Postgres — either the compose one
(npm run dev:db) or CI's service container — reachable via DATABASE_URL.
| Var | Purpose |
|---|---|
RUN_DB_TESTS | Set (to anything) to un-skip the DB-backed integration describe blocks. npm run test:integration sets it for you. |
The Playwright suite needs the same compose Postgres, migrated
(npm run dev:db && npm run db:migrate), plus a downloaded browser
(npx playwright install chromium — one-time, not an npm dependency).
npm run test:e2e runs both DB steps for you before invoking playwright test.
How it works
vitest.config.mts—@path alias (mirrorstsconfig.json), plusserver-onlyaliased totest/stubs/server-only.ts(an empty module). The realserver-onlypackage throws when imported outside Next's server compiler, which is exactly what runninglib/env.ts,lib/dal.ts,lib/email.ts,lib/billing/access.tsetc. under plain Node/Vitest does — stubbing it is the standard workaround (same class of problem as the@better-auth/cli generatefailure documented in organizations.md). The config also hand-parses.env(nodotenvdependency added) soDATABASE_URLis available to the integration suite without exporting it manually.- Test files live next to the code they test (
lib/foo.ts→lib/foo.test.ts), matched byvitest.config.mts'sinclude: ["**/*.test.ts"]. Current suites:lib/billing/plans.test.ts— pure catalog logic (getPlan,annualSavings,PAID_PLANS).lib/billing/access.test.ts—trialDaysLeft(pure), plus adescribe.skipIf(!process.env.RUN_DB_TESTS)suite that inserts realsubscriptionrows and assertsgetAccess's status mapping (active/trialing/canceled/none). Gated onRUN_DB_TESTSrather than just "isDATABASE_URLset", because.envships a real local connection string by default (see setup.md) — checking presence alone would make plainnpm testtry to hit a Postgres that might not be running.lib/email.ts—sendEmail's no-op-when-unconfigured contract, and both the critical/non-critical error-handling branches, with theresendpackage mocked viavi.doMock+vi.resetModules()(the module memoises its client, so each case needs a fresh import).lib/actions/contact.test.ts—submitContact's zod validation branches and the DB-failure path, with@/lib/dbmocked (vi.mock) so it runs without Postgres.
npm test— the full suite, DB-backed tests skipped. Safe to run with nothing else running.npm run test:integration— brings up the compose Postgres (dev:db) and setsRUN_DB_TESTS=1, so the DB-backed suites run too.npm run test:watch/npm run test:coverage— Vitest's watch mode and@vitest/coverage-v8HTML report (coverage/index.html)..github/workflows/ci.yml— six jobs:lint→typecheck→unit→build(chained vianeeds, each a real quality gate before the next), plusintegrationandplaywright(both need onlylint, run in parallel with the chain and each other) that each spin up their ownpostgres:17-alpineservice container (notdocker compose— GitHub Actions' native service-container feature is simpler in CI and needs no compose file) and rundb:migrateagainst it.integrationthen runsRUN_DB_TESTS=1 npx vitest rundirectly (bypassing thetest:integrationscript'sdocker composestep, since the service container already is that Postgres).playwrightcaches the browser binary keyed onpackage-lock.json's hash (actions/cache,~/.cache/ ms-playwright) — on a cache hit it still runsplaywright install-depsfor the OS-level libraries the cache doesn't cover — thennpx playwright test, uploadingplaywright-report/as a build artifact on failure. All jobs set dummy-but-validBETTER_AUTH_SECRET/BETTER_AUTH_URL/NEXT_PUBLIC_APP_URLenv vars —next buildanddrizzle-kit migrateneed something shaped right, not real secrets (seelib/env.ts: nothing is required at build time). Triggers on push/PR tostarterandmain. README badge points at it.playwright.config.mts(.mtsfor the same reason asvitest.config.mts— the native config loader warns/fails onimport.metain a CJS-loaded.tsfile otherwise) — hand-parses.envthe same way, pointswebServeratnpm run build && npm run starton port 3100 (not 3000, so it doesn't collide with a dev server you may already have running), and defines two projects:setup(runse2e/auth.setup.tsfirst) andchromium(dependencies: ["setup"]).e2e/global-setup.ts— Playwright'sglobalSetuphook (distinct from thesetupproject above — this one is Node-only, no browser). Seeds one pre-verified user directly into Postgres via a standalonepg/drizzleclient (deliberately not@/lib/db, which pulls in@/lib/env→ theserver-onlypackage, and Playwright's config has no alias mechanism to stub it the wayvitest.config.mtsdoes). ExportsE2E_USERfor specs to reuse. There's no DB row to read a real verification link from (see the file's own comment for why), so this seeds an already-emailVerified: trueuser with a real scrypt password hash (better-auth/crypto'shashPassword— the same implementation the credential provider itself uses) rather than driving the email-link flow. Deletes and re-inserts on every run, so re-running needs no manual cleanup.e2e/auth.setup.ts— thesetupproject's one test: logs in through the real/loginform asE2E_USERand savesstorageStatetoe2e/.auth/user.json(gitignored — it's a live session cookie). Doubles as a login smoke test.e2e/*.spec.ts—marketing.spec.ts(landing/pricing/contact, logged out),auth.spec.ts(signup reaches "check your email"; login; wrong password),access-control.spec.ts(everyPROTECTED_PREFIXESroute redirects to/loginlogged out;/admin404s rather than redirecting for a logged-in non-admin — seerequireAdmininlib/dal.ts;/loginbounces an authenticated user to/dashboard). The authenticated tests usetest.use({ storageState: "e2e/.auth/user.json" })instead of logging in per test.
How to extend it
- New unit test — add
*.test.tsnext to the file under test; import real dependencies and mock only what talks to the outside world (DB, Resend, Stripe) withvi.mock/vi.doMock. - New DB-backed test — put it in a
describe.skipIf(!process.env.RUN_DB_TESTS)block sonpm teststays Docker-free; clean up any rows you insert inafterEach. - New E2E spec — add
e2e/*.spec.ts; usetest.use({ storageState: "e2e/.auth/user.json" })for anything that needs to be logged in (thechromiumproject'ssetupdependency already produced it — see "How it works"). Need a second logged-in identity (e.g. an admin) — add anothertest("...", ...)toe2e/auth.setup.tsthat saves to a differente2e/.auth/*.jsonpath, and seed that user ine2e/global-setup.tsalongsideE2E_USER. - Coverage thresholds —
vitest.config.mts'stest.coverageblock accepts athresholdsoption; add one and wiretest:coverageinto CI as a gate once you have enough tests for a threshold to mean something.
How to remove it
Delete vitest.config.mts, test/, every *.test.ts, the
vitest/@vitest/coverage-v8 dev dependencies, playwright.config.mts,
e2e/, the @playwright/test dev dependency, every test* script in
package.json, and .github/workflows/ci.yml. Nothing else in the app
imports test code, so there's nothing else to untangle.