| name | testing |
| description | vers testing conventions — the three regimes by package kind, the no-mock / no-branching / co-location rules, `bun test` process-wide lifecycle, isolation levels, factories and composites, jest-extended matchers, authorisation pairs, time and timestamp rules, forced infrastructure failures, golden values and determinism, observability harnesses, contract-schema idioms, and the MSW / RSC / Forms / real-database specifics. Load when designing, writing, or reviewing tests. |
Testing
bun test runs every file in one process with no per-file isolation — lean into it: lifecycle and
cleanup register once in a package's bunfig.toml preload and apply process-wide, and test files
contain no beforeAll/beforeEach/afterEach/afterAll. Three regimes cover the workspace, by
package kind:
- Pure packages — libs and CLIs with no service or database edge. Mock-free: pure modules assert
on return values, file-touching ones use
mkdtemp trees, and CLI behaviour is asserted end-to-end
by spawning the real binary — a module hard to test without mocking moves its I/O to the caller.
- MSW-mocked packages — clients of mocked services and app-web.
- Real-database packages — services, apps, and DB-backed libraries exercising a real postgres.
Principles
The rules below decide most situations; where they don't, these do:
- Clarity over abstraction: repetition in a test isn't a smell, hidden setup is.
- Isolation is non-negotiable: every test passes alone and in any order.
- Test behaviour, not implementation: a refactor that preserves the observable contract — renaming a
private helper, restructuring a loop — breaks no test.
- Every mock is a divergence from reality: mock only what is genuinely out of reach, and keep it
high-fidelity — correct codes, realistic shapes, shared types.
- Test utilities are production code: anything a test file would grow beyond a local
setupTest()
moves to test-utils/ with its own test, or it doesn't exist — an untested helper inside a test
file can be wrong in a way no test reports.
- Assertions are the contract: one loose assertion makes the rest of the test theatre.
Everywhere
-
Never use describe — write flat test(…) blocks with behavioural titles that start with "it"
(test('it pads before a return statement', …)).
-
A test body arranges, acts, asserts — phases separated by blank lines, never // arrange
comments; the act is usually a single call. A body with two act-assert pairs is two tests. A
pure-function test may collapse all three onto one expression.
-
test.each is sanctioned only for a closed decision table — data-only rows and a title template
that starts with "it" and interpolates the distinguishing input
(test.each(rows)('it picks %s when the action is %s', …)). Anything else is one test() per
case.
-
A loop over a module's own exports proving registry completeness (Object.keys(generators)) is
sanctioned iteration, not test branching — but the loop must not re-implement the transformation
it checks.
-
An assertion inside a callback the unit may never invoke passes vacuously when the callback is
skipped — capture into a const outside the scope and assert after the callback returns.
-
Test files are co-located with the module they test (parse-source.ts beside
parse-source.test.ts) — no test/, tests/ or __tests__ directories. Declaration emit
excludes *.test.ts, so they never ship.
-
A test whose unit consumes a domain object or DTO takes it from the package's faker-defaulted
create-mock-* factory in test-utils/factories/ (each factory has its own test), overriding
only the fields the unit reads. Extraction follows where the type lives, not how many tests use
it: a type that crosses module boundaries gets its factory immediately; a type local to the module
under test stays an inline literal. Defaults are faker-dynamic where the value is arbitrary —
proving the unit doesn't depend on specific data — and static only for a constrained field (an
enum, a discriminator) or throughout a deterministic engine package, where a faker value would
churn every golden snapshot. The factory's own test pair keeps fixed titles —
it builds a default X asserting the whole shape with toStrictEqual plus asymmetric matchers,
and it applies overrides on top of the defaults; it may additionally round-trip the contract
schema, never instead. A factory defaults every foreign key to a freshly generated id
(createId()), never a real parent's — parent wiring is a composite's job. A row factory returns
the table's ; a DTO factory returns the contract type — distinct artefacts in the
packages that own each shape.
MSW-mocked packages
-
MSW mocks the external HTTP/service boundary — never internal abstractions. One shared server
(setupServer()) per package in mocks/node.ts, its lifecycle wired by
registerMSWLifecycle(server) (@vers/test-utils/bun) in the preload with
onUnhandledRequest: 'error'. For oRPC procedures, per-test handlers are built with
buildMockService / mockService (@vers/client-test-utils/orpc).
-
A per-test server.use(...) handler models a deviation — an error code, a transport failure, a
scripted call sequence — or captures inputs for assertion. Everything the stateful handlers can
model comes from shaping the store, not an override: a missing row is already a not-found from the
default handler. Happy-path behaviour comes from the service's stateful mock handlers over the
@msw/data store (build<Service>MockHandlers, @vers/mock-services), driven by seeding its
collections; a handler that re-implements service logic inline is a defect.
-
Inputs are captured with an inline mock() the per-test handler feeds, asserted through the mock
matchers. A side-effect endpoint with no @msw/data backing that several tests inspect — an email
send, a webhook — instead exports a stateful store from its handler module (sentEmails), swept
by the preload; the store, the URL constant, and the resolver are separate exports so a per-test
deviation can wrap or replace them.
const track = mock<(input: unknown) => void>();
server.use(
mockActivityService.getActivityRewards.handler((opts) => {
track(opts.input);
return { items: [], verifiedHead: 2 };
}),
);
expect(track).toHaveBeenCalledExactlyOnceWith({ activityID: activity.id });
-
Stateful backends use @msw/data: an in-memory store built from a zod schema
(new Collection({ schema }), .create()/.createMany(), .findFirst()/.findMany(),
.defineRelations()) read and written directly from the oRPC mock handlers — never @msw/data's
factory() model dictionary. Every row-schema field carries a .default() — faker-driven where
the value is arbitrary — except a discriminator whose value gives a row its meaning; the preload
seeds faker once so runs are reproducible, and a call never restates a default.
RSC and server functions
-
Server functions are thin ambient shells: they read request context (getRequestHeaders, cookies)
and load data, then delegate to a pure component or handler taking that data as explicit
props/args. A unit that needs ambient server context in a test has its ambient read in the wrong
place — move the read up to the shell.
-
A function that returns React elements is a component: write it as one and test it by rendering.
Pure server components render under RTL + happy-dom like any component — render per state, assert
visible behaviour.
-
Server-fn bodies are named exported handlers that createServerFn wraps, so tests call the body
directly.
-
An uncompiled createServerFn dispatch relays only a Response or a thrown redirect/error to its
caller; a plain result object resolves as undefined. Component tests cover the branches that
round-trip that way — plain-object branches are asserted at the handler layer.
-
The Flight pipeline (renderServerComponent, composite components) and ambient reads cannot run
under bun test (one module graph, no react-server export condition); their coverage is the
real-runtime smoke suite.
-
Ambient request context (@tanstack/react-start/server) is stubbed only through the shared
withRequestContext util, installed once in the preload behind a mutable holder — never Start's
RSC/render APIs. Every stubbed ambient path is also crossed by the smoke suite. A test wraps the
render and its assertions in the callback; the call is awaited for its { cookies, value }
outcome, or deliberately left un-awaited so a rejection can be asserted on it.
const signedIn = await createSignedInUser();
await withRequestContext({ cookies: signedIn.cookies }, async () => {
const rendered = renderWithRouter(<AccountScreen />);
await expect(rendered.findByText(signedIn.username)).resolves.toBeInTheDocument();
});
-
A thrown router redirect is asserted directly —
expect(promise).rejects.toMatchObject({ options: { href: '/login' } }) — and the no-redirect
branch asserts the resolved value. Never a .catch(isRedirect) ternary, never a sentinel return,
never an instanceof throw-guard where invariant narrows.
Forms (Conform)
- A form island drives a Conform form through the shared
useFormSubmit hook (lib/forms/): pass
the form's server function and it dispatches the FormData, returning lastResult for useForm,
an in-flight flag, and the submit handler. The handler runs parseWithZod(formData) and returns
submission.reply(); the honeypot check stays a server-side helper. Validation imports from
@conform-to/zod/v4.
- The hook also takes an optional seed
lastResult, which an island forwards from props beside the
action. Cover a form's result→UI mapping by rendering with a hand-built
submission.reply()-shaped lastResult and asserting the errors — a form-level message under the
empty-string key, a field message under the field name. This reaches every branch with no submit
and no server. Inject an action to drive pending state and the Response fallback.
Real-database packages
service-avatar is the reference example.
-
Production service factory. A service exposes one create<Service>Service({ db? }) in src/,
owning its createService config; the production entrypoint and every test call that same
factory. db is injected only in tests, for transaction isolation — never clone the
createService config into tests.
-
Single-statement atomicity is the default. A conditional UPDATE/DELETE ... RETURNING,
INSERT ... ON CONFLICT, or a data-modifying CTE claims a single-row invariant and survives a
serverless process kill with no orphaned transaction state. Reach for an interactive
db.transaction() only for a genuine multi-row invariant that doesn't reduce to one statement —
and give that handler's suite schema isolation, since the default transaction-isolation handle
cannot nest.
-
Isolation strategy. Acquire the database through @vers/service-test-utils/bun:
createTestDB() returns an await using handle over one of three isolation levels. transaction
(rollback on dispose) is the default. schema (a real, committed clone of public in its own
schema on a shared database) is the opt-out for code that commits mid-op or continues after a
caught constraint violation, cases where a rolled-back transaction can't nest and an aborted
statement poisons the rest of a shared test transaction. database (a real, committed clone
database) is reserved for database-scoped state (advisory locks, LISTEN/NOTIFY), DDL and migration
exercises, and structures LIKE can't reproduce (partitioned parents). Inject the handle's db
into the code under test — code that opens its own connection bypasses the isolation. A suite that
opts out of transaction carries a comment naming the code path that forces it ("createChain
opens its own interactive transaction, which the default handle can't nest").
-
Test setup. A local setupTest() per suite — typed config in, named props out, no if —
builds the db and boots the service. It may seed what the service needs to boot (a sim version, a
content document); scenario data — anything a test asserts on — stays in the test body. Never
centralise it: a shared setupTest accretes conditionals as services multiply. Multiple
await using handles tear down last-in-first-out, so a resource declared after the db closes
before the db drops.
Observability
- A counter's suite is two tests: record it two or three times with distinct attributes and read the
points back through
createInMemoryMetrics() (@vers/test-utils/bun) — never a hand-rolled
MeterProvider — then the fixed-title it stays inert without a registered meter provider
asserting the record call doesn't throw.
- Spans are captured with an in-memory harness:
InMemorySpanExporter behind a
NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }), registered in the
test and torn down in onTestFinished as trace.disable() then await provider.shutdown() —
that order, or the global registration leaks process-wide.
- Error reporting is asserted against the real Sentry SDK: a well-formed fake DSN,
disableDefaultIntegrations: true, and a beforeSend that records the event and returns null
so nothing egresses; waitFor the recorder before asserting.
- A log line is asserted through an injected write-capture stream —
createLogger({ level, stream: { write: (line) => lines.push(line) } }), JSON.parse the line,
toMatchObject — always paired with a below-level test asserting toBeEmpty(). Never a spy on
the logger.
- An OTLP exporter's success path runs against a loopback receiver:
Bun.serve({ port: 0 })
capturing requests, the endpoint injected with updateEnv, the server stopped in
onTestFinished; assert path, headers, and a non-empty body.
Contracts and scripts
- A contract module's suite is a triad: each procedure's
errorMap keys via
toContainAllKeys/toContainKey, an explicit status assertion per bespoke code, and a closing
OpenAPI-generation test through new OpenAPIGenerator({ schemaConverters: […] }).
- A schema rejection asserts the issue path with one matcher shape —
expect(result.error?.issues).toPartiallyContain(expect.objectContaining({ path: ['field'] })) —
never positional issues[0] (couples the test to issue ordering) and never code in place of
path.
- An accept test reads
result.data — expect(Schema.parse(payload)).toStrictEqual(payload) where
the schema passes values through, explicit expected values where it transforms. A bare
success: true passes for a schema that strips, coerces, or defaults wrongly.
- A rejection payload restates the full valid literal with one field changed — the deliberate
consequence of banning module-level fixtures, not duplication to clean up.
- A collection scanner's suite carries an explicit negative-scope test naming what it never touches
(
it never touches other machines' databases) — for sweep logic that drops resources, the test
that matters most.