React Testing Library
02 / 03

User Events & Async Testing

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