launch-quickly
Patterns

Testing

How to test in this project — the unit/integration/e2e split, the tenant-isolation test every feature needs, the signed-in Playwright fixture, and how to tell a real pass from a test that never checked anything. Use when adding tests or when a test passes and you are not sure it should have.

Generated from .claude/skills/testing/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.

Three suites, and it matters which one you are writing.

suiteglobnotes
unitsrc/**/*.test.ts(x)no database
integrationsrc/**/*.integration.test.tsreal Postgres, fileParallelism: false
e2ee2e/*.spec.tsPlaywright against a built app

pnpm test runs unit + integration. pnpm test:e2e runs Playwright. lq check runs neither — it typechecks, lints and checks structure, so both are needed before you claim a task is done.

Integration tests share one database, so files run serially. Parallel files would see each other's rows and turn isolation failures into flakes.

server-only is aliased to a stub in vitest.config.ts. The modules under test legitimately import it, so the guard stays real in the app while tests import the same files.

The tenant-isolation test

Every tenant-scoped feature gets one, and lq generate feature emits it. Its job is not to prove the queries work — it is to prove they cannot reach another tenant's rows, including when written wrong.

// A deliberately unscoped query must still return zero rows,
// because the Postgres policy refuses — not because the query filtered.
const rows = await withTenant(alpha.ctx, (tx) => tx.select().from(projects));
expect(rows.every((r) => r.organizationId === alpha.organizationId)).toBe(true);

Create two orgs with the fixture factory, write rows to each, then assert org A cannot see org B's. This is the highest-leverage test in a multi-tenant codebase and no competing starter ships it.

Assert your fixtures are real before asserting isolation. A test that passes because both orgs are empty proves nothing at all, and it will keep passing forever. The shipped test checks the row counts first for exactly this reason.

The signed-in Playwright fixture

import { test, expect } from './fixtures/authenticated';

test('the projects page renders', async ({ signedIn: page }) => {
  await page.goto('/projects');
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

signedIn seeds a session directly into Postgres and signs the cookie with Better Auth's own makeSignature, so no OAuth round-trip is needed.

Two details that will bite you:

  • The fixture signature is async ({}, use). Playwright requires the destructuring pattern; renaming it to _fixtures breaks the suite. e2e/** overrides the no-empty-pattern lint rule for this.
  • Asserting redirects is not the same as rendering a page. An earlier version of this suite only checked that signed-out visitors were bounced to /sign-in, which meant no test ever rendered an authenticated page — a missing provider took production down and every test stayed green. Render real pages.

Watch for the stale dev server

Playwright's reuseExistingServer will happily attach to a server left running on port 3100 from an earlier session, serving old code. If a test fails in a way that makes no sense against the code in front of you, check for one before debugging further.

The worse version, which costs an hour if you do not know it. Run pnpm test:e2e twice in quick succession and the second run attaches to the first one's server while it is still shutting down. The port answers, so Playwright proceeds; then 35 to 40 of 49 tests fail at once, across specs you never touched — landing page, marketing, waitlist — all with "element not found".

Learn the signature: a mass failure spanning unrelated specs is this, not your change. A real regression is narrow. Before believing a single one of those failures, check the port is free (lsof -ti:3100) and run it again.

Prove a test can fail

The habit that matters most here: after writing an assertion, reintroduce the defect it is supposed to catch and watch it go red. A test that has never failed has not been tested.

This is not theoretical caution. Every one of these happened in this repo:

  • A concurrency test passed with the advisory lock removed — the connection pool never interleaved, so it proved nothing. It is now labelled a smoke test, and a real one holds the lock in a competing transaction.
  • An OAuth callback probe returned a confident green for a randomly invented domain, because GitHub redirects to its login page before validating redirect_uri.
  • A CI drift check used git diff --exit-code, which ignores untracked files — precisely the case it existed to catch, since a generator only ever writes new ones.
  • A test/structure.test.ts matched no vitest project and was never run. Vitest reported every other file passing and said nothing about the one it dropped.

The shared shape: the check reported success while examining nothing. When a new test passes on the first run, be suspicious rather than pleased, and go make it fail on purpose.

What to test

  • an action's unauthorized path, not just its happy path
  • tenant isolation, with fixtures asserted real
  • the field-error path of a form, since that is where server and client agree
  • for a bug fix: a test that fails before the fix

lq check fails a feature that has mutations and no test at all.

A feature with pages ships an e2e

pnpm test does not run Playwright. That means a slice can be entirely green — types, lint, integration tests — with its whole UI untested, and that is not hypothetical: three products were built on this template by three independent agents and not one wrote a UI test, because the definition of done they were given never mentioned it.

So: lq generate feature writes a starter spec covering the walk a buyer does by hand (list renders, create works, the record reads back), lq check says so (advisorily) when a route has no spec touching it, and the rule is in AGENTS.md. Extend the starter with the rules your product actually has — state machines, permissions, the thing that would embarrass you if it broke.

pnpm test:e2e                    # all of it
pnpm test:e2e e2e/widgets.spec.ts

The fixture in e2e/fixtures/authenticated.ts seeds a session straight into Postgres — OAuth is a third party and its consent screen is not automatable, and the thing under test is your pages, not GitHub's login form. It also exports seedSecondUser() for flows that need two people.

On this page