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.
| suite | glob | notes |
|---|---|---|
| unit | src/**/*.test.ts(x) | no database |
| integration | src/**/*.integration.test.ts | real Postgres, fileParallelism: false |
| e2e | e2e/*.spec.ts | Playwright 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_fixturesbreaks the suite.e2e/**overrides theno-empty-patternlint 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.tsmatched 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.tsThe 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.
Background jobs
Defining, enqueueing and scheduling jobs — defineJob, the typed registry, cron triggers, step.run idempotency, and where withSystem is sanctioned. Use when work should not happen in the request, when adding a cron, or when a job retries strangely.
Fixing lq check
What each lq check failure means and the recipe that fixes it — every custom lint rule, the boundaries graph, the structure checks, and the type errors that encode conventions. Use whenever lq check fails and the fix is not immediately obvious.