RemitWise uses a multi-runner test setup. This is intentional — each runner is the
right tool for a different kind of test — but it can be confusing on first contact. This
guide explains which runner to use when, maps every package.json test script to what
it runs, describes the tests/ layout, states the coverage and gate expectations, and
gives a copy-paste recipe for adding a test with each runner.
New here? Read CONTRIBUTING.md first for branch naming and PR expectations, then come back here when you need to add or run tests.
- TL;DR
- The runners
- Script map
- Directory layout
- Which suites need a server or database
- Running a single file or test
- Coverage
- The CI gate
- How to add a test
npm run test # unit suites (node:test + Vitest) — the fast inner loop
npm run test:coverage # full Vitest run with a coverage report
npm run test:property # property-based (fast-check) tests
npm run test:integration# integration suites (node:test + Vitest)
npm run test:e2e # Playwright end-to-end (auto-starts the dev server)
npm run lint # ESLint
npm run build # Next.js build — also type-checks (tsc)| I want to test… | Use | Lives in |
|---|---|---|
| A TypeScript function / hook / component | Vitest (.test.ts / .test.tsx) |
tests/unit/, lib/**, components/** |
| A Node-native module (crypto, raw middleware) | node:test (.test.cjs) |
tests/unit/ |
| A correctness property over many random inputs | Vitest + fast-check | tests/property/ |
| An API route / error contract end to end | node:test or Vitest | tests/integration/ |
| A real user flow in a browser | Playwright | tests/e2e/ |
The repo uses three test runners. Pick by what you are testing, not by preference.
Configured in vitest.config.mts. Use Vitest when you want:
- TypeScript with no manual compile step,
- the
expectmatcher API and@testing-library/jest-domassertions (loaded by vitest.setup.ts), vi.mock/vi.fnmocking,- a
jsdomenvironment (the config setsenvironment: 'jsdom', so component and browser-ish code works).
Vitest discovers any file matching the include globs in vitest.config.mts
(tests/unit/**, tests/property/**, tests/integration/**, tests/session/**,
lib/**/*.test.ts(x), components/**/*.test.tsx). That is why
npm run test:coverage sweeps far more files than the single file named in
test:unit:vitest.
Example: tests/unit/validation/savings-goals.test.ts
Some suites run on Node's built-in test runner (node --test) with node:assert. They
are plain CommonJS (.cjs), need no transform, and no browser environment. Use node:test
when the thing under test is Node-native — webhook signature verification (crypto), raw
middleware logic, the LRU contract cache — or when an integration test spins up real HTTP
calls without a bundler in the way.
The .cjs unit suites transpile the TypeScript source they import on the fly via the
typescript package (see the loadVerifyModule helper in the webhook test). If
typescript is not installed, those suites skip rather than fail.
Example: tests/unit/webhooks-verify.test.cjs
Configured in playwright.config.ts. Runs real chromium
against the app. The config's webServer block starts npm run dev automatically and
waits for http://localhost:3000, injecting test env vars (DATABASE_URL,
SESSION_PASSWORD, etc.), so you do not have to start the server yourself. Tests live in
tests/e2e/ (matched by testDir) and are named *.spec.ts.
Example: tests/e2e/health.spec.ts
Every script below is defined in package.json. The "Runner" column says which engine actually executes, and "When to use it" says when to reach for it.
| Script | What it runs | Runner | When to use it |
|---|---|---|---|
npm test |
Alias for test:unit |
node:test + Vitest | The default fast check before pushing. |
npm run test:unit |
Runs test:unit:node then test:unit:vitest |
node:test + Vitest | Inner loop for unit changes. |
npm run test:unit:node |
node --test over webhooks-verify.test.cjs, middleware.test.cjs, contract-cache.test.cjs |
node:test | You changed a Node-native module (crypto, middleware, cache). |
npm run test:unit:vitest |
vitest run tests/unit/validation/savings-goals.test.ts |
Vitest | You changed the savings-goals validators (or copy it for a new file). |
npm run test:property |
vitest run tests/property |
Vitest + fast-check | You changed validation/business logic and want random-input coverage. |
npm run test:integration |
node --test over auth/health/validation .cjs suites, then vitest run the goals API TS suite |
node:test + Vitest | You changed an API route, error contract, or auth/health behavior. |
npm run test:coverage |
vitest run --coverage — all Vitest-discovered files (see globs above) |
Vitest | Before a PR, to see the full suite + coverage report. |
npm run test:watch |
vitest in watch mode |
Vitest | Iterating on a Vitest spec. |
npm run test:ui |
vitest --ui |
Vitest | Browsing results in the Vitest UI. |
npm run test:e2e |
playwright test |
Playwright | You changed a user-facing flow or want a full-stack smoke test. |
npm run lint |
eslint . |
ESLint | Always, before pushing. |
npm run build |
next build (runs tsc as part of the build) |
Next.js / tsc | To catch type errors — this is the type-check gate. |
Note:
npm run test:unit:vitestdeliberately targets a single file, whilenpm run test:coverageruns the whole Vitest include set. If you add a new Vitest spec under a covered path,test:coveragewill pick it up automatically; to run it in isolation, pass the path explicitly (see below).
tests/
├── unit/ # Fast, isolated tests
│ ├── *.test.ts # → Vitest (TypeScript units, hooks, stores)
│ ├── *.test.cjs # → node:test (Node-native modules)
│ └── validation/, goals/, split/, anchor/, hooks/ # grouped by feature
├── property/ # Property-based tests (Vitest + fast-check)
│ └── *.test.ts
├── integration/ # API-route / contract tests
│ ├── *.test.cjs # → node:test (auth, health, validation, split)
│ ├── api/*.test.ts # → Vitest (e.g. goals-validation)
│ ├── helpers.cjs # shared request/response helpers
│ └── setup.cjs # integration bootstrap
├── session/ # Session/auth lifecycle tests (Vitest)
│ └── *.test.ts
└── e2e/ # Playwright browser specs
└── *.spec.ts
Where does my new test go?
- Testing a TypeScript function, hook, store, or component in isolation →
tests/unit/<feature>/<name>.test.ts(x)(Vitest). Component tests can also sit next to the component ascomponents/**/*.test.tsx. - Testing a Node-native module (no JSX, no TS-only types needed) →
tests/unit/<name>.test.cjs(node:test), and add the path to thetest:unit:nodescript. - Verifying a correctness property over generated inputs →
tests/property/(Vitest + fast-check). - Exercising an API route, error shape, or auth/health behavior →
tests/integration/(.cjsfor node:test, orapi/*.test.tsfor Vitest). - Driving a real browser flow →
tests/e2e/<flow>.spec.ts(Playwright).
| Suite | Needs a running dev server? | Needs a database? |
|---|---|---|
| Vitest unit / property | No | No |
node:test unit (.cjs) |
No | No |
Integration (tests/integration) |
No — handlers/HTTP are exercised in-process | Yes — DATABASE_URL must point at a real (SQLite) DB; the DB layer is not mocked |
| Playwright e2e | Auto-started by the config's webServer (you don't start it manually) |
Yes — the config sets DATABASE_URL=file:./ci.db and SESSION_PASSWORD for the spawned server |
A minimal local env (also described in CONTRIBUTING.md) is a .env
with DATABASE_URL="file:./dev.db" and a SESSION_PASSWORD of at least 32 characters.
Apply migrations once with npx prisma migrate dev before running integration/e2e suites.
# Vitest — one file
npx vitest run tests/unit/validation/savings-goals.test.ts
# Vitest — only tests whose name matches a pattern
npx vitest run -t "rejects zero"
# node:test — one .cjs file
node --test tests/unit/middleware.test.cjs
# Playwright — one spec, or one test by line
npx playwright test tests/e2e/health.spec.ts
npx playwright test tests/e2e/send-flow.spec.ts:31
# Playwright — see the tests without running them
npx playwright test --listnpm run test:coverageThis runs vitest run --coverage over every file the Vitest config discovers and
prints a text summary to the terminal. Per
vitest.config.mts, coverage uses the v8 provider and emits
text, json, and html reporters. The HTML report is written to the default Vitest
location, coverage/ (git-ignored) — open coverage/index.html in a browser for the
line-by-line view.
Coverage is measured over the app's source — lib/contracts/**, app/**/*.ts,
lib/**/*.ts, and components/**/*.tsx — with the test files themselves and everything
under tests/** excluded (see the coverage.include / coverage.exclude lists in the
config).
Coverage expectation: new code should ship with tests, and a PR should not lower overall coverage. There is no hard numeric threshold enforced in the config today, so the practical bar is: cover the logic you add or change — happy path plus the error/edge cases — using the runner that matches the file type. Reviewers look at the coverage diff, not just the headline percentage.
Before opening a PR, run the same checks CI runs (see .github/workflows/ci.yml and .github/workflows/e2e.yml):
npm run lint # ESLint must pass
npx tsc --noEmit # type-check (npm run build runs this as part of next build)
npm run test # unit suites
npm run test:integration# integration suites (needs DATABASE_URL)
npm run test:e2e # Playwright (CI installs chromium first)For local development the fast loop is npm run lint && npx tsc --noEmit && npm run test;
run npm run test:coverage, test:integration, and test:e2e before pushing anything
that touches API routes, validation, or user flows.
A minimal recipe per runner. Copy the matching existing example, then adapt.
- Create
tests/unit/<feature>/<name>.test.ts. - Import from
vitestand use path alias@/for app modules.
import { describe, it, expect } from 'vitest';
import { validateAmount } from '@/lib/validation/savings-goals';
describe('validateAmount', () => {
it('rejects zero', () => {
const result = validateAmount(0);
expect(result.isValid).toBe(false);
expect(result.error).toBe('goal_amount_positive');
});
});- Run it:
npx vitest run tests/unit/<feature>/<name>.test.ts. It is automatically included innpm run test:coverage(it matchestests/unit/**).
Component tests work the same way with .test.tsx (jsdom + @testing-library/jest-dom
are already set up). Model: tests/unit/validation/savings-goals.test.ts.
- Create
tests/unit/<name>.test.cjsusingnode:test+node:assert/strict.
const test = require('node:test');
const assert = require('node:assert/strict');
test('adds CORS headers for an allowed origin', () => {
// require/exercise the Node-native module here
assert.equal(1 + 1, 2);
});- Register it by adding the path to the
test:unit:nodescript inpackage.json(the runner only runs the files listed there):
- Run it:
node --test tests/unit/<name>.test.cjs(ornpm run test:unit:node). Model: tests/unit/middleware.test.cjs.
- Create
tests/property/<name>.test.ts.
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import { validateAmount } from '@/lib/validation/savings-goals';
describe('validateAmount properties', () => {
it('rejects every non-positive number', () => {
fc.assert(
fc.property(fc.integer({ max: 0 }), (n) => {
expect(validateAmount(n).isValid).toBe(false);
}),
);
});
});- Run it:
npm run test:property(or scope withnpx vitest run tests/property/<name>.test.ts). Model: tests/property/validation-properties.test.ts.
Choose the runner by file type:
- node:test (
.cjs) for raw request/response or HTTP behavior — put it intests/integration/<name>.test.cjs, reusetests/integration/helpers.cjs, and add the path to thetest:integrationscript. - Vitest (
.test.ts) for TypeScript route/error-contract assertions — put it intests/integration/api/<name>.test.ts(already covered by thetests/integration/**Vitest globs and thetest:integrationscript'svitest runsegment).
import { describe, it, expect } from 'vitest';
import { createValidationError } from '@/lib/errors/api-errors';
describe('createValidationError', () => {
it('returns 400 with an error body', async () => {
const res = createValidationError('Invalid input', 'Amount must be positive');
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: 'Invalid input' });
});
});Run it: npm run test:integration (set DATABASE_URL first).
Model: tests/integration/api/goals-validation.test.ts.
See also docs/TESTING_INTEGRATION.md for the in-process
route-handler approach.
- Create
tests/e2e/<flow>.spec.ts.
import { test, expect } from '@playwright/test';
test('health endpoint reports status', async ({ request }) => {
const res = await request.get('/api/health');
expect([200, 503]).toContain(res.status());
expect(await res.json()).toHaveProperty('status');
});- Run it:
npm run test:e2e(the config starts the dev server for you) ornpx playwright test tests/e2e/<flow>.spec.ts. Model: tests/e2e/send-flow.spec.ts.
Open a GitHub discussion or join the RemitWise Discord.