🧪 Form Testing in React: From Basics to Best Practices
Forms are the heart of many web applications. Whether it’s a login screen, a checkout form, or a sign-up page, your users rely on forms to interact with your app. That’s why testing forms is critical—they must always behave as expected, even as your codebase evolves.
In this post, you’ll learn how to test forms using React Testing Library, the right way—from the user’s perspective—without relying on brittle implementation details. Along the way, you’ll see how to simplify test maintenance, generate realistic data, and write clean, expressive test code.
When a user interacts with a form, they expect to:
Your test should reflect that same behavior—just like a real user would.
Imagine we have a simple login form that accepts a username
and password
, then calls an onSubmit
callback when submitted.
Let’s write a test for this.
import { render, screen } from '@testing-library/react'import userEvent from '@testing-library/user-event'import Login from '../login' // assume this is your Login componenttest('submits username and password', async () => {const handleSubmit = jest.fn()render(<Login onSubmit={handleSubmit} />)const usernameInput = screen.getByLabelText(/username/i)const passwordInput = screen.getByLabelText(/password/i)const submitButton = screen.getByRole('button', { name: /submit/i })await userEvent.type(usernameInput, 'johndoe')await userEvent.type(passwordInput, 's3cret')await userEvent.click(submitButton)expect(handleSubmit).toHaveBeenCalledWith({username: 'johndoe',password: 's3cret',})})
This test does everything from the user’s point of view:
userEvent
Instead of checking submittedData
manually, you can use jest.fn()
and assert it was called correctly:
const handleSubmit = jest.fn()...expect(handleSubmit).toHaveBeenCalledWith({ username, password })
📘 Read more about jest.fn() and toHaveBeenCalledWith
Hardcoding values like 'chucknorris'
or 'password123'
might make the test look specific—even when they aren’t.
Instead, use a data generator to signal: “The specific value doesn’t matter here.”
import { faker } from '@faker-js/faker'const username = faker.internet.userName()const password = faker.internet.password()
This clearly communicates that the values don’t hold any special meaning—they’re just realistic inputs.
buildLoginForm
HelperMake your tests even cleaner and more reusable by wrapping your data generation in a helper:
function buildLoginForm(overrides = {}) {return {username: faker.internet.userName(),password: faker.internet.password(),...overrides,}}
Use it like this:
const { username, password } = buildLoginForm()
Or, override a field when needed:
const { username, password } = buildLoginForm({ password: 'abc123' })
This communicates intent: “This test cares about the specific password, but the username can be anything.”
Want a cleaner, more structured way to generate test data?
Check out @jackfranklin/test-data-bot
.
import { build, fake } from '@jackfranklin/test-data-bot'const buildLoginForm = build('LoginForm', {fields: {username: fake(f => f.internet.userName()),password: fake(f => f.internet.password()),},})
Now use it in your tests:
const { username, password } = buildLoginForm()
This keeps your tests clean, expressive, and ready for future changes.
✅ Good Practice | ❌ Bad Practice |
---|---|
Use getByLabelText / getByRole | Use querySelector , firstChild , etc. |
Use userEvent for realistic actions | Use fireEvent (less accurate) |
Use jest.fn() for form submission | Manually track state |
Generate test data | Hardcode usernames like 'admin' |
jest.fn
and custom data builders for clean, maintainable tests.Ready to reinforce what you’ve learned? Fill out the feedback form:
👉 Form Testing – Feedback Form
Quick Links
Legal Stuff
Social Media