-
Notifications
You must be signed in to change notification settings - Fork 0
Testing
bun test. N workers, N real Postgres databases, sealed network. No mocking the database, no rollback hacks, no shared-state flakes.
Tests live next to their source as <file>.test.ts.
bun test --workers 8
worker 0 → myapp_test_0 (CREATE DATABASE myapp_test_0 TEMPLATE myapp_test_tpl)
worker 1 → myapp_test_1
...
| Step | Mechanism |
|---|---|
| Once per run | build a template DB: migrate + seed → myapp_test_tpl
|
| Per worker |
CREATE DATABASE myapp_test_N TEMPLATE myapp_test_tpl — Postgres file-copies, typically 100–400ms |
| Per test file | truncate the tables that file touched, or take a savepoint if it declares readonly
|
| Teardown | drop on exit; --keep-db to inspect a failure |
| Common approach | Why rejected |
|---|---|
| Wrap each test in a transaction and roll back | breaks anything that commits: the outbox, LISTEN/NOTIFY, logical replication, real isolation levels, nested transactions, and every job test. You end up testing a code path production never runs |
| One shared test DB with serial tests | slow, and the first flake teaches everyone to re-run instead of read |
| One shared DB with parallel tests | shared-state flakes. The failures are order-dependent, unreproducible, and eventually the suite is ignored |
| Mock the database | tests pass, SQL is wrong. The main thing worth testing is the query |
Real databases, truly parallel, is the only combination that is both fast and honest.
Any test that can pass twice and fail the third time is worse than no test — it trains people to ignore red.
| Control | Behavior |
|---|---|
| Seeds |
seed(name) builds a named, deterministic fixture graph via entity factories. Same input → identical rows, identical UUIDs |
| Frozen clock | time starts at a fixed instant. clock.advance('3d') moves it, and it also drives step.sleep and cron in tests |
| Seeded RNG |
Math.random, crypto.randomUUID, and Bun's RNG are seeded per test file from its path — reproducible, distinct across files |
| Sealed network | any egress not explicitly mocked fails the test with X_TEST_NETWORK_EGRESS, naming the URL and the fix |
| Fixed timezone + locale |
UTC and en-US unless a test declares otherwise; a tz-dependent bug fails deterministically |
| Ordered concurrency | job workers in tests run deterministically; runJobs() drains the queue synchronously |
Sealed network is the highest-value rule: it converts "the suite is slow and occasionally fails" into "you forgot to mock Stripe, here is the line".
X_TEST_NETWORK_EGRESS: unmocked network call
cause: POST https://api.stripe.com/v1/charges from app/billing/service.ts:42
fix: x test mock https://api.stripe.com/v1/charges
Locale and zone are declared per test when the behavior under test depends on them — see Timezones and dates and I18n.
| Type | Command | Asserts | Runs against |
|---|---|---|---|
| unit | x test unit |
pure logic — services, money, policy predicates, matchers | no DB, no I/O |
| contract | x test contract |
an action's input/output schema, its policy denials, its emitted OpenAPI + MCP tool shape | cloned DB |
| live | x test live |
a live query's initial snapshot, incremental patches on write, reconnect delta, and that a policy-failing row is never delivered | cloned DB + in-process replicator + in-process NATS |
| job | x test job |
step-level replay (a step already run is not re-run), idempotency-key dedupe, retry/backoff, concurrency and rate limits, outbox atomicity on rollback | cloned DB + frozen clock |
| e2e | x test e2e |
real browser against the built output: render mode behavior, streaming holes filling, hydration timing, SW install + offline fallback, version-skew reload | built app + cloned DB |
| eval | x test eval |
prompt quality vs. a baseline: exact, schema, rubric (judge), or regression tolerance | pinned models, recorded fixtures |
Each type is a first-class runner with its own fixture shape — not a naming convention on top of one runner. Every command supports --json.
An eval declares its cases beside the prompt (<name>.evals.ts) and gates on the drop from a
committed baseline, never on an absolute score — models drift, prompts should not.
| Rule | Code |
|---|---|
a prompt no defineEval names |
X_EVAL_MISSING |
the run mean or a case fell past tolerance
|
X_EVAL_THRESHOLD |
| the baseline was never recorded | X_EVAL_BASELINE_MISSING |
export const summarizeEval = defineEval({
name: 'summarize',
prompt: summarizePrompt,
cases: [{ name: 'refund', vars: { ticket: 'I want my money back' }, expected: 'billing' }],
scorers: [exact, jsonSchemaValid(['category', 'summary'])],
baseline: import.meta.resolve('./summarize.baseline.json'),
tolerance: 0.05,
});ULTIMATE_EVAL_RECORD=1 x test eval re-records every baseline, so accepting a new number is a
reviewable diff instead of an edited threshold. It is refused inside the gate: x verify with that
variable set is X_EVAL_RECORDING and runs no eval suite, because recording passes by definition
and would overwrite the committed baselines during the run.
// contract test — generated as a scaffold with the action
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
const { post, stranger } = await seed('two-orgs');
await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
.rejects.toBeUltimateError('X_FORBIDDEN');
});// job test — the step guarantee, not the happy path
test('onboardOrg retries only the failed step', async ({ seed, clock, mail }) => {
const { org } = await seed('fresh-org');
mail.failOnce(nudgeEmail);
await runJobs(onboardOrg, { orgId: org.id });
clock.advance('3d');
const trace = await runJobs.drain();
expect(trace.steps.provision.executions).toBe(1); // replayed from storage
expect(trace.steps['nudge'].executions).toBe(2); // only this one retried
});The job example is the one that matters: it asserts the durability guarantee, not that mail was sent. See Jobs and workflows and Policies and authz.
test passes a bag as the first argument and builds only what the body destructures — a test that never names runJobs never starts a queue. The framework owns the whole bag; an app registers only what the framework cannot know.
| Fixture | Is | Built by |
|---|---|---|
clock |
now() · advance('3d') · set(instant)
|
the preload |
mail |
outbox() · lastTo(address) · failOnce(mail)
|
the preload |
network |
offline() · drop() · online() · state()
|
the preload |
runJobs |
enqueue+drain, then drain() due() inFlight() depth()
|
the preload |
page |
goto · gotoStreamed · getByRole · evaluate · waitForServiceWorker
|
a browser driver |
budget |
jsBytes(route) off the built output |
a browser driver |
signIn |
put the browser session in a member's shoes | a browser driver |
deploy |
newBuild() — same app, new build id, page still open |
a browser driver |
subscribe |
one subscriber's rows() patches() settled() lsn()
|
a replicator |
seed, and anything else |
the app's own graph | the app's scripts/test-setup.ts
|
The last five are declared, not built. The framework does not bundle a browser, so the name resolves and asking for it without a driver fails as X_TEST_FIXTURE_UNAVAILABLE naming what is missing — different from X_TEST_FIXTURE_UNKNOWN, whose fix ("register it") would have the app inventing its own idea of what a page is. A driver installs through the same registry:
// scripts/test-setup.ts
defineFixtures({ page: () => openBrowserPage(), seed: () => loadSeed });defineFixtures merges and the last registration wins, so a driver replaces the declaration it was waiting on. There is no second seam.
network.offline() fails every request ahead of the mocks, so the app's own offline path runs instead of a branch written for the test; drop() is the same for a request but tells a subscriber its connection was cut rather than closed, which is what separates a resume from a resubscribe.
One row shape per entity, deterministic, with named variations. defineFactory is the only entry point — there is no bare factory().
import { associate, defineFactory } from '@ultimat3/testing';
const orgFactory = defineFactory(org, {
defaults: (index, ids) => ({ id: ids.uuid(), name: `org-${index}` }),
});
const postFactory = defineFactory(post, {
defaults: (index, ids) => ({ id: ids.uuid(), title: `post-${index}`, published: false, views: 0 }),
traits: {
published: { published: true },
popular: (index) => ({ views: index * 100 }),
},
associations: { orgId: associate(orgFactory, (row) => row.id) },
});| Member | Does |
|---|---|
build(over?) |
an in-memory row. Pure — no database, no persister |
buildMany(count, over?) |
the same, count times |
create(over?) |
builds, creates its association parents, then persists. Returns the row |
createMany(count, over?) |
the same, count times |
with(...traits) |
a view with those traits applied |
traits |
the declared trait names, sorted |
table |
the entity's table |
reset() |
restarts the sequence and both generators, and cascades to every association |
A trait is a Partial<Row> or a (index, ids) => Partial<Row>. Merge order is: defaults, then traits in the order applied, then the call's own over — last wins.
A view from with() shares the base sequence, so ids never repeat across views of one factory. An unknown trait throws X_TEST_FACTORY_TRAIT_UNKNOWN at the with() call, not three calls later, and cause lists every declared trait — a typo and a trait nobody added have the same symptom, and the list is what separates them.
A column whose value comes from another factory, built with the same strategy: build() builds the parent, create() creates it. Parents are created sequentially, never Promise.all — concurrent parents would interleave draws from the shared seeded generators and the ids would stop being reproducible. An association is skipped entirely when the caller supplies that column.
seed defaults to a hash of the table name, so every table has its own uuid stream while staying a pure function of the schema. As of 2026-08 that is the 1.1.0 fix: every registry factory used to default to seed: 1, so two tables minted the same uuid and a join assertion could pass for the wrong reason.
ids.uuid() and ids.number() come from the seeded generators; index starts at 1 for the first row.
factoriesFor(registry, seed?) derives a factory per entity with name-based column inference — id/*Id → uuid, *At → epoch, *Minor → number, *Currency → 'USD', is*/has* → false, else <column>-<index>.
create() needs a persister; there is one seam and it is process-global.
// scripts/test-setup.ts
usePersister({ insert: (table, row) => repoFor(table).insert(row) });Without one, create() throws X_TEST_FACTORY_NOT_PERSISTED — which means nothing was attempted, distinct from an insert that failed. build() needs no persister. clearPersister() and persisterInstalled() round out the seam.
One contract, asserted against many subjects, with the subject passed as a thunk so a beforeAll-built value is read at test time rather than at declaration time.
const anAuthenticatedAction = sharedExamples<Denier>('an authenticated action', (subject) => {
test('denies an anonymous actor', () => {
expect(subject().denies('anonymous')).toBe(true);
});
});
describe(testName('unit', 'publishPost'), () => {
behavesLike(anAuthenticatedAction, () => publishPost);
});behavesLike wraps the body in describe('behaves like <name>'), so a failure reads publishPost > behaves like an authenticated action > denies an anonymous actor — the subject and the contract both named. Because it calls describe, it belongs at declaration scope and never inside a test body. The body runs once per behavesLike call.
Every primitive emits a test scaffold that fails until filled in — an untested action is a red build, not a backlog item.
| Primitive | Scaffold |
|---|---|
action / mutator
|
schema round-trip + one denial case per policy branch |
query (live: true) |
snapshot + one incremental patch + one policy-filtered row |
job |
idempotency dedupe + one step-retry case |
route |
metadata presence, budget, and offline strategy |
llm prompt |
an evals file (missing evals fails x verify) |
The single gate. Green means shippable.
Seventeen steps, one list, in cost order. There is no --only and no --skip — "green" has to mean
the same thing for everyone. A step with nothing to check reports as skipped (-), never as passed.
The list is defined once, as VERIFY_STEP_NAMES in
packages/cli/src/verify-step.ts.
This table is a hand-synced copy of it (Contributing).
| Step | Fails on |
|---|---|
typecheck |
any error; any is banned by lint, not tolerated by a cast |
lint |
formatting, any, default exports, bare Error, raw hex colours, hardcoded user-facing strings, Intl date formatting with no timeZone
|
boundaries |
site/ → app/, routes → DB, services → HTTP, tier violations in framework packages |
filesize |
a source file over 500 lines |
package-shape |
a workspace package missing README.md, CLAUDE.md, tsconfig.json, or src/index.ts
|
errors |
an X_* code with no runnable fix or no docs page |
unit |
pure logic — services, money, policy predicates, matchers |
contract |
action/query schemas, policy denials, emitted OpenAPI and MCP shapes |
live |
live-query snapshot, incremental patches, reconnect delta, policy-filtered rows |
job |
step replay, idempotency dedupe, retry/backoff, concurrency, outbox atomicity |
e2e |
the built output under Playwright, offline and SW update included |
eval |
a prompt scoring below its committed baseline, or a prompt with no eval at all |
drift |
schema differs from migrations, or a migration is not reversible-or-marked |
contract-diff |
a breaking change to a published action/query without a version bump |
budgets |
per-route JS bytes and LCP |
manifest |
x.manifest.json / openapi.json differ from what the code produces, or AGENTS.md is missing or over its byte cap |
roadmap |
framework repo only — a milestone missing its status marker, or a shipped milestone missing an artifact its own row names |
Any failure fails the gate; flakes are failures.
$ x verify
✓ typecheck ✓ lint ✓ boundaries ✓ filesize ✓ package-shape ✓ errors
✓ unit ✓ contract ✓ live ✓ job ✓ e2e ✓ eval
✗ drift
X_DB_DRIFT: schema differs from migrations
cause: table "posts" has column "publish_at" not present in any migration
fix: x db gen "add publish_at"
x verify --json emits the same content machine-readably → MCP and AI. CI runs exactly x verify — no bespoke pipeline steps, because a check that lives only in CI is a check developers cannot run.
| Code | Cause | Fix |
|---|---|---|
X_TEST_NETWORK_EGRESS |
a test reached the network without a mock | x test mock <url> |
X_FORBIDDEN |
the actor's policy refused — the assertion target of every denial test |
x actions describe <action> --json names the capability it enforces: assert the denial with .rejects.toBeUltimateError('X_FORBIDDEN'), or grant that capability to the seeded actor's role in apps/web/shared/policies.ts
|
X_INVARIANT |
a domain invariant was violated inside a test fixture | fix the seed or the invariant |
X_CONFIG_INVALID |
test config names an unknown worker count, driver, or test type | x test --help |
Full list: Error codes.
- Never mock the database. Clone it.
- Never assert on wall-clock time. Advance the frozen clock.
- Never let a test reach the network unmocked — it fails by design.
- A flaky test is deleted or fixed the day it flakes. There is no
retry: 3. - Every framework package ships at least 2 tests that would catch a real regression. No
expect(true).toBe(true). - Tests live next to their source as
<file>.test.ts. - A denial test per policy branch, not one happy-path test per action.
- Assert on error codes, never on error message text.
- CI runs
x verifyand nothing else.
Ultimate — v1.1.0 As of 2026-08. Stable API, semver from here. MIT licensed.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
Cross-cutting
Reference