React Testing Library: User Events & Async
userEvent (v14)
Always prefer userEvent over fireEvent — it simulates real browser interactions including focus, keyboard, pointer events, and input composition.
import userEvent from '@testing-library/user-event'
// Setup once per test (creates an instance with pointer/keyboard state)
const user = userEvent.setup()
test('user can fill and submit form', async () => {
const onSubmit = jest.fn()
render(<LoginForm onSubmit={onSubmit} />)
// Typing — triggers input, change, keydown, keyup events
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret123')
// Click — triggers pointer events, focus, click
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
})
})
// Other userEvent methods
await user.clear(input) // clear input value
await user.selectOptions(select, 'option-value') // <select>
await user.deselectOptions(multiSelect, 'val')
await user.upload(fileInput, new File([''], 'photo.jpg', { type: 'image/jpeg' }))
await user.keyboard('{Tab}') // press Tab
await user.keyboard('{Enter}')
await user.keyboard('[ShiftLeft>]A[/ShiftLeft]') // shift+A
await user.hover(element)
await user.unhover(element)
await user.dblClick(element)Async Testing
// findBy* — waits up to 1000ms (configurable) for element to appear
test('shows error after failed login', async () => {
render(<LoginForm />)
await user.click(screen.getByRole('button', { name: /sign in/i }))
// findBy retries until element appears or timeout
const error = await screen.findByRole('alert')
expect(error).toHaveTextContent(/invalid credentials/i)
})
// waitFor — wait for an assertion to pass (more flexible)
import { waitFor } from '@testing-library/react'
test('shows success message after save', async () => {
render(<SaveButton />)
await user.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(screen.getByText(/saved successfully/i)).toBeInTheDocument()
})
// or with timeout:
await waitFor(
() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(),
{ timeout: 3000 }
)
})
// waitForElementToBeRemoved — explicitly wait for removal
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'))
// act() — wrap state updates that happen outside events
import { act } from '@testing-library/react'
await act(async () => {
jest.advanceTimersByTime(1000) // advance fake timers
})Testing Hooks
import { renderHook, act } from '@testing-library/react'
// renderHook — test custom hooks in isolation
test('useCounter increments correctly', () => {
const { result } = renderHook(() => useCounter(0))
expect(result.current.count).toBe(0)
act(() => result.current.increment())
expect(result.current.count).toBe(1)
act(() => result.current.decrement())
expect(result.current.count).toBe(0)
})
// Hook with async state
test('useFetchUser fetches user data', async () => {
const { result } = renderHook(() => useFetchUser('123'))
expect(result.current.loading).toBe(true)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.data?.name).toBe('Alice')
expect(result.current.error).toBeNull()
})
// Hook that needs a wrapper (e.g., context)
test('useAuth requires AuthProvider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider><>{children}</></AuthProvider>
)
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.isAuthenticated).toBe(false)
})Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free