Testing Forms
Test controlled inputs, validation, error messages, file uploads, and Server Action forms.
Search across all documentation pages
Test controlled inputs, validation, error messages, file uploads, and Server Action forms.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const user = userEvent.setup();
// Type into an input
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
// Clear and retype
await user.clear(screen.getByLabelText(/email/i));
await user.type(screen.getByLabelText(/email/i), "bob@example.com");
// Select from a dropdown
await user.selectOptions(screen.getByLabelText(/role/i), "admin");
// Check a checkbox
await user.click(screen.getByLabelText(/agree to terms/i));
// Submit a form
await user.click(screen.getByRole("button", { name: /submit/i }));
// Assert validation errors
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});When to reach for this: Whenever you test any component with form inputs, validation, or submission logic.
// src/components/registration-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const registrationSchema = z
.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type RegistrationData = z.infer<typeof registrationSchema>;
interface RegistrationFormProps {
onSubmit: (data: RegistrationData) => Promise<void>;
}
export function RegistrationForm({ onSubmit }: RegistrationFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)} aria-label="Registration">
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} />
{errors.name && <p role="alert">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p role="alert">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Registering..." : "Register"}
</button>
</form>
);
}// src/components/registration-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
import { RegistrationForm } from "./registration-form";
describe("RegistrationForm", () => {
const user = userEvent.setup();
const mockSubmit = vi.fn().mockResolvedValue(undefined);
async function fillForm(overrides: Record<string, string> = {}) {
const defaults = {
name: "Alice Johnson",
email: "alice@example.com",
password: "password123",
confirmPassword: "password123",
};
const values = { ...defaults, ...overrides };
await user.type(screen.getByLabelText(/^name$/i), values.name);
await user.type(screen.getByLabelText(/email/i), values.email);
await user.type(screen.getByLabelText(/^password$/i), values.password);
await user.type(
screen.getByLabelText(/confirm password/i),
values.confirmPassword
);
}
it("submits valid form data", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm();
await user.click(screen.getByRole("button", { name: /register/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith(
{
name: "Alice Johnson",
email: "alice@example.com",
password: "password123",
confirmPassword: "password123",
},
expect.anything() // react-hook-form passes event as second arg
);
});
});
it("shows validation errors for empty fields", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await user.click(screen.getByRole("button", { name: /register/i }));
const alerts = await screen.findAllByRole("alert");
expect(alerts.length).toBeGreaterThanOrEqual(3);
expect(mockSubmit).not.toHaveBeenCalled();
});
it("validates email format", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm({ email: "not-an-email" });
await user.click(screen.getByRole("button", { name: /register/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
it("validates minimum password length", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm({ password: "short", confirmPassword: "short" });
await user.click(screen.getByRole("button", { name: /register/i }));
expect(
await screen.findByText(/password must be at least 8/i)
).toBeInTheDocument();
});
it("validates password confirmation match", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm({ confirmPassword: "different123" });
await user.click(screen.getByRole("button", { name: /register/i }));
expect(
await screen.findByText(/passwords do not match/i)
).toBeInTheDocument();
});
it("disables button while submitting", async () => {
const slowSubmit = vi.fn(() => new Promise(() => {})); // never resolves
render(<RegistrationForm onSubmit={slowSubmit} />);
await fillForm();
await user.click(screen.getByRole("button", { name: /register/i }));
await waitFor(() => {
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button")).toHaveTextContent("Registering...");
});
});
});What this demonstrates:
fillForm to reduce test duplicationfindByText/findAllByRole for async validation renderingzodResolver adapter runs Zod schema validation and maps errors to field namesuserEvent.type simulates real keystrokes including focus, keydown, keypress, keyup, and input eventsrole="alert" on error messages ensures they are announced by screen readers and queryable by getByRole("alert")isSubmitting is managed by react-hook-form and is true while the onSubmit handler's Promise is pendingTesting file uploads:
it("uploads a file", async () => {
const user = userEvent.setup();
render(<AvatarUpload onUpload={vi.fn()} />);
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
const input = screen.getByLabelText(/upload avatar/i);
await user.upload(input, file);
expect(input.files).toHaveLength(1);
expect(input.files![0].name).toBe("avatar.png");
});Testing Server Action forms with useActionState:
// src/components/contact-form.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/actions";
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, {
message: "",
errors: {},
});
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{state.errors.email && <p role="alert">{state.errors.email}</p>}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" />
{state.errors.message && <p role="alert">{state.errors.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
{state.message && <p role="status">{state.message}</p>}
</form>
);
}// src/components/contact-form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
// Mock the server action
vi.mock("@/app/actions", () => ({
submitContact: vi.fn(),
}));
// Mock useActionState to control the form state
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
useActionState: vi.fn(),
};
});
import { useActionState } from "react";
import { ContactForm } from "./contact-form";
describe("ContactForm", () => {
it("renders errors from server action", () => {
vi.mocked(useActionState).mockReturnValue([
{ message: "", errors: { email: "Email is required" } },
vi.fn(),
false,
]);
render(<ContactForm />);
expect(screen.getByText("Email is required")).toBeInTheDocument();
});
it("shows success message", () => {
vi.mocked(useActionState).mockReturnValue([
{ message: "Message sent!", errors: {} },
vi.fn(),
false,
]);
render(<ContactForm />);
expect(screen.getByRole("status")).toHaveTextContent("Message sent!");
});
});// Type the form data helper
type FormValues = {
name: string;
email: string;
password: string;
confirmPassword: string;
};
async function fillForm(overrides: Partial<FormValues> = {}) {
const defaults: FormValues = {
name: "Alice",
email: "alice@example.com",
password: "password123",
confirmPassword: "password123",
};
// ...
}Using fireEvent.change instead of userEvent.type -- fireEvent.change sets the value directly without triggering keystroke events, which may skip validation logic. Fix: Always use userEvent.type().
Testing validation on render -- react-hook-form validates on submit by default, not on change. Fix: Submit the form first, then assert errors, unless you configure mode: "onChange".
Regex anchors in label queries -- screen.getByLabelText(/password/i) matches both "Password" and "Confirm Password". Fix: Use anchored regex: /^password$/i.
Server Action forms cannot be fully tested in jsdom -- useActionState with a real Server Action requires the Next.js server. Fix: Mock useActionState to control form state, or test Server Actions as unit functions separately.
File input type confusion -- userEvent.upload requires the actual <input type="file"> element. Fix: Query the input directly by label, not the wrapping button.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Playwright E2E | You need to test the full form flow including server-side validation | Unit tests with fast feedback are sufficient |
| Storybook interaction testing | You have form stories and want to test within Storybook | You need full mocking and assertion capabilities |
| Cypress Component Testing | You want real browser form behavior with component isolation | You want fast Node-based tests |
fireEvent.change sets the value directly without triggering keystroke events.userEvent.type() simulates real keystrokes including focus, keydown, keypress, keyup, and input.By default, react-hook-form validates on submit, not on change. Submit the form first, then assert errors. Configure mode: "onChange" if you need validation on every keystroke.
const file = new File(["content"], "avatar.png", { type: "image/png" });
const input = screen.getByLabelText(/upload avatar/i);
await user.upload(input, file);
expect(input.files).toHaveLength(1);The regex /password/i matches any label containing "password". Use anchored regex:
screen.getByLabelText(/^password$/i);Create a helper function:
async function fillForm(overrides: Partial<FormValues> = {}) {
const values = { ...defaults, ...overrides };
await user.type(screen.getByLabelText(/email/i), values.email);
// ...
}Mock useActionState to control the form state directly:
vi.mocked(useActionState).mockReturnValue([
{ message: "", errors: { email: "Required" } },
vi.fn(),
false,
]);Pass an onSubmit that never resolves, then assert:
const slowSubmit = vi.fn(() => new Promise(() => {}));
// ... fill and submit form
await waitFor(() => {
expect(screen.getByRole("button")).toBeDisabled();
});No. useActionState with a real Server Action requires the Next.js server. Mock useActionState in unit tests, or test Server Actions as standalone async functions separately.
type FormValues = {
name: string;
email: string;
password: string;
};
async function fillForm(overrides: Partial<FormValues> = {}) {
const values: FormValues = { ...defaults, ...overrides };
// ...
}await user.selectOptions(screen.getByLabelText(/role/i), "admin");The zodResolver adapter runs your Zod schema on submission and maps validation errors to field names. In tests, you trigger validation by submitting the form, then assert the error messages appear.
await user.click(screen.getByLabelText(/agree to terms/i));
expect(screen.getByLabelText(/agree to terms/i)).toBeChecked();Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥