Testing Library Patterns: Testing Components from the User's Perspective
Prologue: Refactored the Code, Tests All Failed
I refactored a component. Behavior stayed identical — just cleaned up the internals. Twenty tests went red.
The cause: tests were coupled to internal state names, CSS classes, and prop names. Things like wrapper.find('.submit-btn') and component.state().isLoading.
That's when I properly learned Testing Library. Kent C. Dodds, its creator, said it best:
"The more your tests resemble the way your software is used, the more confidence they can give you."
Test behavior, not implementation. That's all Testing Library is about.
The Core Philosophy
Don't Test Implementation Details
// Bad: coupled to implementation
const { container } = render(<LoginForm />)
// Breaks when CSS class name changes
expect(container.querySelector('.form-submit-btn')).toBeTruthy()
// Breaks when state field is renamed
expect(wrapper.state('isSubmitting')).toBe(true)
// Good: from the user's perspective
render(<LoginForm />)
// User sees a disabled button with "Submitting..." label
const submitButton = screen.getByRole('button', { name: 'Submitting...' })
expect(submitButton).toBeDisabled()
The second test passes regardless of implementation, as long as the button is disabled and has the right label.
Why getByRole Is the Best Default
Testing Library's official query priority order:
- Accessibility queries (accessible to everyone):
getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue
- Semantic queries:
getByAltText, getByTitle
- Test IDs (last resort):
getByTestId
getByRole tops the list for two reasons:
- It finds elements by ARIA role, so it also validates that your markup is accessible
- It doesn't depend on class names, IDs, or DOM structure — it's refactor-resistant
// Given this button:
<button type="submit" className="btn btn-primary">
Sign in
</button>
// Best: getByRole
screen.getByRole('button', { name: 'Sign in' })
// OK: getByText (second choice)
screen.getByText('Sign in')
// Don't: querySelector (fragile)
document.querySelector('.btn-primary')
Commonly Used ARIA Roles
// Buttons
screen.getByRole('button', { name: 'Save' })
// Links
screen.getByRole('link', { name: 'Go to blog' })
// Text inputs (when associated with a label)
screen.getByRole('textbox', { name: 'Email' })
screen.getByRole('spinbutton', { name: 'Age' }) // number input
screen.getByRole('combobox', { name: 'Country' }) // select
// Checkboxes
screen.getByRole('checkbox', { name: 'I agree to the terms' })
// Headings
screen.getByRole('heading', { name: 'Dashboard', level: 1 })
// Tables
screen.getByRole('table')
screen.getAllByRole('row')
// Structural
screen.getByRole('navigation')
screen.getByRole('main')
screen.getByRole('dialog') // modal
getByLabelText: Best Practice for Forms
// Given this markup:
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// Find it with:
screen.getByLabelText('Email address')
// Works with aria-label too
<input aria-label="Email address" type="email" />
screen.getByLabelText('Email address')
Using getByLabelText implicitly verifies that label and input are correctly associated — catching accessibility issues for free.
userEvent vs fireEvent
Both simulate user interactions, but they're quite different.
fireEvent: Dispatches a single DOM event.
import { fireEvent } from '@testing-library/react'
fireEvent.click(button) // just one click event
fireEvent.change(input, { target: { value: 'hello' } }) // just one change event
userEvent: Simulates the full event sequence a real user triggers.
import userEvent from '@testing-library/user-event'
// Typing fires: keydown, keypress, input, keyup — per character
await userEvent.type(input, 'hello')
// Clicking fires: pointerover, pointerenter, mouseover, mouseenter,
// pointermove, mousemove, pointerdown, mousedown,
// focus, pointerup, mouseup, click
await userEvent.click(button)
When to use which:
| userEvent | fireEvent |
|---|
| Mimics real user | Accurate | Simplified |
| Async handling | Required (await) | Not needed |
| Complex interactions | Essential | Falls short |
| Simple event testing | Overkill | Sufficient |
Default to userEvent unless you have a specific reason to use fireEvent.
Using userEvent Correctly
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
test('login form submission', async () => {
// Recommended: initialize with setup() (v14+)
const user = userEvent.setup()
render(<LoginForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Email'), 'user@example.com')
await user.type(screen.getByLabelText('Password'), 'password123')
await user.click(screen.getByRole('button', { name: 'Log in' }))
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
})
})
Real Example: Form Component Tests
// ContactForm.test.tsx
import { vi, describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './ContactForm'
describe('ContactForm', () => {
const user = userEvent.setup()
it('shows success message after valid form submission', async () => {
const mockSubmit = vi.fn().mockResolvedValue(undefined)
render(<ContactForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText('Name'), 'Alice')
await user.type(screen.getByLabelText('Email'), 'alice@example.com')
await user.type(screen.getByLabelText('Message'), 'Hello!')
await user.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => {
expect(screen.getByRole('status')).toHaveTextContent('Message sent!')
})
expect(mockSubmit).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@example.com',
message: 'Hello!',
})
})
it('shows validation errors when submitting empty form', async () => {
const mockSubmit = vi.fn()
render(<ContactForm onSubmit={mockSubmit} />)
await user.click(screen.getByRole('button', { name: 'Send' }))
const alerts = screen.getAllByRole('alert')
expect(alerts).toHaveLength(3)
expect(alerts[0]).toHaveTextContent('Name is required')
expect(alerts[1]).toHaveTextContent('Email is required')
expect(alerts[2]).toHaveTextContent('Message is required')
expect(mockSubmit).not.toHaveBeenCalled()
})
it('disables button and changes label while submitting', async () => {
let resolveSubmit!: () => void
const mockSubmit = vi.fn().mockImplementation(
() => new Promise<void>(resolve => { resolveSubmit = resolve })
)
render(<ContactForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText('Name'), 'Alice')
await user.type(screen.getByLabelText('Email'), 'alice@example.com')
await user.type(screen.getByLabelText('Message'), 'Test')
await user.click(screen.getByRole('button', { name: 'Send' }))
// Check submitting state
expect(screen.getByRole('button', { name: 'Sending...' })).toBeDisabled()
// Complete the submission
resolveSubmit()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Send' })).not.toBeDisabled()
})
})
})
Async Queries: findBy and waitFor
// getBy: synchronous, throws if not found
const button = screen.getByRole('button')
// queryBy: synchronous, returns null if not found
const maybeButton = screen.queryByRole('button') // null means not there
// findBy: async, waits for element to appear (default 1s timeout)
const asyncButton = await screen.findByRole('button')
// waitFor: waits for condition to be true
await waitFor(() => {
expect(screen.getByText('Load complete')).toBeInTheDocument()
})
Quick reference:
// Always present: getBy
const title = screen.getByRole('heading', { name: 'Dashboard' })
// Might not be there: queryBy
const errorMessage = screen.queryByRole('alert')
expect(errorMessage).not.toBeInTheDocument()
// Appears asynchronously: findBy
const successMessage = await screen.findByText('Saved!')
// Complex async condition: waitFor
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(5)
})
Testing Modals and Dialogs
describe('ConfirmDialog', () => {
const user = userEvent.setup()
it('calls onConfirm when confirm button is clicked', async () => {
const onConfirm = vi.fn()
const onCancel = vi.fn()
render(
<ConfirmDialog
isOpen={true}
title="Confirm Delete"
message="Are you sure?"
onConfirm={onConfirm}
onCancel={onCancel}
/>
)
const dialog = screen.getByRole('dialog')
expect(dialog).toBeVisible()
expect(screen.getByRole('heading', { name: 'Confirm Delete' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Confirm' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).not.toHaveBeenCalled()
})
it('closes on ESC key', async () => {
const onCancel = vi.fn()
render(<ConfirmDialog isOpen={true} onConfirm={vi.fn()} onCancel={onCancel} />)
await user.keyboard('{Escape}')
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
Useful Custom Matchers from jest-dom
// Presence
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()
// Visibility
expect(element).toBeVisible()
// State
expect(button).toBeEnabled()
expect(button).toBeDisabled()
expect(checkbox).toBeChecked()
// Content
expect(element).toHaveTextContent('Hello')
expect(element).toHaveTextContent(/hello/i)
// Values
expect(input).toHaveValue('user@example.com')
// Attributes
expect(input).toHaveAttribute('type', 'email')
// Focus
expect(input).toHaveFocus()
// Accessibility
expect(element).toHaveAccessibleName('Email input')
Common Anti-Patterns
1. Using container.querySelector()
// Don't
const { container } = render(<MyComponent />)
container.querySelector('.my-button').click()
// Do
render(<MyComponent />)
await userEvent.click(screen.getByRole('button'))
2. Testing implementation details
// Don't
const { result } = renderHook(() => useMyHook())
expect(result.current.internalState).toBe(true)
// Do — test what the user sees
render(<ComponentUsingHook />)
expect(screen.getByText('Active')).toBeInTheDocument()
3. Slapping data-testid on everything
// Unnecessary
<button data-testid="submit-button">Log in</button>
screen.getByTestId('submit-button')
// Better
<button type="submit">Log in</button>
screen.getByRole('button', { name: 'Log in' })
Reserve data-testid for elements that truly can't be found by role or label.
4. Ignoring act() warnings
// Don't suppress warnings
jest.spyOn(console, 'error').mockImplementation(() => {})
// Do: handle async properly
await waitFor(() => expect(screen.getByText('Loaded')).toBeInTheDocument())
Conclusion: Tests That Survive Change
Here's a practical litmus test for every assertion you write:
Does this test fail because the user's desired behavior broke, or because I changed the internal structure?
If the former — good test. If the latter — it's coupled to implementation details.
Follow Testing Library's approach and your tests will survive complete component rewrites. That's the real value of good tests.