Skip to content
Operate And Ship

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 read

On This Page

The Test Pyramid

Pick the layer that matches what you need to prove. Each layer has its own command and file pattern.

LayerToolCommandFile patternTouches
UnitVitestpnpm test:run (or pnpm test:coverage)*.test.ts(x) next to the sourceMocked deps. jsdom in apps/web and packages/storage; node elsewhere
IntegrationVitestpnpm test:integration*.integration.test.tsReal Postgres, real Prisma, Moto-mocked S3
Live StripeVitestRUN_STRIPE_LIVE=1 pnpm test:stripe*.live.test.ts, *.live.integration.test.tsReal Stripe test-mode API
E2EPlaywrightpnpm test:e2eapps/web/e2e/*.spec.tsChromium, 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:stripe

The 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 / exportConsumers
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_USERSpackages/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.

SettingWhy
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AACloudflare's official always-pass test secret.
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000BBMatching always-pass site key.
DISABLE_CAPTCHA_FOR_TESTS=true / NEXT_PUBLIC_DISABLE_CAPTCHA_FOR_TESTS=trueServer captcha checks and the client Turnstile widget are bypassed so Playwright never waits on Cloudflare.
DISABLE_ABUSE_PROTECTION=trueUpstash-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=noopsendEmail 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=0Stripe live suite stays opt-in.
SYNTAXKIT_E2E=trueAllows 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:

  1. changes decides which areas moved (web, docs, setup, …).
  2. setup installs the workspace and caches node_modules, the generated Prisma client, and built package artifacts under a SHA key. Later jobs restore that cache.
  3. static-checks runs pnpm lint, pnpm check-types, pnpm db:validate, and build-arg sync in one job.
  4. build-test builds docs when needed and runs pnpm test:coverage (unit + per-package gates).
  5. setup-smoke runs only when setup-related files change.
  6. integration boots Postgres 17 and Moto S3, migrates, seeds, then runs pnpm test:integration.
  7. e2e boots Postgres 17, installs Chromium, migrates, seeds, builds the app, then runs pnpm test:e2e:run. Uploads the Playwright HTML report (7-day retention) and traces on failure (3-day retention).
  8. ci-gate fails 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
  1. Add foo.test.ts (or .tsx) next to foo.ts.
  2. Use the package's Vitest environment (jsdom in apps/web / packages/storage, node elsewhere).
  3. Mirror nearby tests in the same package.
  4. To mock a cross-package boundary, follow Mocking Patterns.
  5. Run with pnpm test:run (or pnpm test:coverage to check gates).
Adding an integration test
  1. Name it *.integration.test.ts so the unit run skips it.
  2. Import createIntegrationHarness from packages/api/test/integration.ts (or the helper in your package).
  3. Clean up in beforeEach and afterEach.
  4. Mock cross-package boundaries you do not want live (abuse is the common one).
  5. Prepare the DB once: pnpm db:migrate:deploy && pnpm db:seed:test.
  6. Run with pnpm test:integration.
Adding an E2E test
  1. Add *.spec.ts under apps/web/e2e/.
  2. For authenticated flows, import test from helpers/fixtures.ts (not @playwright/test) so you get worker-scoped storageState.
  3. For signup flows, use uniqueSignupEmail() so parallel workers do not collide.
  4. Run with pnpm test:e2e (UI mode: pnpm test:e2e:ui).
  5. If you need more than four isolated users, grow TEST_WORKER_COUNT / TEST_USERS in @syntaxkit/shared/testing and re-seed.

Where To Go Next

Also useful: Authentication, Security, and Going To Production.

Was this page helpful?

On this page