Testing Forms
Setup
@domphy/form runs in any JavaScript environment — no DOM required for state and validation tests. Tests that involve reactivity need flushSync() to drain the reactive queue synchronously.
npm install -D vitest @vitest/uivitest.config.ts:
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node", // or "jsdom" if testing DOM output
},
})Basic: test field values and validation
import { createForm } from "@domphy/form/domphy"
import { flushSync } from "@domphy/core"
import { describe, it, expect } from "vitest"
describe("email field", () => {
it("validates email format on change", () => {
const form = createForm<{ email: string }>({
defaultValues: { email: "" },
onSubmit: () => {},
})
const emailField = form.field<string>("email", {
validators: {
onChange: ({ value }) => value.includes("@") ? undefined : "Invalid email",
},
})
// Start: no errors (field not yet touched)
expect(emailField.errors()).toEqual([])
// Type an invalid value
emailField.handleChange("notanemail")
flushSync()
expect(emailField.errors()).toEqual(["Invalid email"])
// Fix it
emailField.handleChange("user@example.com")
flushSync()
expect(emailField.errors()).toEqual([])
form.destroy()
})
})flushSync() from @domphy/core drains all pending reactive updates synchronously, so assertions after .handleChange() see the updated state immediately.
Test form-level validation
import { createForm } from "@domphy/form/domphy"
import { flushSync } from "@domphy/core"
it("rejects date range where end is before start", () => {
const form = createForm<{ start: string; end: string }>({
defaultValues: { start: "", end: "" },
validators: {
onChange: ({ value }) => {
if (value.start && value.end && value.end < value.start) {
return "End must be after start"
}
},
},
onSubmit: () => {},
})
const startField = form.field<string>("start", {})
const endField = form.field<string>("end", {})
startField.handleChange("2024-06-10")
endField.handleChange("2024-06-01")
flushSync()
expect(form.state().errors).toContain("End must be after start")
expect(form.isValid()).toBe(false)
endField.handleChange("2024-06-20")
flushSync()
expect(form.state().errors).toEqual([])
expect(form.isValid()).toBe(true)
form.destroy()
})Test async validation
For async validators, await the field's validate promise directly via field.api.validate():
import { createForm } from "@domphy/form/domphy"
import { vi } from "vitest"
it("checks username availability asynchronously", async () => {
const checkUsername = vi.fn().mockResolvedValue(false) // false = taken
const form = createForm<{ username: string }>({
defaultValues: { username: "" },
onSubmit: () => {},
})
const usernameField = form.field<string>("username", {
validators: {
onChangeAsync: async ({ value }) => {
if (!value) return undefined
const available = await checkUsername(value)
return available ? undefined : "Username already taken"
},
},
})
// Trigger the async validator
usernameField.handleChange("alice")
// Wait for the async validator to complete
await usernameField.api.validate("change")
expect(usernameField.errors()).toEqual(["Username already taken"])
expect(checkUsername).toHaveBeenCalledWith("alice")
form.destroy()
})Test submission
import { createForm } from "@domphy/form/domphy"
import { flushSync } from "@domphy/core"
import { vi } from "vitest"
it("calls onSubmit with valid form values", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined)
const form = createForm<{ email: string; password: string }>({
defaultValues: { email: "", password: "" },
validators: {
onSubmit: ({ value }) =>
value.email && value.password ? undefined : "All fields required",
},
onSubmit,
})
const emailField = form.field<string>("email", {})
const passwordField = form.field<string>("password", {})
// Fill the form
emailField.setValue("user@example.com")
passwordField.setValue("secret123")
flushSync()
// Submit
await form.handleSubmit()
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].value).toEqual({
email: "user@example.com",
password: "secret123",
})
form.destroy()
})
it("does not call onSubmit when validation fails", async () => {
const onSubmit = vi.fn()
const form = createForm<{ email: string }>({
defaultValues: { email: "" },
validators: {
onSubmit: ({ value }) => value.email ? undefined : "Email required",
},
onSubmit,
})
form.field<string>("email", {})
await form.handleSubmit()
expect(onSubmit).not.toHaveBeenCalled()
expect(form.state().errors).toContain("Email required")
form.destroy()
})Test field array operations
Array mutations go through form.form (the underlying FormApi):
import { createForm } from "@domphy/form/domphy"
import { flushSync } from "@domphy/core"
it("manages a dynamic tag list", () => {
const form = createForm<{ tags: string[] }>({
defaultValues: { tags: ["typescript"] },
onSubmit: () => {},
})
const tagsField = form.field<string[]>("tags", {})
expect(tagsField.value()).toEqual(["typescript"])
// Push via the underlying FieldApi
tagsField.api.pushValue("domphy")
flushSync()
expect(tagsField.value()).toEqual(["typescript", "domphy"])
// Remove first element via the underlying FormApi
form.form.removeFieldValue("tags", 0)
flushSync()
expect(tagsField.value()).toEqual(["domphy"])
form.destroy()
})Test reset behavior
it("resets to defaultValues", () => {
const form = createForm<{ name: string }>({
defaultValues: { name: "Alice" },
onSubmit: () => {},
})
const nameField = form.field<string>("name", {})
nameField.handleChange("Bob")
flushSync()
expect(nameField.value()).toBe("Bob")
expect(form.state().isDirty).toBe(true)
form.reset()
flushSync()
expect(nameField.value()).toBe("Alice")
expect(form.state().isPristine).toBe(true)
form.destroy()
})Access field meta in tests
field.meta() (called without a listener) returns the current metadata snapshot:
it("tracks touched state after blur", () => {
const form = createForm<{ email: string }>({
defaultValues: { email: "" },
onSubmit: () => {},
})
const emailField = form.field<string>("email", {})
expect(emailField.meta().isTouched).toBe(false)
emailField.handleBlur()
flushSync()
expect(emailField.meta().isTouched).toBe(true)
form.destroy()
})Validate all fields programmatically
form.form.validateAllFields(cause) runs all field validators without requiring a submit:
it("reports all errors at once", async () => {
const form = createForm<{ name: string; email: string }>({
defaultValues: { name: "", email: "" },
onSubmit: () => {},
})
const nameField = form.field<string>("name", {
validators: { onSubmit: ({ value }) => value ? undefined : "Name required" },
})
const emailField = form.field<string>("email", {
validators: { onSubmit: ({ value }) => value.includes("@") ? undefined : "Invalid email" },
})
await form.form.validateAllFields("submit")
expect(nameField.errors()).toContain("Name required")
expect(emailField.errors()).toContain("Invalid email")
form.destroy()
})