Form tests cover what the user types, what the component submits, and what validation errors appear. This guide shows how to test React forms and validation with user-event and accessible queries.
The same approach applies whether the form is hand-built or wrapped by a library like React Hook Form. Validation is part of the user experience, so tests assert what the user sees, not what the state object holds.
Build the form under test
A signup form stores the name and an error message. The submit handler checks the value and sets either an empty string or the error, so the test can assert both outcomes.
import { useState } from "react";
export default function ValidatedForm() {
const [name, setName] = useState("");
const [error, setError] = useState("");The markup below renders the input and shows the message when error is set. The input is marked invalid so assistive technology announces the failure.
return (
<form onSubmit={(e) => {
e.preventDefault();
setError(name.trim() ? "" : "Name is required.");
}}>
<label htmlFor="name">Name</label>
<input id="name" value={name} aria-invalid={!!error} onChange={(e) => setName(e.target.value)} />
<button type="submit">Submit</button>
{error && <p role="alert">{error}</p>}
</form>
);
}The alert role announces the error to screen readers, and aria-invalid flips on the input. Both are testable through the same semantics a user relies on, rather than through a className or state flag. Keeping the error in state means the component decides what to show, and the test reads that decision from the rendered output.
Test the happy path
A valid name should submit without leaving an error on screen. The test fills the field first, then submits, which mirrors the order a user follows.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ValidatedForm from "./ValidatedForm";
test("clears the error for a valid name", async () => {
const user = userEvent.setup();
render(<ValidatedForm />);
await user.type(screen.getByRole("textbox", { name: "Name" }), "Ada");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});The test types into the labeled field and clicks the submit button. Because the name is not empty, the handler stores an empty error, and the alert stays hidden.
queryByRole returns null, which the assertion checks. The visible result is a form with no error message, exactly what a user would see after a clean submit.
Test the validation error
Submitting with an empty field should show the message and mark the input invalid. This is the branch a user hits when they skip the field.
test("shows an error for an empty name", async () => {
const user = userEvent.setup();
render(<ValidatedForm />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Name is required.");
expect(screen.getByRole("textbox", { name: "Name" })).toBeInvalid();
});findByRole waits for the alert to appear after the click. The invalid assertion confirms the input carries the state that screen readers announce. Both checks describe what a user experiences after a failed submit.
Test the submit payload
When validation passes, the form usually hands the value to a parent or an API. Pass a submit function as a prop and assert it receives the typed value. This test lives in the same file as the previous ones and reuses their imports.
import { vi } from "vitest";
test("submits the typed name", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<NameForm onSubmit={onSubmit} />);
await user.type(screen.getByRole("textbox", { name: "Name" }), "Ada");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).toHaveBeenCalledWith("Ada");
});This test checks the handoff rather than internals. The user sees the same flow, and the assertion confirms the value that leaves the component. For the interaction helpers behind it, see how to test user events in React.
Keeping the submit function as a prop also makes the form reusable, because the parent decides what happens with the value.
Common mistakes
The biggest trap is asserting the wrong layer, either the state instead of the UI or the markup instead of the behavior.
- Finding inputs by test id instead of their label.
- Asserting validation logic instead of the rendered error message.
- Forgetting await, so the assertion runs before React flushes the update.
- Testing only the happy path and missing the empty and invalid branches.
What to learn next
The interactions behind these tests are covered in the user events guide. Forms built with React Hook Form are covered in the React Hook Form tutorial.
Rune AI
Key Insights
- Find controls by role or label, never by test id.
- Type and submit with user-event, awaiting each interaction.
- Assert validation messages through the alert role.
- Check the invalid state with toBeInvalid.
- Keep happy path and error path as separate tests.
Frequently Asked Questions
Should I test client validation or the submit handler?
How do I test forms built with React Hook Form?
Conclusion
Form tests drive the same path a user takes. Type into labeled controls, click the submit button, and assert what appears or stays hidden.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.