# Testing

> Voltro's test story — the @voltro/testing package, the test pyramid (unit handlers/tools → workflow runner → e2e), and the voltro test / voltro e2e CLI commands.



---

<!-- source: en/testing/overview.md -->
## Overview

_Voltro's test story — the @voltro/testing package, the test pyramid (unit handlers/tools → workflow runner → e2e), and the voltro test / voltro e2e CLI commands._

## The package

Test utilities live in **`@voltro/testing`** — a separate, public package. It is **not** bundled into a scaffolded project, so the moment you write your first test, add it (and `vitest`) as a **devDependency** of the app you're testing:

```bash
pnpm --filter @my-app/api add -D @voltro/testing vitest
```

Everything in it is in-memory and synchronous-friendly — no docker, no running server, no real model calls. A handler test boots in milliseconds.

| Export | Use |
|---|---|
| `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `webhooks`, `llm`, optional `ai`. → [Unit testing](/docs/testing/unit-testing) |
| `mockStore(seed)` | Seed rows: `makeTestContext({ store: mockStore({ todos: [...] }) })`. |
| `user(id, opts?)` / `apiKey` / `serviceAccount` / `anonymous` / `system` | Subject factories — the acting identity, instead of a hand-written `{ type: 'user', … }` literal. |
| `MockClock` / `MockLLM` | Deterministic clock, queued LLM responses. |
| `invoke(descriptor, handler, input, ctx)` | Run a procedure the way the dispatcher does — guards, decode, transaction, plugin interceptors. → [Unit testing](/docs/testing/unit-testing) |
| `makeTestApp({ ctx, restRoutes, publicApi, strategies })` | The request-level harness — a real HTTP request through the real REST pipeline. → [Unit testing](/docs/testing/unit-testing#request-level-testing-maketestapp) |
| `fixtureRow(table, over?)` / `defineFactory(table, opts?)` | Complete a required-column row; a named factory with defaults, traits and associations. |
| `makeVoltroTestClient(config)` | The frontend harness — render a component against mocked `useSubscription` / `useMutation`, from the `@voltro/testing/client` subpath. → [Component testing](/docs/testing/component-testing) |
| `makeWorkflowRunner({ ctx, workflows })` | Drive a workflow in-process and assert on its steps. → [Workflows](/docs/testing/workflows) |
| `runDialectParity(fixture)` | Run one fixture across a SQL dialect for portability — from the `@voltro/testing/dialect` subpath. → [Dialect parity](/docs/testing/dialect-parity) |

## The test pyramid

Voltro's primitives are designed so the cheapest test covers the most surface. Reach for the lowest layer that proves what you need:

1. **Unit — handlers, tools, mutations, queries.** Call the executor directly with a `makeTestContext()` ctx. The store is the real mixin-wrapped store, so tenant scoping, soft-delete, and audit auto-fill all behave as in production — without a database. This is 90% of your tests. See [Unit testing](/docs/testing/unit-testing).
2. **Request — the transport hop.** `makeTestApp` sends a real request through the framework's own REST pipeline: the `x-tenant` header, the auth strategy chain, path-param decode, the 405/404/410 the transport produces, HTTP idempotency, and a `publicApi` annotation's scopes. Everything a handler test structurally cannot reach, and still no server, no port, no docker. See [Unit testing → request-level testing](/docs/testing/unit-testing#request-level-testing-maketestapp).
3. **Component — the reactive frontend.** `makeVoltroTestClient` renders a component against mocked `useSubscription` / `useMutation`, so loading, empty, stream-error and failing-write branches are all assertable without a running server. See [Component testing](/docs/testing/component-testing).
4. **Workflow — durable, multi-step logic.** `makeWorkflowRunner` drives a workflow end-to-end in-process and hands back the same step log the dashboard shows, including real retry counts. See [Workflows](/docs/testing/workflows).
5. **Parity — hand-written SQL that must stay portable.** `runDialectParity` runs a fixed scenario suite against a dialect you stand up. Mostly for dialect-package authors and apps with `unsafe()` SQL. See [Dialect parity](/docs/testing/dialect-parity).
6. **End-to-end — the running stack.** `voltro e2e` boots the api + web siblings, runs each `e2e/**/*.spec.ts` as a plain tsx script against the live processes (with `WEB_URL` + `API_URL` in the environment), and tears them down. No test runner and no browser ship with it — bring your own driver. See [`voltro e2e`](/docs/cli/inspect#voltro-e2e).

Agents are integration tests; tools are unit tests. Don't reach for an e2e harness to assert logic a `makeTestContext` test can prove.

## The CLI

```bash
voltro test    # vitest against the current app
voltro e2e     # boot api + web siblings, run tests, tear down
```

`voltro test` is a thin wrapper over vitest — it runs the app's test files with the framework's config. Everything `@voltro/testing` exports is plain TypeScript you import inside those files; there's no special test runner.

`e2e/` is left alone: those specs belong to `voltro e2e`, which drives them through tsx against a booted api + web. They define no vitest suite, so collecting them would report "No test suite found" — a red run for an app laid out exactly as the framework asks. Pass your own `--exclude` and it wins outright.

## What NOT to do

- **Don't spin up Postgres for a handler test.** `makeTestContext`'s in-memory store enforces the same mixin behaviour — tenant scoping, soft-delete, audit — so a unit test catches the same class of bug far faster. Save a real database for [dialect parity](/docs/testing/dialect-parity) and e2e.
- **Don't call a real model in agent tests.** Queue deterministic responses with `mockAi` / `useMockAi` (see [Unit testing → agents](/docs/testing/unit-testing#testing-agents-and-the-model-loop)). A flaky test that hits the network on every run is worse than no test.
- **Don't assert on wall-clock time.** Inject `MockClock` and `advance(...)` deterministically — never `setTimeout` + real sleeps.



---

<!-- source: en/testing/unit-testing.md -->
## Unit testing

_makeTestContext, invoke and makeTestApp — the in-memory request ctx, the handler hop and the transport hop. The real mixin-wrapped store, subject factories, row factories, plugin services, and the deterministic clock / webhook / LLM doubles._

## tsconfig `paths` aliases

`voltro test` derives Vite's `resolve.alias` from your app's tsconfig
`compilerOptions.paths`, so an app that maps `@/* → ./src/*` can test modules
that import through it without any extra config:

```jsonc
// tsconfig.json
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }
```

```ts
import { greeting } from '@/locales/en'   // resolves under `voltro test`
```

Before this, the first person to write a test for each aliased file discovered
`Cannot find package '@/locales/en' imported from src/lib/i18n.ts` — one file at
a time — and worked around it with a local `vitest.config.ts` restating what
tsconfig already said. The dev and build pipelines resolve these already (your
app runs), so the test runner disagreeing with them was a gap, not a policy.

A project-local `vitest.config.ts` still merges on top, so an app that already
wrote the workaround keeps working.

## `makeTestContext`

`makeTestContext(options?)` returns an **`AppContext`** — the exact `ctx` a mutation / query / action / tool executor receives at runtime — with the deterministic test doubles added on top. Because it *is* an `AppContext`, you pass it straight into a handler: `await myHandler(input, makeTestContext({ … }))`. The acting subject is at `ctx.request.subject`; `ctx.store` is the **real** mixin-wrapped store backed by an in-memory data store (so tenant auto-scoping, soft-delete filtering, audit auto-fill, and the fluent `select` / `update` / `delete` builders behave exactly as in production); `ctx.cache` is a real in-memory cache whose TTLs honour `ctx.clock`.

Tables are auto-wired from whatever the test imported (the globally-registered tables), so you never re-declare schema. Seed rows with `mockStore`.

```ts
import { makeTestContext, mockStore } from '@voltro/testing'
import createTodo from '../mutations/todos.create.mutation.server'

test('createTodo inserts a row for the caller’s tenant', async () => {
  const ctx = makeTestContext({
    subject: { type: 'user', id: 'user_1', tenantId: 't1' },
    store:   mockStore({ todos: [] }),
  })

  const row = await createTodo({ tenantId: 't1', title: 'buy milk' }, ctx)

  expect(row.title).toBe('buy milk')
  // The real store middleware auto-stamped audit + tenant columns —
  // no different from a live mutation.
})
```

The same call tests a tool — a tool's `execute` / handler takes `(input, ctx)`:

```ts
import { makeTestContext, mockStore } from '@voltro/testing'
import searchDocs from '../tools/searchDocs.tool'

test('searchDocs returns matching rows', async () => {
  const ctx = makeTestContext({ store: mockStore({ docs: [{ id: 'd1', body: 'hello world' }] }) })
  const out = await searchDocs({ query: 'hello' }, ctx)
  expect(out).toHaveLength(1)
})
```

### Options

`MakeTestContextOptions`:

| Field | Default | Meaning |
|---|---|---|
| `subject` | anonymous, no tenant | The acting `Subject`. |
| `tables` | all registered | Tables to wire into the store's schema. |
| `store` | `{}` | Seed rows keyed by table name — use `mockStore({...})`. |
| `clockStart` | `2026-01-01T00:00:00Z` | Frozen start instant for `ctx.clock`. |
| `ai` | — | Injected AI mock (a `mockAi({...})` value). |
| `llmResponses` | `[]` | Queued responses for the bundled `ctx.llm` (`MockLLM`). |
| `env` | ambient `process.env` | Env values sealed into the boot snapshot so handler code reading `getSecret('X')` / `serverEnv.X` resolves under test. Merged over `process.env` (these win). |
| `relations` | — | `relations()` specs to register for this context — the boot sweep's stand-in. See [Eager loads under test](#eager-loads-under-test). |
| `rowFilter` | the registered filter | A row filter for this context only, instead of the process-global `setRowFilter(...)`. See [Row-level security under test](#row-level-security-under-test). |
| `plugins` | `[]` | Plugins whose rpc interceptors wrap every `invoke` **and** whose `services:` layer is provided to Effect-mode handlers. See [Plugin services](#plugin-services-mail-storage-your-own). |
| `layers` | `[]` | Your app's own `layers:` from `app.config.ts` — the Effect services a handler `yield*`s. Merged last, so a user layer overrides a plugin layer declaring the same Tag. |

The returned `TestContext` carries `{ clock, webhooks, llm, ai?, request, access, cache, kv, store, storeForTenant, outbox, load, loadMany, withSubject, withTenant }` — read the acting subject at `ctx.request.subject` and the in-memory cache at `ctx.cache`.

### Subjects — `user()`, `apiKey()`, `anonymous()`, `system()`

Every authenticated `Subject` requires a `tenantId`, so a test that hand-writes `{ type: 'user', id: 'u1', tenantId: 't1', scopes: [] }` is restating the same four fields at every call site. Use the factories:

```ts
import { user, apiKey, serviceAccount, anonymous, system, TEST_TENANT_ID } from '@voltro/testing'

user('u1')                                   // tenant TEST_TENANT_ID, NO scopes → guards deny
user('u1', { scopes: ['notes:write'] })       // the caller a guard should let through
user('u2', { tenantId: 'other' })             // a different tenant — the store hides the first one's rows
apiKey('key_1', { scopes: ['orders:read'] })  // what a Bearer key resolves to
anonymous('acme')                             // unauthenticated, tenant from the `x-tenant` header
system('job:nightly')                         // the CROSS-tenant machine actor (tenantId null, admin:full)
```

Two decisions worth knowing, because they decide what your tests mean:

- **The default tenant is shared** (`TEST_TENANT_ID`). `user('a')` and `user('b')` are two members of ONE tenant, so a cross-subject read is a row-level question. A per-call-unique tenant would make the store hide everything and every such test would pass for the wrong reason.
- **`scopes` defaults to empty, not to a bypass.** `user('b')` is refused by any `guards:` — which is the assertion most negative tests are written to make.

## `invoke` — run a handler through its guards, its input Schema, and its transaction

Calling an executor directly skips the hops the real dispatcher runs first: the descriptor's **guards**, the input-Schema **decode**, and — for a mutation — the **transaction**. So a test can feed the handler a value the wire would reject, can exercise a handler the caller was never authorized to reach, and can leave half-written state behind that production would have rolled back. `invoke(descriptor, executor, rawInput, ctx)` closes them — it decodes `rawInput` through `descriptor.input`, enforces `descriptor.guards` against `ctx.request.subject`, then calls the executor with the decoded value, wrapping a mutation in a real store transaction. It also runs the hops around those: the plugin interceptor chain, the post-commit (`afterCommit`) drain, and the deadlock replay.

```ts
import { invoke, makeTestContext } from '@voltro/testing'
import { createNote } from './notes.mutation'          // the descriptor
import { createNoteHandler } from './notes.mutation.server' // the executor

const ctx = makeTestContext({ subject: { type: 'user', id: 'u1', tenantId: 't1' } })

// valid input decodes, then the handler runs
const note = await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)

// a bad shape rejects at the decode — the handler never runs
await expect(invoke(createNote, createNoteHandler, { title: 42 }, ctx)).rejects.toThrow()
```

### Effect-mode handlers run too

A handler may be written `async` **or** as an `Effect` — the framework's contract is "your choice, per handler", and the dispatcher runs both. `invoke` makes the same test, so an `Effect`-returning executor is *executed*, and `invoke` resolves to its success value (typed as that value, not as the `Effect`):

```ts
export const publishNote = (input: { id: string }, ctx: AppContext) =>
  Effect.gen(function* () {
    const store = yield* EffectStore
    yield* store.update('notes', input.id, { published: true })
    return 'published'
  })

const out = await invoke(publish, publishNote, { id: 'n1' }, ctx)
expect(out).toBe('published')     // the value — not an un-run Effect
```

A failure on the typed error channel rejects with **that error**, exactly as an async handler's `throw` does — so the same assertion works for either mode:

```ts
await expect(invoke(publish, publishNote, { id: 'gone' }, ctx))
  .rejects.toMatchObject({ _tag: 'NoteNotFound' })
```

`EffectStore` and `SubjectService` are provided over the context the handler is actually given — inside a mutation that is the *transactional* one, so an Effect handler's writes roll back with everything else. Guards, the input decode, the transaction, the deadlock replay, `afterCommit` and the plugin interceptors all wrap the Effect form identically. An app's own `layers:` and the aggregate registry are **not** provided: those are boot injections the harness has no access to.

### Guards are enforced

An unauthorized caller is refused with the typed `ScopeError` — the same error a client would receive — before the handler runs:

```ts
// the descriptor declares `guards: [scope('notes:write')]`
const outsider = makeTestContext({
  subject: { type: 'user', id: 'u2', tenantId: 't1', scopes: [] },
})

await expect(invoke(createNote, createNoteHandler, { title: 'hi' }, outsider))
  .rejects.toMatchObject({ _tag: 'ScopeError' })
```

What this covers:

- **Scope guards** — checked against `ctx.request.subject`.
- **Resource-scoped guards** — resolved through whatever `setResourceScopeResolver` the test registered.
- **Relationship / policy guards** — resolved through whatever tuple source the test registered. With none registered they **DENY**, exactly as in production: an authorization question nobody can answer is a refusal.

The full order `invoke` runs, which is production's:

```text
decode → plugin interceptors( guards → [transaction → handler] → afterCommit )
```

The **decode is first** because in production it happens at the wire (`@effect/rpc`), before any runner is reached — so a malformed payload rejects with a parse error even for a caller no guard would have let through. Guards then run before the transaction opens, and **inside** the plugin chain, so an rbac-style plugin that publishes a subject's role scopes from its interceptor has already run when the guard is checked. A `resource` extractor therefore sees the **decoded** input, exactly as on the dispatch spine.

### Mutations run in a real transaction

A mutation invoked through `invoke` runs inside `store.transactional(...)` — the store's own method, the same one the serve pipeline calls. So "the mutation failed, therefore nothing was written" is something you can assert here rather than deferring to an e2e test:

```ts
// the handler inserts, then throws on the second step
await expect(invoke(createNote, failingHandler, { title: 'hi' }, ctx)).rejects.toThrow()

// nothing survived — the rollback is the store's, not a copy-aside restore
expect(await ctx.store.select('notes').all()).toHaveLength(0)
```

The rollback is real: the in-memory data store's transactional view keeps a private overlay and discards it on a throw (buffered change events drain only on commit). Nothing in the harness copies rows aside and puts them back.

Which procedures get wrapped comes off `descriptor.kind`, which every `defineQuery` / `defineMutation` / `defineAction` / `defineStream` stamps — you never declare it, and so can't declare it wrongly:

- **`kind: 'mutation'`** → wrapped.
- **Queries and actions** → **not** wrapped. That mirrors production rather than omitting something: an action deliberately runs *outside* a transaction because it performs external I/O that cannot be rolled back.
- A hand-rolled descriptor object with no `kind` → not wrapped. There is nothing to key off, and wrapping everything would give an action the wrong semantics.

The context the handler receives is re-derived over that transaction: `txCtx.store`, its `withSubject` / `withTenant` re-scopers, and its `load` / `loadMany` batcher all read and write through the **same** transaction, so a handler can't accidentally escape it mid-mutation.

### `runInStoreTransaction`

The same wrap is exported for tests that want it around something other than an `invoke` call:

```ts
import { runInStoreTransaction, makeTestContext } from '@voltro/testing'

const ctx = makeTestContext({ store: mockStore({ notes: [] }) })

await expect(
  runInStoreTransaction(ctx, async (txCtx) => {
    await txCtx.store.insert('notes', { title: 'a' })
    throw new Error('boom')
  }),
).rejects.toThrow()

expect(await ctx.store.select('notes').all()).toHaveLength(0)
```

Nested calls are **not** supported — the in-memory store rejects a nested transaction, exactly as it does at runtime.

### `afterCommit` and `ctx.outbox`

Post-commit work runs, after the commit and **never** after a rollback — which is the entire contract of `afterCommit`: the side effect happens if and only if the write did. `ctx.outbox.enqueue(...)` schedules its delivery nudge through exactly that hook, and the enqueue itself is atomic with your domain write, so a throw loses both. Read the nudges a call produced with `outboxNudgesOf(ctx)`:

```ts
await invoke(createNote, async (input, c) => {
  await c.store.insert('notes', { title: input.title })
  await c.outbox.enqueue('note.created', { title: input.title })
  return 'ok'
}, { title: 'hi' }, ctx)

expect(outboxNudgesOf(ctx)).toHaveLength(1)
```

### Plugin interceptors

Pass the plugins to the context and their rpc interceptors wrap every `invoke` on it — the same list you declare in `app.config.ts`, composed the same way (first listed is outermost):

```ts
const ctx = makeTestContext({ subject, plugins: [auditPlugin, rbacPlugin] })

// the plugin's interceptMutation now wraps this call
await invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)
```

The harness takes the **plugin objects**, not a bare function, so it makes the same kind-selection production does: `interceptMutation` for a mutation, `interceptQuery` for a query, `interceptAction` for an action. A hook filed under the wrong name silently never fires here — exactly as it silently never fires in production, which is the bug worth catching.

Your interceptor receives what it receives at runtime: `{ tag, kind, input, subject, traceId, spanId? }`, with `input` already decoded. It can short-circuit (return a different `Effect` and the handler never runs), transform the result, or tap the error channel. Typed errors round-trip unwrapped — a plugin in the chain does not turn your handler's `NoteNotFound` into an opaque defect.

Two ordering properties you can assert directly, because they are the ones that bite:

```ts
// 1. The chain wraps the GUARDS — an interceptor sees the ScopeError.
//    (This is what lets an rbac plugin publish scopes before the check.)
// 2. The chain runs OUTSIDE the transaction — an interceptor that throws
//    AFTER the commit does not roll the mutation back:
const plugin = definePlugin({
  name: '@acme/audit',
  framework: '^1.0.0',
  interceptMutation: (next) =>
    next.pipe(Effect.flatMap(() => Effect.fail(new Error('audit sink down')))),
})

const ctx = makeTestContext({ subject, plugins: [plugin] })
await expect(invoke(createNote, createNoteHandler, { title: 'hi' }, ctx)).rejects.toThrow()

// the write is still there — post-only side effects must not undo the mutation
expect(await ctx.store.select('notes').all()).toHaveLength(1)
```

Only the rpc interceptors are wired. Lifecycle hooks (`onActivate`), `schema`, routes and dashboard mounts are boot concerns with no meaning for a single handler call, and are ignored. `rpcInterceptorFor(ctx, 'mutation')` returns the composed chain if you want to assert on it without invoking.

### The deadlock replay

Production replays a mutation whose transaction lost a deadlock — under concurrency the contract is "the victim retries", not "the write fails". `invoke` does the same, using the runtime's own classifier (mysql/maria errnos, the pg/mssql serialization SQLSTATEs, walked down the `cause` chain), so what counts as deadlock-shaped has one definition:

```ts
let attempts = 0
await invoke(createNote, async (input, c) => {
  attempts++
  await c.store.insert('notes', { title: input.title })
  if (attempts === 1) throw Object.assign(new Error('Deadlock found'), { errno: 1213 })
  return 'ok'
}, { title: 'hi' }, ctx)

expect(attempts).toBe(2)
expect(await ctx.store.select('notes').all()).toHaveLength(1) // only the surviving attempt
```

An ordinary failure is **not** replayed — it throws on the first try, so a plain bug never runs your handler three times.

The property that makes a replay safe is that each attempt starts **clean**: the rolled-back attempt's writes are gone (the transaction), and so is its queued post-commit work. Without that reset a replayed mutation would fire the outbox nudges of writes that never landed:

```ts
// attempt 1 enqueues then deadlocks; attempt 2 enqueues and commits
expect(outboxNudgesOf(ctx)).toHaveLength(1) // one, not two
```

Retries are always on and carry no backoff here. Production's jitter exists to de-correlate concurrent lock victims; a unit harness has neither concurrency nor a lock manager, so a sleep would only cost wall-clock (and stall a suite on fake timers). The retry semantics — attempt budget, transient classification, per-attempt reset — are what's reproduced. There is no opt-out, and it cannot mask a real failure: a handler that fails deterministically fails identically after the last attempt.

### What `invoke` still does NOT cover

- **Transport concerns** — connection info, rate limiting, the tenant header. Those are properties of the HTTP hop, not of the procedure; faking them here would only assert against the fake. Reach one level up for them: [`makeTestApp`](#request-level-testing-maketestapp) drives a real request through the real REST pipeline and runs the procedure through `invoke` underneath.
- **Undo capture and the metrics sample** — optional injections the serve entrypoint makes. Their absence changes nothing a handler can observe.
- **The CLI-built base services** — `Cache`, `Kv`, analytics, the outbound `HttpClient`, the aggregate registry. The CLI builds those at boot from your config, and `@voltro/testing` does not depend on the CLI, so a handler that `yield*`s one gets the ordinary "Service not found". Plugin services and your own `layers:` DO resolve — see below.

## Plugin services — mail, storage, your own

A plugin contributes an Effect **service**, not a `ctx` field: a handler writes `const mail = yield* MailService`. Register the plugin on the context and `invoke` provides its `services:` layer, exactly as the serve pipeline does — so the handler runs, and you assert on what the plugin actually recorded:

```ts
import { invoke, makeTestContext, user } from '@voltro/testing'
import { mailPlugin, readMailBuffer, clearMailBuffer } from '@voltro/plugin-mail'
import { sendWelcome } from '../actions/welcome.action'
import sendWelcomeHandler from '../actions/welcome.action.server'

beforeEach(() => { clearMailBuffer() })

test('signing up sends the welcome email', async () => {
  const ctx = makeTestContext({
    subject: user('u1'),
    plugins: [mailPlugin({ provider: 'memory', from: 'app@acme.com' })],
  })

  await invoke(sendWelcome, sendWelcomeHandler, { to: 'ada@acme.com' }, ctx)

  const sent = readMailBuffer()
  expect(sent).toHaveLength(1)
  expect(sent[0].to).toEqual(['ada@acme.com'])
  expect(sent[0].subject).toBe('Welcome')
})
```

`provider: 'memory'` is the mail plugin's own test adapter, and `readMailBuffer()` / `clearMailBuffer()` its own assertion surface — so the whole send path runs: the default `from`, the dev allowlist, suppression, per-send idempotency, the template render. There is deliberately **no `ctx.email` mock**: a double in `@voltro/testing` would model a weaker message than `MailService` accepts, and asserting against it would be asserting against the double.

A service nobody provided still fails with **"Service not found"**, which is the honest outcome — it is what the handler would do at runtime, not something to paper over.

Your own services work the same way, through `layers:`:

```ts
const ctx = makeTestContext({ layers: [Layer.succeed(Pricing, { quote: () => 499 })] })
```

## Request-level testing — `makeTestApp`

`invoke` is the handler hop. `makeTestApp` is the one above it: an actual request through the framework's own REST pipeline — no server, no port, no docker.

```ts
import { makeTestApp, makeTestContext, anonymous, user } from '@voltro/testing'
import { listOrders } from '../queries/orders.list.query'
import listOrdersHandler from '../queries/orders.list.query.server'

const ctx = makeTestContext({ subject: anonymous(), store: mockStore({ orders: [{ id: 'o1', tenantId: 'acme' }] }) })
const app = makeTestApp({
  ctx,
  publicApi: [{ descriptor: listOrders, handler: listOrdersHandler }],
  restRoutes: [healthRoute],
  strategies: [myBearerStrategy],
})

// the transport gate: the publicApi annotation's scopes refuse an unscoped caller
expect((await app.get('/v1/orders/list')).status).toBe(403)

// acting as someone who holds the scope
const res = await app.actingAs(user('u1', { tenantId: 'acme', scopes: ['orders:read'] })).get('/v1/orders/list')
expect(res.status).toBe(200)
expect(res.body).toEqual({ ids: ['o1'] })

// how an identity is RESOLVED — headers, not an injected subject
const viaHeader = await app.withHeaders({ 'x-tenant': 'acme', authorization: 'Bearer k1' }).get('/v1/orders/list')
```

The two ways to say who is calling are different questions, and the harness keeps them apart:

- **`actingAs(subject)`** fills the same `resolveSubject` seam the serve pipeline fills with its auth resolver. Use it to test what a given identity *may do*.
- **`withHeaders({...})`** and no `actingAs` runs the request through `composeAuthStrategies` — your real strategy chain, first-match-wins, the `x-tenant` fallback, `anonymousTenantRequired`. Use it to test how an identity *is resolved*.

Both return a derived app; neither mutates the one it came from.

| Method | |
|---|---|
| `app.get(path, opts?)` | Query string goes in the path: `app.get('/v1/orders?limit=2')`. |
| `app.post(path, body?, opts?)` | `put` / `patch` / `delete` likewise. `body` is JSON-encoded. |
| `app.request(method, path, opts?)` | The general form. |
| `app.actingAs(subject)` / `app.withHeaders(h)` | Derived views. |
| `app.paths` | Every mounted path, in mount order. |

A `TestResponse` is `{ status, headers, text, body, stream }` — `body` is the parsed JSON when the route answered with a JSON content-type.

What it runs is the framework's own code, not a re-implementation: `restRoutesToHttpRoutes` (method gate, sunset gate, `{ query, params, body }` assembly, input decode, guards, HTTP idempotency, output encode), `collectPublicApiRoutes` (the descriptor → REST projection, `scopes` → guards), `dispatchSharedPath` (the per-path dispatcher that lets a GET and a POST share one route), `composeAuthStrategies`, and `invoke` beneath a `publicApi` route — so the procedure's own guards, decode, transaction and interceptors still apply under the transport.

Two things it deliberately does not do. An auth strategy that **refuses** (`anonymousTenantRequired` with no tenant) makes the request-call REJECT rather than return a status: mapping an auth refusal onto an HTTP code is the server's job, and inventing a number here would be a number the harness made up. And there is no WebSocket / live-subscription lifecycle and no `POST /rpc` wire — those are dispatcher concerns the CLI owns; a `publicApi:` annotation is how a procedure gets an HTTP surface this harness can reach.

## Completing a fixture row — `fixtureRow`

`ctx.store` is the real mixin-wrapped store, so `store.insert` runs the same required-column validation production does: a payload that omits a NOT-NULL, no-default, non-auto-stamped column throws `TableValidationFailed` naming the column. That is deliberate — the test store is not laxer than Postgres, so a fixture can't pass on a row the database would reject. But it means a lean fixture (`insert('journal_entries', { id })`, an omitted required FK) now fails.

`fixtureRow(table, overrides)` completes the row for you. It fills every column the validator would flag with a schema-typed placeholder, then merges your overrides on top — so you write only the columns the test cares about and the rest are made valid:

```ts
import { fixtureRow, makeTestContext } from '@voltro/testing'
import { journalEntries } from '../schema/journal'

const ctx = makeTestContext({ subject, store: mockStore({ journal_entries: [] }) })

await ctx.store.insert('journal_entries', fixtureRow(journalEntries, {
  amount: '100.00',          // the columns THIS test asserts on
}))                          // entryNumber (unique), postedAt, … auto-filled
```

What it fills and what it leaves alone:

- **Fills** each required column with a value of the right shape — a `oneOf` column takes its first allowed value, a `unique` column gets a distinct value per call (so two fixtures don't collide on the key), `timestamp` / `date` get a fixed epoch, `decimal` / `bigint` a numeric string.
- **Leaves out** exactly what a caller may legitimately omit: nullable columns, defaulted columns, and the framework auto-stamped set (`id`, `tenantId`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, `deletedAt`, `deletedBy`) — the store stamps those from the subject and the table scheme.
- **Refuses to guess** a structured type (`json`, `bytes`, `vector`, `array`, `interval`, `raw`): it throws naming the column and telling you to pass it explicitly, rather than inventing a value that fails the codec obscurely.
- **Your override always wins** — including an explicit `null`. Don't assert on a synthesized placeholder; override anything the test inspects.

There is no flag to turn the validation off — a test store that accepts rows production rejects is a fake testing itself. `fixtureRow` is a runtime filler for the loose `store.insert(name, row)` path. For **compile-time** payload typing (a missing required column caught as a type error at the call), use [`insertRow` / `upsertRow`](/docs/data/mutations) from `@voltro/database`, which check against `InferInsertRow<T>`.

### Factories — `defineFactory`

`fixtureRow` makes ONE row valid. What it cannot do is the part a fixture actually costs you: the **parent rows**. It fills a `reference()` column with a placeholder string, which satisfies the validator and points at nothing — invisible in the in-memory store, a constraint violation against a real database, and an eager `.with({ author: true })` that resolves to nothing either way.

`defineFactory` adds defaults, traits and associations on top:

```ts
import { defineFactory, makeTestContext } from '@voltro/testing'
import { users, posts } from '../schema'

const userFactory = defineFactory(users, {
  defaults: { name: 'Ada', email: (seq) => `user-${seq}@test.local` },
  traits:   { admin: { role: 'admin' } },
})
const postFactory = defineFactory(posts, {
  defaults: { title: 'A post', views: 0 },
  traits:   { popular: { views: 10_000 } },
  associations: { authorId: userFactory },
})

const ctx = makeTestContext({ subject })

const post   = await postFactory.create(ctx.store)                  // an author IS created
const byAdmin = await postFactory.create(ctx.store, { authorId: (await userFactory.with('admin').create(ctx.store)).id })
const top3   = await postFactory.with('popular').createList(ctx.store, 3)
const built  = postFactory.build()                                  // pure — writes nothing
```

- **Defaults** take a value or a `(seq) => value` function; `seq` is a process-monotonic counter shared with `fixtureRow`'s unique filler, so two sources of "unique enough" values can't collide.
- **Traits** are named override bundles, composed left to right (`with('a', 'b')` — `b` wins). An undeclared trait **throws** rather than quietly building the base row.
- **Associations** apply to `create` only. An ancestor is created only for a required `reference()` column your overrides leave unset — pass `{ authorId: existing.id }` and nothing extra is written. A column with no declared factory still gets its parent, built with a plain `fixtureRow`. A **cyclic** reference is refused, naming the path and the column to pass by hand.
- `build` / `buildList` are pure: they write nothing, so a `reference()` column gets `fixtureRow`'s placeholder. Use `create` when the relations matter.

## Subscribers — `makeSubscribeContext` + the change constructors

A `*.subscribe.ts` handler does not receive an `AppContext`. It receives a
change EVENT and a `SubscribeContext`, and both have constructors:

```ts
import { changeInsert, changeUpdate, changeDelete, changeSoftDelete, makeSubscribeContext, makeTestContext } from '@voltro/testing'
import subscriber from '../src/attendance.subscribe'

const app = makeTestContext({ store: { attendance: [{ id: 'a1', employeeId: 'e1' }] } })
const ctx = makeSubscribeContext({ id: 'attendance', store: app.store })

await subscriber.handler(changeUpdate('attendance', { id: 'a1', state: 'in' }, { id: 'a1', state: 'out' }), ctx)
```

**Build the event with a constructor, not with an object literal.** The
constructors put the semantics in the name, which is the half a literal cannot
give you:

| | `op` | `old` | `new` |
| --- | --- | --- | --- |
| `changeInsert(t, row)` | `insert` | `null` | the row |
| `changeUpdate(t, before, after)` | `update` | before | after |
| `changeDelete(t, row)` | `delete` | the row | `null` |
| `changeSoftDelete(t, row)` | **`update`** | `deletedAt: null` | `deletedAt` set |

`changeSoftDelete` is the reason this exists. There is no `op: 'softDelete'` and
there never will be — a soft delete is an ordinary update that sets `deletedAt`
— so a test author who does not know that writes a delete, and the test passes
against a stream the framework never emits. We shipped exactly that defect: a
feature that keys off soft deletes was inert in production while its own tests
were green, because they asserted against an event shape that does not exist.

`ctx.store` has **no default and throws when touched**. Pass
`makeTestContext().store` so the subscriber and the code under test share one; a
silent empty store would let a subscriber reading the wrong table pass its test,
which is the same silent-nothing the constructors exist to remove.

`ctx.publish` is absent unless you pass one, mirroring the real context — where
it is optional precisely so that reaching for it in an app that declares no
event is a type error.

## Subject + tenant re-scoping

`withSubject` and `withTenant` re-scope to a different principal for one block, sharing the **same** underlying data — so cross-subject reads exercise real tenant scoping, not a closure stub. This is how you prove isolation: write as one tenant, then assert another tenant can't see the row.

```ts
const ctx = makeTestContext({
  subject: { type: 'user', id: 'u1', tenantId: 't1' },
  store:   mockStore({ todos: [] }),
})

await ctx.store.insert('todos', { title: 'private to t1' })

await ctx.withTenant('t2', async (ctx2) => {
  const rows = await ctx2.store.select('todos').all()
  expect(rows).toHaveLength(0)   // t2 sees none of t1's rows
})
```

`withSubject(subject, fn)` swaps the full identity; `withTenant(tenantId, fn)` keeps the current subject and only changes the tenant. Both return a `Promise` of whatever `fn` returns.

## Asserting on hidden rows

`ctx.store` applies the same scoping a handler gets — the tenant filter plus `deletedAt IS NULL`. To assert on rows the scope hides (that a mutation soft-deleted a row, or wrote into another tenant), read **past** the scope with `.unscoped()` (drops the tenant filter) and `.withDeleted()` (includes soft-deleted rows) — both typed, no cast:

```ts
await deleteNote({ id: 'n1' }, ctx)                 // soft-delete
expect(await ctx.store.select('notes').all()).toHaveLength(0)   // hidden from normal reads

const raw = await ctx.store.select('notes').unscoped().withDeleted().all()
expect(raw[0]?.deletedAt).not.toBeNull()            // …but still there, tombstoned
```

## Eager loads under test

`relations()` is **pure** — it returns a spec, it does not register one. In production `voltro dev` discovers every `*.relations.ts` and registers what it exports; a unit test runs no boot, so importing the module registers nothing and the first `.with({ … })` fails with *"no relations registered"*. Hand the specs to the context instead:

```ts
import { teamRelations } from '../db/teams.relations'

const ctx = makeTestContext({
  relations: [teamRelations],
  store: mockStore({ teams: [{ id: 't1' }], members: [{ id: 'm1', teamId: 't1' }] }),
})

const rows = await ctx.store.select('teams').with({ members: true }).all()
expect(rows[0].members).toHaveLength(1)
```

The relations registry is **process-global**, so the option *replaces* it with exactly the specs you pass rather than adding to it. That is what keeps two `makeTestContext({ relations: [...] })` calls in one file independent — additive registration would throw `duplicate relation` on a re-registered spec and would carry the first test's relations into the second. Omitting the option leaves the registry untouched.

## Row-level security under test

`ctx.store` applies the app's [row filter](/docs/authentication/row-level-security) for the context's subject: registered with `setRowFilter(...)`, resolved once per context, AND-merged into every read. Both read paths are covered (the fluent builders and `store.query(descriptor)`), `.unscoped()` does **not** bypass it — that opts out of tenant isolation, not of authorization — and a `system` subject bypasses it, exactly as at runtime.

```ts
const ctx = makeTestContext({ subject: alice, store: mockStore({ tickets: seed }), rowFilter: ownTickets })

const rows = await ctx.store.select('tickets').all()
expect(rows.map((r) => r.id)).not.toContain('bobs-ticket')  // the rule, asserted
```

Pass `rowFilter:` — as above — to scope a filter to **this context only**. `setRowFilter` is process-global: registered in one test it silently constrains every later test in the same worker, and a forgotten `afterEach` surfaces as a failure in an unrelated file. Either way the resolution is the runtime's own, so the retry schedule, the system bypass and the `onLoadError` policy behave identically: a filter whose `load` fails refuses the read (with `RowFilterUnavailable`, or zero rows under `onLoadError: 'deny'`) rather than quietly returning everything.

### Filters over SHARED resources

A filter over rows the user *owns* needs no database — the subject carries the id. A filter over rows *shared with* the user must read a membership table, and `load` is `Effect<Ctx, unknown>` with `R = never`, so it cannot `yield*` a store service. Its only route is [`runAsSystem`](/docs/multi-tenancy/edge-cases), and `makeTestContext` wires its seeded store into that, so this resolves under test exactly as it does at runtime:

```ts
const sharedLists: RowFilter<{ listIds: ReadonlyArray<string> }> = {
  load: (subject) =>
    Effect.promise(() =>
      runAsSystem(async (sys) => {
        const rows = await sys.store.select('listMembers').where('userId', String(subject.id)).all()
        return { listIds: rows.map((r) => String(r.listId)) }
      }),
    ),
  predicate: (ctx, table) => (table === 'lists' ? inSet('id', ctx.listIds) : undefined),
}

const bob = makeTestContext({ subject: bobSubject, store: mockStore(seed), rowFilter: sharedLists })
expect((await bob.store.select('lists').all()).map((r) => r.id)).not.toContain('alices-list')
```

`makeTestContext` registers the **raw** data store + schema registry, so nothing is double-wrapped (`runAsSystem` applies the system-subject mixin wrap itself), and it is the outer store rather than a transactional view — matching production, where a `runAsSystem` block inside a mutation reads outside that mutation's transaction.

The handle is a process global, and the harness handles that in two layers. Registration is last-wins, so a bare `await runAsSystem(...)` written directly in a test also resolves. On top of that, each context re-points the handle at **its own** store for the span of its `load` and restores the previous value — necessary because two `makeTestContext` calls allocate two separate in-memory stores even from one seed object, so filter resolution must not depend on build order. Nothing leaks across test files (vitest gives each file a fresh module registry). Only the harness registers: an app that registers no handle still gets `runAsSystem: no data store available`, unchanged. If a test in the same file needs that refusal back, call `clearSystemStoreHandle()` from `@voltro/runtime`.

## The deterministic mocks

### `ctx.clock` — `MockClock`

A frozen clock you advance by hand. No real sleeping.

```ts
ctx.clock.now()            // current instant in ms
ctx.clock.date()           // current instant as a Date
ctx.clock.advance('15m')   // jump forward — ms | s | m | h | d, or a number of ms
ctx.clock.advance(900_000) // same, in milliseconds
ctx.clock.set('2026-03-01T12:00:00Z')  // jump to an ABSOLUTE instant
```

#### Freezing GLOBAL time — `withFrozenTime` / `frozenTime`

On its own a `MockClock` is a value you read: `Date.now()` and `new Date()` are
untouched. That matters more than it sounds, because almost nothing stamps a
timestamp through the clock you passed it — `MockWebhooks.emittedAt`, the mail
plugin's memory provider, every `createdAt` default and most of your own code
all call `new Date()`. Advance the mock clock, assert on one of those stamps,
and you are comparing two unrelated clocks: the assertion passes by measuring
the machine.

`withFrozenTime` takes over the realm's `Date` for the body and restores it
afterwards — the Rails `travel_to` / Laravel `Carbon::setTestNow()` move:

```ts
import { withFrozenTime } from '@voltro/testing'

await withFrozenTime('2026-03-01T12:00:00Z', async (clock) => {
  expect(new Date().toISOString()).toBe('2026-03-01T12:00:00.000Z')
  await invoke(createTodo, { title: 'x' }, ctx)   // its createdAt is that instant
  clock.advance('1h')                             // global time moves with it
  expect(Date.now()).toBe(Date.parse('2026-03-01T13:00:00Z'))
})
```

It restores whether the body returns, throws, resolves or rejects — an async
body is awaited *before* the clock goes back, so a `finally` cannot put the real
clock back underneath a still-running test.

The Effect-native form freezes for a **scope** and releases on success, failure
and interruption alike:

```ts
import { frozenTime } from '@voltro/testing'

Effect.scoped(Effect.gen(function* () {
  const clock = yield* frozenTime('2026-03-01T12:00:00Z')
  yield* somethingThatStampsNow
  clock.advance('1d')
}))
```

`clock.install()` is the manual escape hatch and hands back the uninstall. Four
things worth knowing before you reach for it:

- **Only the zero-argument readings change.** `new Date(0)`, `new Date('2020-05-05')`
  and `new Date(2020, 0, 2)` mean exactly what they say; `Date.parse` and
  `Date.UTC` are untouched.
- **A second install throws.** Nesting two would make the inner uninstall restore
  the *outer fake*, leaving the realm frozen with nothing pointing at why.
- **Timers are not faked.** `performance.now()` and `setTimeout` are unaffected —
  use vitest's `vi.useFakeTimers()` for the timer wheel.
- **A module that captured `Date` into a local before the install keeps the real
  one.** That is inherent to any time fake.

### Email is not a double — see [Plugin services](#plugin-services-mail-storage-your-own)

There is no `ctx.email`. Mail is an Effect service (`yield* MailService`), so the assertion surface is the mail plugin's own memory provider — register `mailPlugin({ provider: 'memory' })` on the context and read `readMailBuffer()`.

### `ctx.webhooks` — `MockWebhooks`

Records outgoing webhook emissions. Every context derived from this one — the transaction context `invoke` builds for a mutation, a `withSubject` / `withTenant` block — shares the same recorder, so what you assert on IS what the handler emitted.

```ts
ctx.webhooks.emitted                     // EmittedWebhook[] — every emit, in order
ctx.webhooks.last('todo.created')        // the most recent emission of that event
ctx.webhooks.payloadsFor('todo.created') // just the payloads, oldest first
ctx.webhooks.clear()                     // reset between cases
```

It records; it does not deliver, sign, or consult subscriptions. `emit` reports `{ delivered: 0 }` because no targets are subscribed, and saying so is more honest than a number a test might assert against.

### `ctx.llm` — `MockLLM`

A queue of canned model responses plus a record of every call. Each `MockResponse` is one of `{ text }`, `{ toolCall: { name, input } }`, or `{ error: { code, message? } }`.

```ts
const ctx = makeTestContext({
  llmResponses: [{ text: 'first' }, { toolCall: { name: 'search', input: { q: 'x' } } }],
})

// inside the handler under test, the model consumes the queue in order;
// afterwards:
expect(ctx.llm.calls).toHaveLength(2)   // each prompt that triggered a call
expect(ctx.llm.remaining()).toBe(0)     // queue drained
```

`MockLLM` throws `MockLLM: no more responses queued` if the code under test asks for one more response than you queued — a useful assertion that the loop ran exactly as many turns as expected.

## Testing agents and the model loop

`ctx.llm` is the low-level queue. For a full agent / model-loop test, install a deterministic provider with `mockAi` / `useMockAi` from `@voltro/ai/test` — the AI package owns the full mock; `@voltro/testing` only types the injected `MockAi` slot on `makeTestContext({ ai })`.

```ts
import { useMockAi } from '@voltro/ai/test'

const mock = useMockAi({
  stream: ['Hello, ', 'world.'],                  // token deltas
  turns: [                                        // scripted tool loop
    { toolCalls: [{ name: 'searchDocs', input: { query: 'x' } }] },
    { text: 'Based on the docs.' },
  ],
})
// … run the agent …
mock.reset()
```

`mockAi({...})` returns a `MockAi` value you pass as `makeTestContext({ ai: mockAi({...}) })`; `throwNTimes(n, value)` exercises retry paths. See [AI → Providers](/docs/ai/providers#mock-for-tests) for the full surface.



---

<!-- source: en/testing/component-testing.md -->
## Component testing

_makeVoltroTestClient — the frontend test harness. Render a component against mocked useSubscription / useMutation, inject a loading state, a stream error or a failing write, and assert on every recorded call._

## Why this exists

A Voltro frontend is a **reactive-data app**. Its components don't take props and render — they call `useSubscription` for live data and `useMutation` / `useAction` to write. Without a way to render such a component against mocked hooks — and to inject a loading state, a stream error, or a failing write — a component is **structurally untestable**: there is no seam to hand it data, and nothing to assert against. That is exactly why real apps end up with zero frontend tests.

`makeVoltroTestClient` closes that gap. It builds a fake api context, hands you a `Provider` to wrap the component under test, and gives you the controls to drive it.

```bash
pnpm --filter @my-app/web add -D @voltro/testing vitest jsdom react-dom
```

Import from the **`/client` subpath**, so backend-only test files never pull React:

```ts
import { makeVoltroTestClient } from '@voltro/testing/client'
```

## The surface

```ts
const harness = makeVoltroTestClient({
  apiName:       'app',                                  // default 'app'
  subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] },
  mutations:     { 'todos.create': (input) => ({ id: '2' }) },
  actions:       { 'todos.export': async () => ({ url: '/x.csv' }) },
})
```

| Config | Meaning |
|---|---|
| `apiName` | The api name your components pass to the hooks. Default `'app'`. Asking for a different one throws a loud, named error rather than rendering nothing. |
| `subscriptions` | Initial data per rpc tag. A tag that is **absent stays in the loading state**. |
| `mutations` | Handlers per tag. Throw (or reject) to exercise the failure path. |
| `actions` | Handlers per tag, same shape. |

| Returned | Use |
|---|---|
| `Provider` | Wrap the component under test. Takes `children`, returns an element. |
| `setSubscription(tag, data)` | Push new data for a tag — components re-render, exactly like a server delta. |
| `failSubscription(tag, error)` | Put a tag into the **cold-start error state** (no snapshot + an error). |
| `resetSubscription(tag)` | Return a tag to the loading state (no snapshot yet). |
| `calls` | Every mutation / action invoked so far, in order — `{ kind, tag, input }`. |

### Absent tag = loading

This is the distinction most worth testing and the easiest to get wrong. A tag with **no fixture** renders the component's loading branch; a tag whose fixture is `[]` renders the *empty* branch. Real apps routinely conflate the two and ship a skeleton that never resolves. The harness makes both states one line apart.

### It is deliberately not a renderer

The harness hands you a `Provider` and nothing else about rendering. It works with `react-dom/client` + `act`, with testing-library, or with your own harness, and locks you into none of them. This repo's own tests use `react-dom/client` directly — there is no `@testing-library` dependency anywhere in the framework.

## A worked example

Two things React needs before any of this runs: a **DOM environment** (the `// @vitest-environment jsdom` docblock, per file) and **`IS_REACT_ACT_ENVIRONMENT = true`**, without which React refuses to honour `act()` outside a runner it recognises.

```tsx
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { act, createElement, type ReactElement } from 'react'
import { createRoot } from 'react-dom/client'
import { useSubscription, useMutation } from '@voltro/client'
import { makeVoltroTestClient } from '@voltro/testing/client'

// React requires this flag before it will honour act().
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

// The component under test — ordinary app code, no test seams of its own.
const TodoList = (): ReactElement => {
  const { data, loading, isEmpty } = useSubscription<Array<{ id: string; title: string }>>('app', 'todos.list', {})
  const create = useMutation<{ title: string }, { id: string }>('app', 'todos.create')
  if (loading) return <p>loading</p>
  if (isEmpty) return <p>empty</p>
  return (
    <div>
      <ul>{(data ?? []).map((t) => <li key={t.id}>{t.title}</li>)}</ul>
      <button onClick={() => { void create.mutate({ title: 'new' }, {}) }}>add</button>
    </div>
  )
}

const mount = (harness: ReturnType<typeof makeVoltroTestClient>) => {
  const host = document.createElement('div')
  const root = createRoot(host)
  act(() => { root.render(createElement(harness.Provider, null, createElement(TodoList))) })
  return {
    html: () => host.innerHTML,
    find: <E extends Element>(selector: string): E | null => host.querySelector<E>(selector),
    unmount: () => act(() => { root.unmount() }),
  }
}

describe('TodoList', () => {
  it('renders the loading state when the tag has no data yet', () => {
    const h = makeVoltroTestClient()                       // no fixture for todos.list
    const view = mount(h)
    expect(view.html()).toContain('loading')
    view.unmount()
  })

  it('distinguishes EMPTY from loading', () => {
    const h = makeVoltroTestClient({ subscriptions: { 'todos.list': [] } })
    const view = mount(h)
    expect(view.html()).toContain('empty')
    expect(view.html()).not.toContain('loading')
    view.unmount()
  })

  it('re-renders when a server delta arrives', () => {
    const h = makeVoltroTestClient({ subscriptions: { 'todos.list': [{ id: '1', title: 'first' }] } })
    const view = mount(h)
    expect(view.html()).toContain('first')

    act(() => { h.setSubscription('todos.list', [{ id: '1', title: 'first' }, { id: '2', title: 'second' }]) })
    expect(view.html()).toContain('second')
    view.unmount()
  })

  it('handles a dead stream', () => {
    const h = makeVoltroTestClient({ subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] } })
    const view = mount(h)
    act(() => { h.failSubscription('todos.list', new Error('stream died')) })
    expect(view.html()).toContain('loading')   // no snapshot → the loading branch
    view.unmount()
  })

  it('records the write with its input', async () => {
    const h = makeVoltroTestClient({
      subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] },
      mutations:     { 'todos.create': () => ({ id: '2' }) },
    })
    const view = mount(h)
    await act(async () => { view.find<HTMLButtonElement>('button')?.click() })
    expect(h.calls).toEqual([{ kind: 'mutation', tag: 'todos.create', input: { title: 'new' } }])
    view.unmount()
  })
})
```

## Testing the failure paths

The three failure modes a reactive frontend actually ships broken:

- **The infinite skeleton.** `failSubscription(tag, error)` drops the snapshot and sets an error — the cold-start case where data never arrived at all. If your component only branches on `data`, this is the test that catches it.
- **The write that rejects.** A `mutations` handler that throws surfaces through the component's own error handling. Pass an `onError` to `mutate` and the promise resolves instead of rejecting — assert both that the handler saw the error and that the component still rendered.
- **The wrong api.** A component asking for an api the harness wasn't built for throws a named error telling you to pass `{ apiName }`, instead of silently rendering an empty tree.

```tsx
const seen: unknown[] = []
const h = makeVoltroTestClient({
  subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] },
  mutations:     { 'todos.create': () => { throw new Error('nope') } },
})
// … click the button, with onError: (e) => { seen.push(e) } …
expect(h.calls).toHaveLength(1)      // the write was attempted
expect(seen).toHaveLength(1)         // handled, not an unhandled rejection
expect(view.html()).toContain('x')   // and the component survived
```

## What NOT to do

- **Don't mock `@voltro/client` yourself.** Module-mocking the hooks gives you a component that renders but proves nothing about subscription lifecycle — loading, delta, error. The harness drives the real hooks against a fake cache.
- **Don't reach for e2e to assert a render branch.** Booting api + web to check that an empty list shows "empty" is minutes of runtime for something a `makeVoltroTestClient` test proves in milliseconds. Keep e2e for the flows that genuinely cross the wire.
- **Don't forget the docblock.** Without `// @vitest-environment jsdom` on the file, `document` doesn't exist and the failure message points at React, not at the missing environment.



---

<!-- source: en/testing/workflows.md -->
## Testing workflows

_makeWorkflowRunner — drive a workflow end-to-end in-process and assert on the same step log the dashboard reads, including real retry counts._

## `makeWorkflowRunner`

A workflow's logic — steps, retries, branching on a signal — deserves a test that doesn't boot the cluster engine. `makeWorkflowRunner({ ctx, workflows })` runs a workflow end-to-end in-process on an in-memory engine and hands back the same step log the [workflow dashboard](/docs/workflows/debugging) reads, so a test can assert on attempts, durations, and intermediate values.

Discovery is explicit: pass each workflow under test as a `{ workflow, execute }` entry. The `workflow` is the value from `workflow({ name, ... })` (the descriptor); the `execute` is the same `(payload, executionId) => Effect` the framework hands to `workflow.toLayer(...)` at registration time — i.e. `buildExecute(ctx)` from your `*.workflow.server.tsx`. The runner matches `start(name, …)` against `entry.workflow.name`.

```ts
import { makeTestContext, mockStore, makeWorkflowRunner } from '@voltro/testing'
import { SummarizeTodos } from '../workflows/todos.summarize.workflow'
import buildExecute from '../workflows/todos.summarize.workflow.server'

test('summarize walks fetch → summarize and succeeds', async () => {
  const ctx = makeTestContext({ store: mockStore({ todos: [{ id: 't1', title: 'x' }] }) })

  const runner = makeWorkflowRunner({
    ctx,
    workflows: [
      { workflow: SummarizeTodos as never, execute: buildExecute(ctx) as never },
    ],
  })

  const result = await runner.start('todos.summarize', { tenantId: 't1' })

  expect(result.status).toBe('succeeded')
  expect(result.steps.map((s) => s.name)).toEqual(['fetch-todos', 'summarize'])
})
```

The `as never` casts keep the call-site ergonomic — the runner is type-erased over each workflow's payload / success / error shapes (it provides the engine layer itself), and `never` assigns to any field type without an `any` leaking into the public type.

## The result

`start(name, payload)` blocks until the run is terminal and resolves a `WorkflowRunResult`:

| Field | Meaning |
|---|---|
| `status` | `'succeeded'` or `'failed'`. |
| `output` | The workflow's success value (`null` on failure). |
| `error` | `{ tag, message }` on failure (`null` on success). `tag` is the typed error's `_tag` when there is one. |
| `steps` | One `InspectedStep` per step name — `{ name, attempt, attempts, status, input, output, error, durationMs }`. |
| `runId` | The synthetic run id, for `inspect(...)`. |

### Asserting on retries

`steps[i].attempts` reflects the **real** retry count: a step that throws twice then succeeds records three attempt rows, so `attempts === 3`. This is the cleanest way to prove a step's retry policy actually fired.

```ts
const charge = result.steps.find((s) => s.name === 'charge-card')
expect(charge?.status).toBe('completed')
expect(charge?.attempts).toBe(3)   // failed twice, succeeded on the third
```

### Inspecting a finished run

`inspect(runId)` returns the recorded `InspectedWorkflow` for a previously-started run **without re-running it**, or `null` for an unknown id — the same shape the dashboard renders (`{ id, name, status, input, output, subject, source: 'test-runner', steps, startedAt, finishedAt, … }`).

```ts
const run = await runner.inspect(result.runId)
expect(run?.source).toBe('test-runner')
```

## Notes

- The runner uses the in-memory workflow engine and an in-memory recorder — nothing persists, nothing schedules on the real clock. Pair it with [the mock clock](/docs/testing/unit-testing#the-deterministic-mocks) for any time-dependent step.
- Starting a workflow the runner doesn't know about throws a clear error naming the missing workflow — register it in the `workflows` array.
- This tests the workflow's **logic**. To exercise the durable engine itself (crash/resume, cluster handoff), that's an integration concern beyond the in-process runner.



---

<!-- source: en/testing/dialect-parity.md -->
## Dialect parity

_runDialectParity — a fixed scenario suite that proves a SQL backend behaves identically to the others. For dialect-package authors and apps with hand-written portable SQL._

## When you need this

Most apps never touch this page. The framework's DSL already hides every cross-dialect difference, and a [unit test](/docs/testing/unit-testing) on the in-memory store proves your handler logic. Reach for `runDialectParity` only when you have code that talks to a real database in a dialect-specific way:

- You're authoring a new `@voltro/sql-*` dialect package and need to prove it before plugging it into `loadDialect()`.
- Your app drops to `unsafe()` SQL or custom store logic that must stay portable across the backends you ship on.

## `runDialectParity`

`runDialectParity(fixture)` registers a vitest suite (`describe('dialect parity: <name>')`) that runs a fixed scenario set against the database your fixture stands up — and asserts identical behaviour on each. It's exported from the **`@voltro/testing/dialect`** subpath — the only part of `@voltro/testing` that imports `vitest`, so `makeTestContext` consumers carry no `vitest` peer. The scenarios cover the contract the DSL relies on:

- DDL idempotency (re-applying the schema is a no-op)
- insert / query round-trip, update post-image, delete
- `onChange` fires insert + update + delete events
- `transactional` rolls back on throw (and drops queued change events) / commits and drains events in order
- `upsert` (insert then update-on-conflict, no duplicate)
- `updateMany` with a predicate, and the atomic compare-and-set (the wakeup-claim pattern)
- `retryFilter` returns a stable `'retry' | 'noRetry'` decision

The harness intentionally does **not** bundle docker setup — that lives next to the dialect, so the parity packages' devDeps stay minimal. Your fixture owns the lifecycle (scratch docker Postgres, temp-file SQLite, …).

```ts
// sql-postgres/__tests__/parity.test.ts
import { runDialectParity, applySchema } from '@voltro/testing/dialect'
import { postgresDialect } from '../src/index'

runDialectParity({
  dialect: postgresDialect,
  name: 'postgres (scratch docker)',
  setup:    async () => { /* stand up a clean scratch database */ },
  teardown: async () => { /* drop it */ },
  make:     async () => ({
    store:   /* the live DataStore for this scenario */,
    migrate: (tables) => /* applySchema(tables, dialect.id) against the same runtime */,
    dispose: async () => { /* close store, drop scratch state */ },
  }),
})
```

## The fixture contract

`DialectFixture`:

| Field | Meaning |
|---|---|
| `dialect` | The `SqlDialect` under test (its `id`, `retryFilter`, etc.). |
| `name?` | Label for the test output (defaults to `dialect.id`). |
| `setup` | Runs before each scenario — give it a clean slate. |
| `teardown` | Runs after each scenario. |
| `make` | Returns a `DialectFixtureSession` for the scenario. |

`DialectFixtureSession`:

| Field | Meaning |
|---|---|
| `store` | The live `DataStore` the scenarios drive. |
| `migrate(tables)` | Apply `applySchema(tables, dialect.id)` against the same runtime the store uses, so DDL and DML hit the same database. |
| `dispose()` | Tear the session down (close store, drop scratch DB). |

`applySchema` is re-exported from `@voltro/testing/dialect` (the same subpath as `runDialectParity`, not the root entry) so a fixture can build its `migrate` without pulling `@voltro/database/sql` separately. Once a dialect green-checks the harness, it's ready for `loadDialect()`.
