Testing Forms
Test controlled inputs, validation, error messages, file uploads, and Server Action forms.
Recipe
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.
Working Example
// 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:
- Testing react-hook-form with Zod validation
- Helper function
fillFormto reduce test duplication - Testing specific validation rules (email format, min length, password match)
- Testing disabled state during submission
- Using
findByText/findAllByRolefor async validation rendering
Deep Dive
How It Works
- React Hook Form validates on submission by default -- errors appear after clicking submit
- The
zodResolveradapter runs Zod schema validation and maps errors to field names userEvent.typesimulates real keystrokes including focus, keydown, keypress, keyup, and input eventsrole="alert"on error messages ensures they are announced by screen readers and queryable bygetByRole("alert")isSubmittingis managed by react-hook-form and istruewhile theonSubmithandler's Promise is pending
Variations
Testing 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!");
});
});TypeScript Notes
// 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",
};
// ...
}Gotchas
-
Using
fireEvent.changeinstead ofuserEvent.type--fireEvent.changesets the value directly without triggering keystroke events, which may skip validation logic. Fix: Always useuserEvent.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 --
useActionStatewith a real Server Action requires the Next.js server. Fix: MockuseActionStateto control form state, or test Server Actions as unit functions separately. -
File input type confusion --
userEvent.uploadrequires the actual<input type="file">element. Fix: Query the input directly by label, not the wrapping button.
Alternatives
| 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 |
FAQs
Why should I use userEvent.type() instead of fireEvent.change()?
fireEvent.changesets the value directly without triggering keystroke events.- This can skip validation logic that depends on input/keydown events.
userEvent.type()simulates real keystrokes including focus, keydown, keypress, keyup, and input.
When do validation errors appear with react-hook-form?
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.
How do I test file uploads with Testing Library?
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);Gotcha: Why does getByLabelText(/password/i) match "Confirm Password" too?
The regex /password/i matches any label containing "password". Use anchored regex:
screen.getByLabelText(/^password$/i);How do I reduce test duplication when filling out forms?
Create a helper function:
async function fillForm(overrides: Partial<FormValues> = {}) {
const values = { ...defaults, ...overrides };
await user.type(screen.getByLabelText(/email/i), values.email);
// ...
}How do I test a Server Action form that uses useActionState?
Mock useActionState to control the form state directly:
vi.mocked(useActionState).mockReturnValue([
{ message: "", errors: { email: "Required" } },
vi.fn(),
false,
]);How do I test that the submit button is disabled while submitting?
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();
});Gotcha: Can Server Action forms be fully tested in jsdom?
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.
How do I type the form helper function in TypeScript?
type FormValues = {
name: string;
email: string;
password: string;
};
async function fillForm(overrides: Partial<FormValues> = {}) {
const values: FormValues = { ...defaults, ...overrides };
// ...
}How do I select a dropdown option in a test?
await user.selectOptions(screen.getByLabelText(/role/i), "admin");How does zodResolver work with react-hook-form in tests?
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.
How do I test checkbox interactions?
await user.click(screen.getByLabelText(/agree to terms/i));
expect(screen.getByLabelText(/agree to terms/i)).toBeChecked();Related
- React Testing Library Fundamentals -- userEvent and query basics
- Testing Async Behavior -- async form submission
- Mocking in Tests -- mocking form handlers and Server Actions
- Testing Server Components & Actions -- testing Server Actions directly