Testing
Unit, integration, and end-to-end testing strategy.
Last updated on
8 min readSyntaxKit ships a four-tier test setup: Vitest for unit, integration, and live Stripe tests, and Playwright for end-to-end browser tests. Every layer runs independently, so you can drive any subset locally while CI runs them all on each push. Per-package coverage gates (90% lines / 85% branches) keep the unit tier honest.
The four layers
Unit, integration, live Stripe, and E2E: what each touches and how to run it.
Test environment
The deterministic .env.test escapes and why each one is enabled.
CI pipeline
How the jobs run on every PR, and where to grab failure traces.
Add a test
Drop in a unit, integration, or E2E spec the right way.
The Test Pyramid
| Layer | Tool | Command | File pattern | Touches |
|---|---|---|---|---|
| Unit | Vitest | pnpm test:run (or :coverage) | *.test.ts(x) co-located | Mocked deps; jsdom for components, node for backend |
| Integration | Vitest | pnpm test:integration | *.integration.test.ts | Real Postgres, Moto-mocked S3, real Prisma |
| Live Stripe | Vitest | pnpm test:stripe (gates on RUN_STRIPE_LIVE=1) | *.live.test.ts, *.live.integration.test.ts | Real Stripe test-mode API |
| E2E | Playwright | pnpm test:e2e | apps/web/e2e/*.spec.ts | Browser (Chromium), seeded users, real DB |
Unit Tests
Co-located *.test.ts(x) files run via per-package Vitest configs: jsdom for component tests, node for backend packages. Each package config (apps/web/vitest.config.ts plus the seven packages/*/vitest.config.ts) declares its own coverage gate, and CI fails the build if any of them slips. Every gate uses the same threshold object:
coverage: {
thresholds: {
lines: 90,
statements: 90,
functions: 90,
branches: 85,
},
},Why coverage is scoped to an allowlist
The apps/web config deliberately scopes coverage to a curated allowlist of critical files (proxy.ts, app/api/webhooks/stripe/route.ts, app/api/health/route.ts, schemas, hooks) rather than the whole tree. Component-level coverage is measured by their own tests but not gated, on the principle that a brittle gate is worse than a thoughtful one.
Integration Tests
Real Postgres, real Prisma, real aws-sdk calls against a Moto-mocked S3 bucket. The *.integration.test.ts suffix lives outside the unit-test include glob, so pnpm test:run skips them; you opt in with pnpm test:integration. The harness in packages/api/test/integration.ts exposes createIntegrationHarness() with ensureStorageBucket, cleanup, and getStoredObject helpers:
import { createIntegrationHarness } from "../../test/integration";
const harness = createIntegrationHarness();
beforeEach(async () => {
await harness.ensureStorageBucket();
await harness.cleanup();
});
afterEach(async () => {
await harness.cleanup();
});pnpm test:integration loads apps/web/.env.test via dotenvx and requires a DATABASE_URL pointing at a disposable test database. Apply migrations with pnpm db:migrate:deploy and seed deterministic data with pnpm db:seed:test before the first run; CI does both automatically.
Live Stripe Tests
The opt-in tier. Test-mode Stripe behavior occasionally drifts from what unit-test mocks assume, so the kit ships a small live suite that catches that drift early. Files use *.live.test.ts or *.live.integration.test.ts, gated by RUN_STRIPE_LIVE=1 so they never run during a normal pnpm test:run. CI runs them on a daily cron (0 3 * * *) and on workflow_dispatch via .github/workflows/stripe-live.yml. Locally:
RUN_STRIPE_LIVE=1 pnpm test:stripeRunning locally requires real Stripe test-mode credentials (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) and the price IDs from your test-mode product catalog. Keep them out of .env.test.example; copy them into apps/web/.env.test only when you actually need to run the suite, and never commit them.
End-to-End Tests
Playwright with Chromium. Specs live in apps/web/e2e/ and cover every critical user flow: marketing, auth, signup, onboarding, dashboard, organization-settings, personal-settings, billing, two-factor, admin. Playwright starts the web server itself via the webServer config in apps/web/playwright.config.ts:
webServer: {
command: `npx dotenvx run --overload -f .env.test -- next start --port ${TEST_PORT}`,
env: { NODE_ENV: "test" },
url: TEST_BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},The deterministic escapes (DISABLE_ABUSE_PROTECTION, DISABLE_CAPTCHA_FOR_TESTS, empty UPSTASH_*) are loaded from apps/web/.env.test by dotenvx. See The Test Environment for what each one does. The auth specs in apps/web/e2e/auth.spec.ts exercise the same flows documented on Authentication, so changes to login, signup, password reset, or 2FA all re-verify against a real browser.
Why E2E runs with NODE_ENV=test
NODE_ENV=test is set explicitly in webServer.env for two reasons: it tells getSetupState to skip the production-only checks (so the env-validation hard-blocks don't fire), and it tells packages/auth/src/server.ts to widen Better Auth's per-route rate limits so the auth specs can hammer /sign-in/email and /two-factor/verify-totp without tripping the credential-stuffing defenses. There is no separate env var for the rate-limit widening: it's gated on NODE_ENV === "test" directly.
Parallelization And Worker Fixtures
Playwright runs four workers locally and two in GitHub Actions, and authenticated specs avoid global state by giving each worker its own deterministic user. Three files stay in sync to make this work:
| File | What it owns |
|---|---|
apps/web/playwright.config.ts | workers: process.env.GITHUB_ACTIONS ? 2 : 4 |
apps/web/e2e/helpers/test-users.ts | Four users test-0@.. through test-3@.. mapped via getWorkerUser(parallelIndex) |
packages/database/prisma/seed/test.ts | WORKER_COUNT = 4 seeds the same four users plus test_user_0 as platform admin |
The fixture in apps/web/e2e/helpers/fixtures.ts caches the sign-in once per worker: the first spec signs the user in and writes storageState to a file, and every later spec in that worker reuses the cached cookie state:
workerStorageState: [
async ({ browser }, apply) => {
const id = test.info().parallelIndex;
const fileName = path.resolve(
test.info().project.outputDir,
`.auth/${id}.json`
);
if (fs.existsSync(fileName)) {
await apply(fileName);
return;
}
const user = getWorkerUser(id);
const page = await browser.newPage({ storageState: undefined });
// ...sign in, then:
await page.context().storageState({ path: fileName });
await apply(fileName);
},
{ scope: "worker", timeout: 120_000 },
],Signup specs create new users instead of reusing seeded ones, so uniqueSignupEmail() returns a pid + Date.now() + counter string and concurrent workers never collide on the same email.
WORKER_COUNT must stay in sync across the three files above. The seed file has a comment that calls this out, and the test-users file references the same constant. If you scale workers up, update all three.
The Test Environment
apps/web/.env.test (copied from apps/web/.env.test.example) defines the deterministic test environment. Several escapes are enabled deliberately so tests don't fight production-grade abuse and rate-limit policies. Each is hard-blocked at boot in production.
| Setting | Why |
|---|---|
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA | Cloudflare's official always-pass test secret. |
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000BB | Matching always-pass site key. |
DISABLE_CAPTCHA_FOR_TESTS=true / NEXT_PUBLIC_DISABLE_CAPTCHA_FOR_TESTS=true | Server-side captcha verification and the client Turnstile widget are bypassed so Playwright never depends on Cloudflare network latency. |
DISABLE_ABUSE_PROTECTION=true | Upstash-backed abuse protection is bypassed across every surface (auth emails, storage, chat, contact) with bypassReason: "explicit_flag". Redundant with the implicit dev_no_upstash bypass below, but kept explicit so the intent is searchable in CI logs. |
EMAIL_DELIVERY_MODE=noop | sendEmail returns true without doing anything. |
UPSTASH_REDIS_REST_URL="" | Abuse policy returns missing_config. In non-production this is auto-bypassed (bypassReason: "dev_no_upstash") so callers proceed and emit a one-time warning. |
RUN_STRIPE_LIVE=0 | Stripe live suite stays opt-in. |
None of these escapes should ever leak into production. The Security page's pre-launch checklist has explicit verification steps for each one.
Why rate limits widen for tests, but aren't in the table
Better Auth's per-route rate limits also widen for tests, but there's no env var to set: the widening is gated on NODE_ENV === "test" directly inside packages/auth/src/server.ts, mirroring how enableTestUtils is gated. Production cannot enter the test runtime, so the credential-stuffing, reset-spam, and TOTP brute-force defenses stay tight by construction.
Why tests need no env-validation bypass flag
env.server.ts skips the boot-time throw whenever NODE_ENV === "test". Vitest sets that automatically, and Playwright sets it explicitly through webServer.env so next start doesn't default to production. With NODE_ENV=test, getSetupState treats the env as non-production, which lets the escapes above live in .env.test without tripping the production hard-blocks. The production-built React bundles still serve, since process.env.NODE_ENV is inlined at next build time, not next start time. .env.test itself never sets NODE_ENV: that would corrupt the build step that loads the same file for next build.
Mocking Patterns
The canonical recipe lives in packages/api/src/router/storage.integration.test.ts: top-of-file vi.mock("@syntaxkit/shared", ...) with vi.importActual to selectively keep real exports, then vi.fn() for the boundaries you want to control:
vi.mock("@syntaxkit/shared", async () => {
const actual = await vi.importActual("@syntaxkit/shared");
return {
...actual,
enforceAbusePolicy: vi.fn(async () => ({
ok: true,
reason: "allowed",
results: {},
})),
};
});Mirror this for any test that mocks a cross-package boundary. Use vi.hoisted() when the mock factory needs data the mocked module reads at load time (rare: it surfaces in middleware and config tests). Pure-function tests need no mock; just call the function and assert.
CI Pipeline
.github/workflows/ci.yml runs on every pull request and every push to main. Jobs run in dependency order:
setupinstalls the workspace and cachesnode_modules, the generated Prisma client, and built artifacts under a SHA-keyed key. Dependent jobs restore that cache instead of reinstalling.lint,check-types,build,setup-smokerun in parallel after setup.testrunspnpm test:coverage(unit + per-package coverage gates).integrationboots Postgres 17 and Moto S3 containers, applies migrations and seeds, then runspnpm test:integration.e2eboots Postgres 17, installs Chromium, applies migrations and seeds, then runspnpm test:e2e. Uploads the Playwright HTML report (14-day retention) and traces on failure (7-day retention).validate-schemarunspnpm db:validateagainst the Prisma schema.
The Stripe live suite runs on a separate workflow (.github/workflows/stripe-live.yml) because it depends on real third-party credentials and shouldn't gate every PR. It runs daily at 03:00 UTC and via workflow_dispatch.
Test artifacts come from the e2e job. When a Playwright spec fails, download playwright-traces from the run and open it with npx playwright show-trace. The trace includes the full DOM, network log, and screenshots at every step.
Adding A Test
Adding a unit test
Drop a *.test.ts(x) next to the source file. Use jsdom for component tests (the apps/web Vitest config already sets this) and node for backend packages. Mirror the existing test patterns in the same package; to mock a cross-package boundary, follow the recipe in Mocking Patterns.
Adding an integration test
Use the *.integration.test.ts suffix so the unit run skips it. Import createIntegrationHarness from packages/api/test/integration.ts (or the equivalent helper in your package), follow the beforeEach: cleanup; afterEach: cleanup pattern, and add vi.mock calls for any cross-package boundaries you don't want to exercise live. Run locally with pnpm test:integration after pnpm db:migrate:deploy && pnpm db:seed:test against your test database.
Adding an E2E test
Drop a *.spec.ts in apps/web/e2e/. For authenticated specs, import test from helpers/fixtures.ts instead of @playwright/test so you get the worker-scoped storage state automatically. For signup specs, generate emails with uniqueSignupEmail() so concurrent workers don't collide. If you need more than four isolated user contexts, scale WORKER_COUNT in all three places (playwright.config.ts, helpers/test-users.ts, prisma/seed/test.ts) and add the matching seeded users.
Where To Go Next
Working With The Codebase
The testing-layer table this page expands on, plus the kebab-case file conventions every test file follows.
API
The procedure-test patterns the integration tests exercise, plus the mocking pattern documented here.
Authentication
The auth flows covered by the E2E auth.spec.ts, plus the abuse-protection toggles tests use.
Security
The pre-launch checklist that verifies no test-only escapes leak into production.
Going To Production
How the deployment smoke checklist complements automated tests.
