Skip to content

Testing your panel

Test resources, policies, queries, and complete authenticated panel flows.

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]
  1. The compiled descriptor — what the client renders. Snapshot it with compileFor + stableStringify. Failure mode: silent protocol drift.
  2. The compiled validator — what the server accepts. Drive it with validateWith / assertValidatorKeys. Failure mode: a rule the UI enforces and the server does not.
  3. 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 merged

makeFakeContext({ 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()
): ResourceDescriptor

A 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 chain

schema is optional on ResourceDescriptor because an index descriptor carries a table instead — see the protocol.

stableStringify

declare function stableStringify(value: unknown): string

JSON.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 resource

validateWith

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[]
): void

Asserts 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[]
): void

Invariant (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 preload

A 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): BaseResource

Builds 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 — assertFillOmits is 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 to examples/blog-admin/tests/functional/.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close