Testing
Four layers: Vitest unit and integration, opt-in Stripe live, and Playwright E2E. One command per layer. Coverage gates at 90% lines / 85% branches.
Last updated on
8 min readOn This Page
The Test Pyramid
Pick the layer that matches what you need to prove. Each layer has its own command and file pattern.
| Layer | Tool | Command | File pattern | Touches |
|---|---|---|---|---|
| Unit | Vitest | pnpm test:run (or pnpm test:coverage) | *.test.ts(x) next to the source | Mocked deps. jsdom in apps/web and packages/storage; node elsewhere |
| Integration | Vitest | pnpm test:integration | *.integration.test.ts | Real Postgres, real Prisma, Moto-mocked S3 |
| Live Stripe | Vitest | RUN_STRIPE_LIVE=1 pnpm test:stripe | *.live.test.ts, *.live.integration.test.ts | Real Stripe test-mode API |
| E2E | Playwright | pnpm test:e2e | apps/web/e2e/*.spec.ts | Chromium, seeded users, real DB |
Unit and coverage run on every web-touching PR. Integration and E2E do too. Stripe live stays opt-in (see CI Pipeline).
Unit Tests
Put a *.test.ts or *.test.tsx next to the file under test. Vitest configs live in apps/web/vitest.config.ts and eight packages/*/vitest.config.ts files. apps/web and packages/storage use jsdom; the rest use node.
Coverage thresholds come from defaultCoverageThresholds in @syntaxkit/shared/testing. Every package spreads the same object:
coverage: {
thresholds: {
lines: 90,
statements: 90,
functions: 90,
branches: 85,
},
},CI fails if any package slips below those numbers.
Why coverage is scoped to an allowlist
apps/web does not gate the whole tree. Its coverage include list is a small set of critical files: proxy.ts, lib/schemas/auth.ts, lib/auth-errors.ts, components/auth/captcha-provider.tsx, app/api/health/route.ts, app/api/healthz/route.ts, and app/api/webhooks/stripe/route.ts. Components still have their own tests; they are just not part of the gate. A brittle whole-app gate is worse than a focused one.
Integration Tests
These hit real Postgres and Prisma. S3 calls go to a Moto mock. The *.integration.test.ts suffix is excluded from the unit include glob, so pnpm test:run skips them. Opt in with pnpm test:integration.
The harness in packages/api/test/integration.ts exposes createIntegrationHarness() with ensureStorageBucket, cleanup, and getStoredObject:
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. Point DATABASE_URL at a disposable test database. Before the first run: pnpm db:migrate:deploy then pnpm db:seed:test. CI does both automatically.
Live Stripe Tests
Use this when unit mocks might drift from real Stripe test-mode behavior. Files use *.live.test.ts or *.live.integration.test.ts. Suites call describe.skip unless RUN_STRIPE_LIVE=1 and real test-mode keys are set, so a normal pnpm test:run never hits Stripe.
RUN_STRIPE_LIVE=1 pnpm test:stripeThe full suite runs from .github/workflows/stripe-live.yml on workflow_dispatch only. A lighter pnpm billing:check-prices job runs on pushes to main to catch catalog drift. Neither gates every PR.
Local runs need real Stripe test-mode credentials (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) and the price IDs from your test-mode catalog. Keep them out of .env.test.example. Put them in apps/web/.env.test only when you need the suite, and never commit them.
End-to-End Tests
Playwright drives Chromium against the real app. Specs live in apps/web/e2e/ and cover marketing, auth, onboarding, dashboard, organization settings, personal settings, billing, two-factor, and admin.
Playwright starts the server itself via webServer in apps/web/playwright.config.ts:
webServer: {
command: `npx dotenvx run --overload -f .env.test -- next start --port ${TEST_PORT}`,
env: { NODE_ENV: "test", SYNTAXKIT_E2E: "true" },
url: TEST_BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},dotenvx loads the escapes from apps/web/.env.test (DISABLE_ABUSE_PROTECTION, DISABLE_CAPTCHA_FOR_TESTS, empty UPSTASH_*). See The Test Environment. Auth specs in apps/web/e2e/auth.spec.ts exercise the same flows as Authentication, so login, signup, password reset, and 2FA re-verify in a real browser.
Why E2E sets NODE_ENV=test and SYNTAXKIT_E2E
NODE_ENV=test does two jobs: getSetupState skips production-only checks, and packages/auth widens Better Auth per-route rate limits so specs can hammer /sign-in/email and /two-factor/verify-totp without tripping credential-stuffing defenses. There is no separate flag for that widening; it keys off NODE_ENV === "test".
SYNTAXKIT_E2E=true is the harness opt-in. A production build (next start) with NODE_ENV=test would otherwise fail the boot guard in env.server.ts. Playwright injects both values in webServer.env. Never set SYNTAXKIT_E2E on a real host.
Parallelization And Worker Fixtures
Playwright uses four workers locally and two in GitHub Actions. Authenticated specs avoid shared state by giving each worker its own seeded user.
The single source of truth is @syntaxkit/shared/testing:
| Constant / export | Consumers |
|---|---|
TEST_WORKER_COUNT (4) | apps/web/playwright.config.ts (workers: process.env.GITHUB_ACTIONS ? 2 : TEST_WORKER_COUNT) |
TEST_USERS (test-0@… through test-3@…) | apps/web/e2e/helpers/test-users.ts via getWorkerUser(parallelIndex) |
Same TEST_USERS | packages/database/prisma/seed/test.ts (seeds those users; test_user_0 is platform admin) |
Change the count or emails in @syntaxkit/shared/testing. Seed, Playwright workers, and helpers stay aligned.
The fixture in apps/web/e2e/helpers/fixtures.ts caches sign-in once per worker. The first spec signs in and writes storageState; later specs in that worker reuse the cookie file:
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. uniqueSignupEmail() builds a pid + Date.now() + counter address so concurrent workers never collide.
To scale workers, update TEST_WORKER_COUNT and TEST_USERS in @syntaxkit/shared/testing, then re-seed with pnpm db:seed:test.
The Test Environment
Copy apps/web/.env.test.example to apps/web/.env.test. Several escapes are on so tests do not fight production abuse and rate-limit policies. Each escape 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 captcha checks and the client Turnstile widget are bypassed so Playwright never waits on Cloudflare. |
DISABLE_ABUSE_PROTECTION=true | Upstash-backed abuse protection is bypassed (bypassReason: "explicit_flag"). Redundant with the empty Upstash bypass below, but kept so the intent shows up in CI logs. |
EMAIL_DELIVERY_MODE=noop | sendEmail returns true without sending. |
UPSTASH_REDIS_REST_URL="" | Abuse policy returns missing_config. In non-production that auto-bypasses (bypassReason: "dev_no_upstash"). |
RUN_STRIPE_LIVE=0 | Stripe live suite stays opt-in. |
SYNTAXKIT_E2E=true | Allows next start under NODE_ENV=test for the Playwright harness. Playwright also injects this in webServer.env. |
None of these escapes should reach production. The Security pre-launch checklist has verification steps for each one.
Why rate limits widen for tests, but aren't in the table
Better Auth per-route rate limits widen under NODE_ENV === "test" inside packages/auth. There is no env var to flip. Production cannot enter that runtime, so 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 when NODE_ENV === "test". Vitest sets that automatically. Playwright sets it in webServer.env, plus SYNTAXKIT_E2E=true so a production-built next start is allowed to boot as a harness. With NODE_ENV=test, getSetupState treats the env as non-production, so the escapes above can live in .env.test without tripping production hard-blocks.
The production-built React bundles still serve, because 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
For unit tests that cross package boundaries, mirror packages/api/src/router/storage.test.ts: declare mocks with vi.hoisted, then vi.mock with vi.importActual to keep real exports you still need.
const mocks = vi.hoisted(() => ({
enforceAndResolveAbuse: vi.fn(),
// ...
}));
vi.mock("@syntaxkit/shared/abuse", async () => {
const actual = await vi.importActual("@syntaxkit/shared/abuse");
return {
...actual,
enforceAndResolveAbuse: mocks.enforceAndResolveAbuse,
};
});Integration tests use the same idea with a lighter mock (see storage.integration.test.ts): keep the real module, stub only enforceAndResolveAbuse. Use vi.hoisted() when the mock factory needs data the mocked module reads at load time. Pure-function tests need no mock: call the function and assert.
CI Pipeline
.github/workflows/ci.yml runs on pull requests and pushes to main. Path filters skip heavy jobs when a change cannot affect them (for example docs-only PRs). When web code changes, the shape is:
changesdecides which areas moved (web,docs,setup, …).setupinstalls the workspace and cachesnode_modules, the generated Prisma client, and built package artifacts under a SHA key. Later jobs restore that cache.static-checksrunspnpm lint,pnpm check-types,pnpm db:validate, and build-arg sync in one job.build-testbuilds docs when needed and runspnpm test:coverage(unit + per-package gates).setup-smokeruns only when setup-related files change.integrationboots Postgres 17 and Moto S3, migrates, seeds, then runspnpm test:integration.e2eboots Postgres 17, installs Chromium, migrates, seeds, builds the app, then runspnpm test:e2e:run. Uploads the Playwright HTML report (7-day retention) and traces on failure (3-day retention).ci-gatefails the workflow if any required job failed or was cancelled.
Stripe live is a separate workflow (.github/workflows/stripe-live.yml). The full suite is workflow_dispatch only. Catalog price drift (pnpm billing:check-prices) runs on pushes to main.
When a Playwright spec fails, download playwright-traces from the e2e job and open it with npx playwright show-trace. The trace has the DOM, network log, and screenshots at each step.
Adding A Test
Adding a unit test
- Add
foo.test.ts(or.tsx) next tofoo.ts. - Use the package's Vitest environment (
jsdominapps/web/packages/storage,nodeelsewhere). - Mirror nearby tests in the same package.
- To mock a cross-package boundary, follow Mocking Patterns.
- Run with
pnpm test:run(orpnpm test:coverageto check gates).
Adding an integration test
- Name it
*.integration.test.tsso the unit run skips it. - Import
createIntegrationHarnessfrompackages/api/test/integration.ts(or the helper in your package). - Clean up in
beforeEachandafterEach. - Mock cross-package boundaries you do not want live (abuse is the common one).
- Prepare the DB once:
pnpm db:migrate:deploy && pnpm db:seed:test. - Run with
pnpm test:integration.
Adding an E2E test
- Add
*.spec.tsunderapps/web/e2e/. - For authenticated flows, import
testfromhelpers/fixtures.ts(not@playwright/test) so you get worker-scopedstorageState. - For signup flows, use
uniqueSignupEmail()so parallel workers do not collide. - Run with
pnpm test:e2e(UI mode:pnpm test:e2e:ui). - If you need more than four isolated users, grow
TEST_WORKER_COUNT/TEST_USERSin@syntaxkit/shared/testingand re-seed.
Where To Go Next
Conventions
The testing-layer table this page expands on, plus kebab-case file naming for every test file.
API
Procedure-test patterns the integration suite exercises, plus the same mocking approach.
Also useful: Authentication, Security, and Going To Production.
Security
Five layers on every request: edge headers, Better Auth, oRPC middleware, Upstash abuse policy, and Zod. Most run by default. Upstash and Turnstile need env vars. Defaults, limits, and a pre-launch checklist.
Commands And Scripts
pnpm and Turborepo tasks for the monorepo. Most run from the repo root; a few live only in the package that owns them.
