How to Test Forms and Validation in React

Fill React forms with user-event, submit them, and assert validation errors through accessible roles and invalid states.

7 min read

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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
  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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Should I test client validation or the submit handler?

Test both. Client validation is a user-facing behavior, and the submit handler is the integration with the rest of the app. Keep them as separate tests.

How do I test forms built with React Hook Form?

The approach is the same. Fill controls with user-event, submit, and assert the rendered error messages. React Hook Form exposes its errors through the same DOM.

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.