Testing
The three suites, what each one can prove, and which to run for a given change.
Three suites, in ascending order of what they cost and what they prove. All three run in CI on every push and pull request, and none of them needs a credential.
| Command | Runs | Proves |
|---|---|---|
npm test | Vitest, plain Node | Pure logic: pricing, masking, cursors, schemas, access rules |
npm run test:sqlite | Vitest against a real SQLite file | Queries, transactions, keyset paging, upserts |
npm run test:e2e | Playwright against a built store | Rendered components and whole user journeys |
npm test has no DOM and no database in it. A green run there says the pure
functions agree with themselves — it cannot tell you a query orders rows
correctly or that a component mounts. The repository once had 44 test files,
all of them this kind, and shipped a guest checkout that crashed the moment it
rendered.
npm test — the unit suite
Plain Vitest in Node. No emulator, no network, no generated Prisma client, so it runs on a fresh clone in under a second.
This is where logic with a pure core belongs, and the convention is to give it
one: pricing, discount eligibility, phone masking, cursor encoding, order
totals, and the i18n dictionaries are all pure modules under src/lib with a
matching file in tests/.
Three suites here are worth knowing about because they fail on changes that look unrelated to them:
| Suite | Fails when |
|---|---|
docs-paths | The documentation names a repository path that no longer exists |
docs-snippets | A code block says it comes from a file, and the file no longer contains those lines |
docs-versions | The tech-stack table quotes a version that does not match package.json |
Renaming a file or editing a quoted function therefore means editing
content/docs in the same commit. That is deliberate: the docs had drifted
badly before these existed — a renamed middleware.ts, a rewritten seed
script, and a stock-decrement example still showing the loop that was replaced
precisely because it oversold.
npm run test:sqlite — the adapter against a real database
Generates the Prisma client for the SQLite schema, creates a database in a
temporary directory, and drives prismaCommerce against it — the same adapter
a Postgres deployment runs, over a different engine.
It is opt-in through RUN_SQLITE_INTEGRATION=1 (which the script sets), because
the default npm test must keep working with no generated client at all.
Use it for anything a fake client cannot honestly answer:
- Ordering and paging. A keyset cursor is only correct if the engine agrees.
The audit trail's test writes five entries fast enough that several share a
createdvalue to the millisecond, then pages through them — which is the only way to prove theidtiebreaker stops rows being skipped or repeated at a page boundary. - Filtering that must happen in the query. Paging an unfiltered listing and filtering the results loses rows silently. The category test seeds a mix, pages smaller than the match count, and asserts every match appears exactly once.
- Constraints doing real work. A newsletter signup twice, with different case and spacing, has to be one row with its original date.
- Transactions. The checkout webhook writes an order and the stock it consumes in one transaction keyed by the Stripe session id.
npm run test:e2e — a real browser against a real store
npm run test:e2e # provision → build → run
npm run e2e:only # re-run the specs against an already-built store
npm run e2e:report # open the HTML reportProvisioning resets a throwaway SQLite database, generates the client, pushes
the schema, seeds the catalog from the same catalog.json the demo backend
uses, and writes the fixtures the specs need. Then it builds and starts the
app and drives Chromium against it.
Sign-in is faked at the row level, and nothing downstream is. With
AUTH_PROVIDER=authjs the store offers Google OAuth and Resend magic links,
neither of which a credential-free run can complete. So provisioning writes the
rows a finished sign-in leaves behind — a users row and a sessions row with
a fixed token — and Playwright restores that cookie through storageState.
Everything after that is the real path: the session lookup, session.user.id
as the commerce uid, per-user carts, and the RPC route's ownership checks.
Writing a spec
Select by accessible role and name, not by CSS class:
await page.getByRole("button", { name: "Add to cart" }).click();
await expect(page.getByRole("listitem").filter({ hasText: name })).toHaveCount(1);Two reasons. Class names churn and roles do not, so the spec pins behaviour rather than markup. And a locator that cannot find an element by its accessible name has found a real accessibility bug — several of the ones fixed in this repo were discovered exactly that way.
Give a spec its own seeded account if it ends a session or writes a cart.
Playwright runs specs in parallel, so two tests sharing one account race:
whichever signs out first revokes the row the other is still holding, and the
loser fails on something unrelated to what it was testing. The accounts live in
e2e/env.ts.
Read back through e2e/support/db.ts to assert what actually landed in the
database, rather than trusting the UI's own success message. Use it for
verification, not for setting up state a test then asserts on — that tests the
fixture instead of the app. The one exception is state the browser genuinely
cannot create, like a cart written on another device.
Which to run
| You changed | Run |
|---|---|
| A pure function, a schema shape, an access rule | npm test |
| A query, a cursor, a transaction, a database column | npm test and npm run test:sqlite |
| Anything that renders | npm test and npm run test:e2e |
| An adapter method | all three |