Test definitions
Import createDefinitionTester from form-please/testing when a test needs to
answer two questions directly: what does this definition resolve to, and what
exactly changes after a realistic form operation?
The tester is headless. It does not require Testing Library, a DOM, or React rendering. It calls the same definition resolver, field-options executor, and managed value coordinator as generated forms.
Inspect and change a definition
Supply complete schema input and the same context used by kit.useForm:
test("shows every definition effect of changing kind", () => {
const tester = createDefinitionTester(definition, {
context: { locale: "en" },
values: { contacts: [], kind: "person", taxId: "" },
})
expect(tester.field("taxId").visible).toBe(false)
const change = tester.setValue("kind", "company")
expect(change.after.field("taxId").visible).toBe(true)
expect(change.changes).toEqual([
{
changes: [{ after: true, before: false, property: "visible" }],
kind: "field",
nodeId: "field:taxId",
path: "taxId",
type: "changed",
},
])
})field(path), array(path), and node(id) are strict selectors. A missing or
ambiguous match throws an error that lists the available paths or IDs. Hidden
nodes remain selectable with visible: false, matching definition resolution.
The tester is stateful, like one mounted form. Each operation advances
tester.current and returns immutable before and after inspections.
changes contains every added, removed, or changed public node property. Use an
exact equality assertion when a rule must not affect anything else.
Snapshots expose resolved rendering inputs such as labels, props, slot options, layout, visibility, and interaction flags. They deliberately omit resolver callbacks, context objects, render components, option trackers, and private resolved nodes.
Field and array snapshots include the node errorDisplay. The value is
undefined when the node does not set it. The tester does not read the
errorDisplay of the kit or useForm.
Include the definition's managed-update policy
Configure hooks and middleware once in kit.defineForm. The tester uses that
same policy automatically:
const normalizeTaxId: FormMiddleware<Input, Context> =
() => (next) => (transaction) => {
const taxId = transaction.nextValues.taxId.trim().toUpperCase()
if (taxId === transaction.nextValues.taxId) {
return next(transaction.patches)
}
return next([
...transaction.patches,
{ op: "replace", path: ["taxId"], value: taxId },
])
}
test("runs the real managed update lifecycle", () => {
const managedDefinition = kit.defineForm(
schema,
(ui) => [
ui.field("kind", { control: "select", options: kindOptions }),
ui.field("taxId", { control: "text", label: "Tax ID" }),
],
{
beforeUpdate(draft, transaction) {
if (
transaction.source.type === "control" &&
transaction.source.path === "kind" &&
transaction.nextValues.kind === "person"
) {
draft.taxId = ""
}
},
middleware: [normalizeTaxId],
},
)
const tester = createDefinitionTester(managedDefinition, {
context: { locale: "en" },
values: { contacts: [], kind: "company", taxId: "GB123" },
})
const typed = tester.setValue("taxId", " gb456 ")
expect(typed.after.values.taxId).toBe("GB456")
const change = tester.setValue("kind", "person")
expect(change.committed).toBe(true)
expect(change.after.values.taxId).toBe("")
expect(change.transaction?.source).toEqual({
path: "kind",
type: "control",
})
})setValue(path, value) proposes a generated control transaction.
update(recipe) proposes the same update transaction as form.update.
Both execute beforeUpdate, the fixed middleware chain, the terminal commit,
and afterUpdate through the production coordinator.
A managed result includes:
committed, which is false for a no-op or cancelled proposal;- the final committed
transaction, including effective patches and values; - the middleware
result; - the definition
before,after, and exactchanges.
The tester creates one independent middleware chain from the definition and preserves its state across operations. A mounted form using the same definition creates another independent chain. A thrown hook or middleware error follows the same commit and propagation semantics in both runtimes.
Test context and generated arrays
Context and form interaction flags are rerender inputs, not value proposals. They re-resolve the definition without calling update hooks or middleware:
const tester = createDefinitionTester(definition, {
context: { locale: "en" },
values: { contacts: [], kind: "company", taxId: "" },
})
const localeChange = tester.setContext({ locale: "fr" })
expect(localeChange.after.field("taxId").label).toBe("fr: Tax ID")
const appended = tester.append("contacts")
expect(appended.transaction?.source).toEqual({
action: "append",
index: 0,
path: "contacts",
type: "array",
})
expect(appended.after.field("contacts.0.name").label).toBe("Contact")Use setContext, setDisabled, setReadOnly, or one rerender call. Verify a
context change through its resolved effects in after and changes. The fixed
definition callbacks receive the new context on the next managed proposal.
append, remove, and move use the current generated array, its real
itemDefault, an array transaction source, and the production structural
patch constraints. Disabled or read-only generated arrays do not commit an
action. Invalid paths and indexes fail with a precise error.
Resolve field options
await tester.resolveOptions(path) returns static options or invokes the same
tracked executor used by generated controls. It supplies the correct fragment
value scope, current context, and an AbortSignal.
Resolver rejection is surfaced to the test instead of being hidden as an empty list. A non-array result throws the same contract error as the rendered field. The mounted UI still applies its existing failure policy and diagnostics around that shared executor.
Know the boundary
The tester owns definition resolution and managed value proposals. It does not reimplement React Hook Form (RHF) validation, dirty or touched state, submission, focus, or DOM accessibility. Use component tests for those behaviors. This keeps definition tests fast without creating a second form runtime that can drift from production.