Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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.

VarPurpose
RUN_DB_TESTSSet (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 (mirrors tsconfig.json), plus server-only aliased to test/stubs/server-only.ts (an empty module). The real server-only package throws when imported outside Next's server compiler, which is exactly what running lib/env.ts, lib/dal.ts, lib/email.ts, lib/billing/access.ts etc. under plain Node/Vitest does — stubbing it is the standard workaround (same class of problem as the @better-auth/cli generate failure documented in organizations.md). The config also hand-parses .env (no dotenv dependency added) so DATABASE_URL is 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 by vitest.config.mts's include: ["**/*.test.ts"]. Current suites:
    • lib/billing/plans.test.ts — pure catalog logic (getPlan, annualSavings, PAID_PLANS).
    • lib/billing/access.test.ts — trialDaysLeft (pure), plus a describe.skipIf(!process.env.RUN_DB_TESTS) suite that inserts real subscription rows and asserts getAccess's status mapping (active/trialing/canceled/none). Gated on RUN_DB_TESTS rather than just "is DATABASE_URL set", because .env ships a real local connection string by default (see setup.md) — checking presence alone would make plain npm test try 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 the resend package mocked via vi.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/db mocked (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 sets RUN_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-v8 HTML report (coverage/index.html).
  • .github/workflows/ci.yml — six jobs: lint → typecheck → unit → build (chained via needs, each a real quality gate before the next), plus integration and playwright (both need only lint, run in parallel with the chain and each other) that each spin up their own postgres:17-alpine service container (not docker compose — GitHub Actions' native service-container feature is simpler in CI and needs no compose file) and run db:migrate against it. integration then runs RUN_DB_TESTS=1 npx vitest run directly (bypassing the test:integration script's docker compose step, since the service container already is that Postgres). playwright caches the browser binary keyed on package-lock.json's hash (actions/cache, ~/.cache/ ms-playwright) — on a cache hit it still runs playwright install-deps for the OS-level libraries the cache doesn't cover — then npx playwright test, uploading playwright-report/ as a build artifact on failure. All jobs set dummy-but-valid BETTER_AUTH_SECRET/BETTER_AUTH_URL/ NEXT_PUBLIC_APP_URL env vars — next build and drizzle-kit migrate need something shaped right, not real secrets (see lib/env.ts: nothing is required at build time). Triggers on push/PR to starter and main. README badge points at it.
  • playwright.config.mts (.mts for the same reason as vitest.config.mts — the native config loader warns/fails on import.meta in a CJS-loaded .ts file otherwise) — hand-parses .env the same way, points webServer at npm run build && npm run start on port 3100 (not 3000, so it doesn't collide with a dev server you may already have running), and defines two projects: setup (runs e2e/auth.setup.ts first) and chromium (dependencies: ["setup"]).
  • e2e/global-setup.ts — Playwright's globalSetup hook (distinct from the setup project above — this one is Node-only, no browser). Seeds one pre-verified user directly into Postgres via a standalone pg/drizzle client (deliberately not @/lib/db, which pulls in @/lib/env → the server-only package, and Playwright's config has no alias mechanism to stub it the way vitest.config.mts does). Exports E2E_USER for 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: true user with a real scrypt password hash (better-auth/crypto's hashPassword — 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 — the setup project's one test: logs in through the real /login form as E2E_USER and saves storageState to e2e/.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 (every PROTECTED_PREFIXES route redirects to /login logged out; /admin 404s rather than redirecting for a logged-in non-admin — see requireAdmin in lib/dal.ts; /login bounces an authenticated user to /dashboard). The authenticated tests use test.use({ storageState: "e2e/.auth/user.json" }) instead of logging in per test.

How to extend it

  • New unit test — add *.test.ts next to the file under test; import real dependencies and mock only what talks to the outside world (DB, Resend, Stripe) with vi.mock/vi.doMock.
  • New DB-backed test — put it in a describe.skipIf(!process.env.RUN_DB_TESTS) block so npm test stays Docker-free; clean up any rows you insert in afterEach.
  • New E2E spec — add e2e/*.spec.ts; use test.use({ storageState: "e2e/.auth/user.json" }) for anything that needs to be logged in (the chromium project's setup dependency already produced it — see "How it works"). Need a second logged-in identity (e.g. an admin) — add another test("...", ...) to e2e/auth.setup.ts that saves to a different e2e/.auth/*.json path, and seed that user in e2e/global-setup.ts alongside E2E_USER.
  • Coverage thresholds — vitest.config.mts's test.coverage block accepts a thresholds option; add one and wire test:coverage into 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.