The testing helpers enforce server/client mirroring
(mirroring test design), the
“index serialization MUST be O(rows) with zero per-row queries” budget, and the
“mass assignment is impossible by construction” security contract. Everything below is
exported from @adonia/core/testing
(packages/core/src/testing.ts) and imports nothing from @adonisjs/* at
runtime, so a unit test needs no app boot and no IoC container.
Public testing surface
The shipped module is a set of plain functions, usable from Japa, Vitest,
node:test or none of them:
| §21 wording | What exists |
|---|---|
| Japa plugin | Plain exported functions; no runner registration |
assertQueryCount(n) |
assertQueryCount(fn, expected, source) — the emitter is injected |
assertDescriptorOmits(field) |
Not implemented. Assert on compileFor(...) directly, or use assertValidatorOmits for the validator half |
| authenticated panel-visit helper | Not implemented. examples/blog-admin/tests/functional/ drives real HTTP through Japa’s API client |
The exact export surface is: makeFakeContext, compileFor, validateWith,
resourceFromFixture, runMirroringCase, runMirroringFixture,
assertValidatorKeys, assertValidatorOmits, assertFillOmits,
assertRequiredWhen, countQueries, assertQueryCount, stableStringify,
the two probe constants GARBAGE_PROBE_VALUE (12345) and
REQUIRED_PROBE_VALUE ('adonia-required-probe'), and the types
FakeAuthUser, FakeGuard, FakeRedirect, FakeResponse,
FakeInertiaRender, FakeSharedState, FakeInertia, FakeHttpContext,
FakeContextOverrides, ValidationResult, MirroringFixture,
MirroringFixtureField, MirroringFixtureCase, MirroringCaseRun,
MirroringFixtureRun, QueryEventSource.
::: info Samples on this page
The docs typecheck program resolves @adonia/* and #models/* but neither
@japa/runner nor @types/node. Test-shaped samples are therefore written as
plain function bodies and top-level statements; in a real suite each block is
the body of a test(...) callback. Signature blocks are written as
declare function, so the typechecker proves every type they name still
exists. The repo’s own style is shown in
Test style in this repo.
:::
Where tests live and how to run them
In a host app. Adonia adds no test conventions of its own. An AdonisJS app
already has tests/unit/ and tests/functional/ wired through
node ace test; resource tests are unit tests (nothing is booted), and HTTP
tests are functional tests. Adonia’s helpers are ordinary imports:
import { compileFor, makeFakeContext } from '@adonia/core/testing'In this repo. Per-package, never project-wide, because the runners differ:
| Package | Runner | Command |
|---|---|---|
@adonia/core |
Japa (bin/test.ts, tests/**/*.spec.ts, run through tsx) |
pnpm --filter @adonia/core test |
@adonia/devtools |
Japa, same bootstrap | pnpm --filter @adonia/devtools test |
@adonia/ui |
Vitest + Testing Library (tests/**/*.test.tsx) |
pnpm --filter @adonia/ui test |
examples/blog-admin |
Japa via the app kernel, against a real database | pnpm --filter blog-admin test |
CI runs the first three as one pnpm -r --filter '@adonia/*' test job and
blog-admin as a separate matrix over DB_CONNECTION=pg|mysql|sqlite — see
CI. Descriptor snapshots are regenerated deliberately with
UPDATE_SNAPSHOTS=1 pnpm --filter @adonia/core test.
The three things worth asserting about a resource
A resource is a declaration, and three artifacts are compiled from it. Each has its own failure mode, and asserting one does not cover the others.
graph LR
R[Resource declaration] --> D[Descriptor: what renders]
R --> V[Validator: what is accepted]
R --> F[Fill set: what is written]- The compiled descriptor — what the client renders. Snapshot it with
compileFor+stableStringify. Failure mode: silent protocol drift. - The compiled validator — what the server accepts. Drive it with
validateWith/assertValidatorKeys. Failure mode: a rule the UI enforces and the server does not. - The fill set — what actually reaches the model. Assert it with
assertFillOmits.
The third is the one teams skip, and it is the only one that is a security
property. §19 states that mass assignment is impossible by construction
because “fill sets derive from the compiled schema for the exact
(mode, visibility-state) of the submission”. Validation and fill are two
separate derivations from that schema, and passing validation does not imply
being persisted: a disabledWhen-true field is shape-validated but dropped
from the fill set (rule R5), and a hidden field is absent from both. A client
that ignores the UI entirely and POSTs { authorId: 1, isAdmin: true } is
stopped by the fill set, not by Vine. So assert the fill set explicitly —
otherwise a refactor that widens it fails no test.
The resource under test
Every sample below drives this resource. subtitle is reactive: hidden unless
title is exactly 'special', and required when it is.
import { BaseResource, F } from '@adonia/core'
import type { SchemaComponent } from '@adonia/core'
import Post from '#models/post'
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
override schema(): SchemaComponent[] {
return [
F.text('title').required(),
F.text('subtitle').visibleWhen('title', 'special').requiredWhen('title', 'special'),
F.text('editorNote').virtual(),
]
}
}From here on, resource is new PostResource(). Resources are instantiated
per request and hold no state, so constructing one per test is free.
makeFakeContext
declare function makeFakeContext<TUser extends FakeAuthUser = FakeAuthUser>(
overrides?: FakeContextOverrides<TUser>
): FakeHttpContext<TUser>Builds the slice of HttpContext that Adonia’s pipeline touches — auth,
request, session flash, response, Inertia, and a container-ish resolver — with
no AdonisJS dependency. It satisfies HttpContextLike, which is the parameter
type of every compile-time callback (canSee) and of the descriptor compiler,
so a fake and a real context are interchangeable at those call sites.
Defaults: an authenticated admin ({ id: 1, email: 'admin@adonia.dev' }),
GET /, accepts: 'html', empty params/query/body/headers/flash/bindings, and
no Bouncer. Every override is shallow; nested facades are built from them,
so the fake is always internally consistent.
import { makeFakeContext } from '@adonia/core/testing'
const ctx = makeFakeContext({
user: { id: 7, email: 'editor@example.com' },
method: 'post',
url: '/admin/posts',
params: { id: '3' },
query: { page: '2', sort: '-publishedAt' },
body: { title: 'Hello' },
flash: { success: 'Saved' },
bindings: { 'app.name': 'blog-admin' },
})
ctx.request.method() // 'POST' — upper-cased for you
ctx.request.param('id') // '3'
ctx.request.param('missing', 'fallback') // 'fallback'
ctx.auth.authenticate().email // 'editor@example.com'
ctx.container.resolve<string>('app.name') // 'blog-admin'Pass user: null for a guest request: auth.check() returns false and
auth.authenticate() throws. Note the distinction — omitting user gives you
the default admin, passing null gives you a guest.
The auth, session, response and Inertia facades record rather than act, and the recordings are the assertion surface:
import { makeFakeContext } from '@adonia/core/testing'
const ctx = makeFakeContext({ user: null })
ctx.response.status(302).redirect().toPath('/admin/login')
ctx.session.flash('error', 'Sign in first')
ctx.inertia.render('adonia/login', { panel: 'admin' })
ctx.response.statusCode // 302
ctx.response.redirectedTo // '/admin/login'
ctx.response.headers.get('cache-control') // header names are lower-cased
ctx.session.flashMessages.get('error') // 'Sign in first'
ctx.inertia.rendered?.component // 'adonia/login'
await ctx.inertia.sharedProps() // every share() provider, resolved and mergedmakeFakeContext({ bouncer }) accepts a structural Bouncer double, which is
how step 2 of the authorization chain is tested without
installing the optional @adonisjs/bouncer peer.
compileFor
declare function compileFor(
resource: BaseResource,
mode: CompileMode, // 'create' | 'edit' | 'detail'
ctx?: HttpContextLike // defaults to makeFakeContext()
): ResourceDescriptorA thin wrapper over DescriptorCompiler.compile. The context is what makes
canSee and ability-based omission observable, so pass a real-shaped fake when
the assertion is about visibility; omit it when it is about structure.
import { compileFor, makeFakeContext } from '@adonia/core/testing'
const create = compileFor(resource, 'create', makeFakeContext({ user: { id: 7 } }))
const detail = compileFor(resource, 'detail')
create.mode // 'create'
create.resource.slug // 'posts'
create.schema?.type // 'grid' — the root is ALWAYS a grid (protocol §2 rule 4)
detail.resource.abilities.view // resolved through the §12 chainschema is optional on ResourceDescriptor because an index descriptor
carries a table instead — see the protocol.
stableStringify
declare function stableStringify(value: unknown): stringJSON.stringify with recursively sorted object keys and two-space indent.
Array order is preserved, because array order is semantic on the wire. Two
compilations of the same descriptor produce byte-identical output, so a
snapshot diff is always a semantic diff and never key-order noise.
import { compileFor, stableStringify } from '@adonia/core/testing'
const a = stableStringify({ b: 1, a: { d: 2, c: 3 } })
const b = stableStringify({ a: { c: 3, d: 2 }, b: 1 })
a === b // true
// What a golden file holds — see packages/core/tests/helpers/snapshot.ts.
const golden = `${stableStringify(compileFor(resource, 'create'))}\n`
golden.startsWith('{\n "mode": "create"') // keys sorted: mode before resourcevalidateWith
declare function validateWith(
resource: BaseResource,
mode: ValidatorMode, // 'create' | 'edit'
input: Record<string, unknown>,
ctx?: HttpContextLike,
record?: unknown
): Promise<ValidationResult> // { outcome, errors, output }Runs the whole §11.2 submit pipeline — prune keys hidden for the R1 evaluation
state, compile and run the Vine validator, re-derive sets — and returns
instead of throwing. outcome is 'success' or 'failure', errors is the
Inertia inputErrorsBag map (field → first message), and output is the
validated payload or null.
record is forwarded to record-dependent canSee predicates. That is how
per-mode projection is expressed: the compilers pass a record in edit and
nothing in create, so record presence is the mode distinction at compile
time.
import { validateWith } from '@adonia/core/testing'
// `subtitle` is hidden for this state, so it is pruned before Vine runs —
// a bypassing client cannot even provoke a shape error for it.
const pruned = await validateWith(resource, 'create', {
title: 'hello',
subtitle: 'sneaked',
})
pruned.outcome // 'success'
pruned.output // { title: 'hello' }
// Flip `title` and the same key becomes required.
const missing = await validateWith(resource, 'create', { title: 'special' })
missing.outcome // 'failure'
missing.errors['subtitle'] // 'The subtitle field must be defined'Anything that is not a Vine E_VALIDATION_ERROR is re-thrown, so a broken
compiler surfaces as an error rather than as a false 'failure'.
assertValidatorKeys
declare function assertValidatorKeys(
resource: BaseResource,
mode: ValidatorMode,
submission: Record<string, unknown>,
keys: readonly string[]
): voidAsserts the exact validator key set for the submission’s R1 state (defaults merged under the submitted values). Exactness is the point: a subset assertion lets a leaking field pass unnoticed. Order does not matter — both sides are sorted before comparison, and the failure message prints both sets.
import { assertValidatorKeys } from '@adonia/core/testing'
// `editorNote` is virtual and `subtitle` is hidden for this state.
assertValidatorKeys(resource, 'create', { title: 'hello' }, ['title'])
assertValidatorKeys(resource, 'create', { title: 'special' }, ['title', 'subtitle'])assertValidatorOmits
declare function assertValidatorOmits(
resource: BaseResource,
mode: ValidatorMode,
submission: Record<string, unknown>,
keys: readonly string[]
): Promise<void>Invariant (a) of the mirroring rule, as a probe. It rewrites each named key to
GARBAGE_PROBE_VALUE (12345, a number where every Phase-1 field expects a
string) and asserts the result has neither an error entry nor an output
entry for it. An error entry would prove the key reached the validator; an
output entry would prove it survived.
import { assertValidatorOmits } from '@adonia/core/testing'
await assertValidatorOmits(resource, 'create', { title: 'hello' }, ['subtitle'])
await assertValidatorOmits(resource, 'edit', { title: 'hello' }, ['subtitle'])assertFillOmits
declare function assertFillOmits(
resource: BaseResource,
mode: ValidatorMode,
payload: Record<string, unknown>,
keys: readonly string[]
): voidInvariant (b): values submitted for keys never reach the model. The fill set
is derived for the payload’s R1 state and applied to a plain-object model
stand-in, so the assertion is about the fill set — a real Lucid save()
belongs to an integration suite. The failure message prints the fill set that
did let the key through.
This is the mass-assignment assertion. Write it for every field a bypassing client must not be able to write: hidden fields, disabled fields, virtual fields, and any foreign key the form does not expose.
import { assertFillOmits } from '@adonia/core/testing'
// A tampered payload: the client sends keys the rendered form never had.
const tampered = { title: 'hello', subtitle: 'sneaked', editorNote: 'internal' }
assertFillOmits(resource, 'create', tampered, ['subtitle', 'editorNote'])
assertFillOmits(resource, 'edit', tampered, ['subtitle', 'editorNote'])assertRequiredWhen
declare function assertRequiredWhen(
resource: BaseResource,
mode: ValidatorMode,
submission: Record<string, unknown>,
key: string
): Promise<void>Invariant (c), asserted in both directions for one key: omitting it fails with
an error keyed to that field, and including REQUIRED_PROBE_VALUE satisfies
the requirement. The second half matters — a validator that rejects everything
would pass the first half alone.
import { assertRequiredWhen } from '@adonia/core/testing'
// `title: 'special'` makes `subtitle` required, even though a bypassing
// client never rendered it.
await assertRequiredWhen(resource, 'create', { title: 'special' }, 'subtitle')
await assertRequiredWhen(resource, 'edit', { title: 'special' }, 'subtitle')countQueries and assertQueryCount
interface QueryEventSource {
on(event: 'query', listener: (query: unknown) => void): unknown
off(event: 'query', listener: (query: unknown) => void): unknown
}
declare function countQueries(source: QueryEventSource, fn: () => unknown | Promise<unknown>): Promise<unknown[]>
declare function assertQueryCount(fn: () => unknown | Promise<unknown>, expected: number, source: QueryEventSource): Promise<void>Note the argument orders differ: countQueries takes the source first,
assertQueryCount takes it last. countQueries returns the raw query events
for callers that assert on the SQL too; assertQueryCount is the count-only
shorthand. Both subscribe for the duration of fn and unsubscribe in a
finally, so a throwing fn leaves no listener behind.
The emitter is injected rather than discovered, because counting a real
database needs a booted app’s connection, which core’s hermetic unit suite does
not have. Any Node-style 'query' emitter works — a knex client, a Lucid
connection, or a fake:
import { assertQueryCount, countQueries, type QueryEventSource } from '@adonia/core/testing'
/** The shape a knex client already has. */
class FakeExecutor implements QueryEventSource {
readonly #listeners = new Set<(query: unknown) => void>()
on(_event: 'query', listener: (query: unknown) => void): this {
this.#listeners.add(listener)
return this
}
off(_event: 'query', listener: (query: unknown) => void): this {
this.#listeners.delete(listener)
return this
}
run(statements: readonly string[]): void {
for (const sql of statements) for (const listener of this.#listeners) listener({ sql })
}
}
const executor = new FakeExecutor()
await assertQueryCount(() => executor.run(['select count(*) from posts', 'select * from posts']), 2, executor)
const queries = await countQueries(executor, () => executor.run(['select 1']))
queries // [{ sql: 'select 1' }]The §18 zero-per-row-query budget
§18 requires index serialization to be O(rows) with zero per-row queries: every relation or aggregate a column needs comes from the eager-load stage. Column costs, as the pipeline actually issues them:
| Call | Statements |
|---|---|
paginate() |
2 — one COUNT(*), one page select |
withCount / withAggregate (C.count, C.sum) |
0 — correlated sub-selects fold into the page select |
preload(relation) (C.relation) |
1 — one batched whereIn per relation, whatever the row count |
| reading an unpreloaded relation | 1 per row — the N+1 this budget forbids |
So a page with one relation column and any number of aggregate columns costs a fixed 3 statements, and the assertion that proves it is a comparison across two row counts, not a single magic number:
import { countQueries, type QueryEventSource } from '@adonia/core/testing'
declare const client: QueryEventSource // db.connection().getReadClient() in a booted app
declare function indexRequest(perPage: number): Promise<unknown>
await indexRequest(5) // warm-up: the first statement may carry connection chatter
const small = await countQueries(client, () => indexRequest(5))
const large = await countQueries(client, () => indexRequest(50))
small.length === large.length // the budget: count must not grow with rows
large.length === 3 // count + page select + one batched preloadA column that forgets contributeHints does not merely render wrong — it turns
a 50-row page into 50 extra statements. packages/core/tests/query_count.spec.ts
asserts both halves against a fake connection whose unpreloaded relations are
lazy getters that emit a query when touched, including a deliberate N+1 that
proves the harness detects one; examples/blog-admin/tests/functional/adonia_pipeline.spec.ts
repeats it against a real seeded database. See
the index table and
the table pipeline for what produces the hints.
Mirroring fixtures
The §11.2 rule — every declarative reactive rule affecting visibility or
requiredness MUST be enforced in the compiled validator, so client evaluation
is purely cosmetic — is tested from a shared corpus of JSON fixtures in
docs/reactivity/fixtures/*.fixture.json. The corpus is the oracle for both
sides: packages/core/tests/mirroring.spec.ts drives the server compilers and
packages/ui/tests/reactivity_fixtures.ts drives the client evaluator, both
reading the same files by relative path so neither can drift from the other.
The format, the R1–R6 rules and the test matrix are normative in
the mirroring test design; the server
half of the evaluator is described in
server-side reactivity.
Add a fixture whenever you add a reactive pattern to a resource. It costs one JSON file and buys the whole invariant.
resourceFromFixture
import type { MirroringFixture } from '@adonia/core/testing'
declare function resourceFromFixture(fixture: MirroringFixture): BaseResourceBuilds a throwaway BaseResource from a fixture by replaying it through the
authoring DSL. The fixture’s type selects a field factory;
default/required/virtual and reactive rules become their matching DSL calls.
Fixture hiddenOn, however, names the fixture modes create and edit, not the
public field projection contexts (form, detail, index). The adapter therefore
uses a record-sensitive canSee: descriptor/validator compilers pass no record for
create and a record for edit. Choice fields declare no options in the format, so the
harness derives enum members from values used by the fixture. An unknown type
throws and lists the known ones.
import {
assertFillOmits,
assertValidatorKeys,
resourceFromFixture,
type MirroringFixture,
} from '@adonia/core/testing'
const fixture: MirroringFixture = {
version: 1,
name: 's3-w1-10-smoke',
pattern: 'w1-10-smoke',
classification: 'jsonlogic',
fields: [
{ key: 'title', type: 'text', required: true },
{
key: 'subtitle',
type: 'text',
reactive: {
visibleWhen: { '===': [{ var: 'title' }, 'special'] },
requiredWhen: { '===': [{ var: 'title' }, 'special'] },
},
},
],
cases: [
{
state: { title: 'hello', subtitle: 'sneaked' },
expect: {
validator: { keys: ['title'], requires: ['title'] },
fill: { keys: ['title'] },
},
},
],
}
const built = resourceFromFixture(fixture)
const state = fixture.cases[0]!.state
assertValidatorKeys(built, 'create', state, ['title'])
assertFillOmits(built, 'create', state, ['subtitle'])runMirroringFixture
import type { MirroringFixture } from '@adonia/core/testing'
declare function runMirroringCase(
fixture: MirroringFixture,
caseIndex: number,
mode: ValidatorMode
): Promise<MirroringCaseRun>
declare function runMirroringFixture(fixture: MirroringFixture): Promise<MirroringFixtureRun>runMirroringCase drives one case × mode row: exact keysFor / requiredFor /
fillFor set equality against case.expect, the garbage probe for every hidden
key, the fill simulation with the R6 derivations overlaid, the requiredness
check for every expected-required key, and — when the fixture declares them —
the exact sets re-derivation map and the capability-URL block on the compiled
node. It throws on the first violation with the fixture, case number and mode in
the message, and otherwise returns the row report.
runMirroringFixture is the loop over that: every case, in case.mode when the
case pins one and in both create and edit otherwise. One call per fixture
is the whole conformance check.
import { runMirroringFixture, type MirroringFixture } from '@adonia/core/testing'
/** One test per fixture; the driver throws on the first violated invariant. */
export async function checkFixture(fixture: MirroringFixture): Promise<number> {
const report = await runMirroringFixture(fixture)
for (const run of report.runs) {
// e.g. "s3-w1-10-smoke case #1 × create — fill: title"
console.log(`${report.fixture} case #${run.caseIndex + 1} × ${run.mode} — fill: ${run.fillKeys.join(', ')}`)
}
return report.runs.length
}Each MirroringCaseRun reports fixture, caseIndex, mode, hiddenKeys,
requiredKeys and fillKeys, which is enough to assert the shape of the run
on top of the invariants the driver already enforced — the repo’s suite does
exactly that, so a fixture that accidentally expects nothing still fails.
packages/core/tests/mirroring.spec.ts discovers the corpus by listing
docs/reactivity/fixtures/ at load time and enrols one Japa test per case ×
mode row, which is why a new fixture file needs no test-file edit. A fixture
classified server-closure may carry cases: [] on purpose: cross-field
validation must contribute nothing to the wire grammar, and absence is the
assertion.
Test style in this repo
Japa in @adonia/core and @adonia/devtools. Groups are named after the thing
under test with the spec section in parentheses, assert arrives destructured
from the test context, and a file-level TSDoc block states which plan item and
spec section the suite discharges.
Neither runner is installed for the docs typecheck program, so the two samples
below declare the runner functions they call instead of importing them. Read
declare const test as import { test } from '@japa/runner' and the Vitest
declarations as import { describe, it, expect, beforeEach, vi } from 'vitest'
/ import { renderHook } from '@testing-library/react'. Everything else —
including the shape of test.group, assert and result.current — is the real
thing.
import { compileFor, stableStringify } from '@adonia/core/testing'
test.group('descriptor snapshots — canonical text resource (W1-10)', () => {
for (const mode of ['create', 'edit', 'detail'] as const) {
test(`mode=${mode} matches the committed protocol snapshot`, ({ assert }) => {
const descriptor = compileFor(resource, mode)
assert.equal(`${stableStringify(descriptor)}\n`, goldenFor(mode))
})
}
})The real file is packages/core/tests/descriptor_snapshot.spec.ts; goldenFor
stands in for packages/core/tests/helpers/snapshot.ts, which reads the
committed <name>.snap.json or rewrites it under UPDATE_SNAPSHOTS=1.
Vitest plus Testing Library in @adonia/ui, with nested describe blocks
naming the protocol section and it naming the behaviour:
import { useFormEngine, type ResourceDescriptor } from '@adonia/ui'
declare const descriptor: ResourceDescriptor
describe('useFormEngine', () => {
beforeEach(() => vi.clearAllMocks())
describe('state tree (protocol v1 §7)', () => {
it('builds the flat state from node keys, applying defaults under incoming state', () => {
const { result } = renderHook(() => useFormEngine(descriptor, { title: 'Hello' }))
expect(result.current.state['title']).toBe('Hello')
})
})
})The real file is packages/ui/tests/use_form_engine.test.tsx.
Three conventions hold across all of them:
- Exact sets, never subsets.
assert.deepEqual(sorted(actual), sorted(expected))over a validator key set, a fill set or a hint set. A subset assertion is how a leak ships. - Assert the failure too. Every helper in this module has a test proving it
fails when the invariant is violated —
assertFillOmitsis checked against a key that is genuinely fillable, and the query-count suite contains a deliberate N+1. A guard that cannot fail is not a guard. - Hermetic by default. Core’s suite imports nothing from
@adonisjs/*at runtime. Anything needing a real connection goes toexamples/blog-admin/tests/functional/.