Skip to content

React · Testing

React Component Interaction Testing — Complete Guide

10 min read Updated 2026-06-24 Share:

Practice Component Interaction Testing interview questions

From rendering to interaction

Most RTL tutorials start with render and stop at "assert text is visible." Real-world components are interactive. Users click buttons, fill forms, open modals, navigate routes, and trigger conditional rendering. This guide covers how to test all of it.

The userEvent setup pattern

Since @testing-library/user-event v14, all interactions are async and require a user instance created via userEvent.setup(). Create it once per test:

import userEvent from '@testing-library/user-event'

test('some interaction', async () => {
  const user = userEvent.setup()   // one instance per test

  render(<MyComponent />)
  await user.click(...)
  await user.type(...)
})

Never create the instance inside a loop or call it before render — fake timers and the internal event queue depend on a stable instance.

Testing button clicks and state updates

The simplest interaction test: click a button, assert the resulting state change in the DOM.

test('counter increments on click', async () => {
  const user = userEvent.setup()
  render(<Counter />)

  expect(screen.getByText('Count: 0')).toBeInTheDocument()
  await user.click(screen.getByRole('button', { name: /increment/i }))
  expect(screen.getByText('Count: 1')).toBeInTheDocument()
})

Three things to note:

  1. Query by role + name — tests accessibility alongside behavior.
  2. await the click — userEvent v14 is async.
  3. Assert the DOM outcome, not the component's internal state.

Testing callback props

When a component calls a callback prop in response to user action, verify both that the callback was called and what arguments it received:

test('calls onAddToCart with product id', async () => {
  const onAddToCart = vi.fn()
  const user = userEvent.setup()

  render(<ProductCard id={42} name="Widget" onAddToCart={onAddToCart} />)
  await user.click(screen.getByRole('button', { name: /add to cart/i }))

  expect(onAddToCart).toHaveBeenCalledTimes(1)
  expect(onAddToCart).toHaveBeenCalledWith(42)
})

Use vi.fn() (Vitest) or jest.fn() for callback spies. Never mock internal implementation functions — only the public contract.

Form testing

Testing a form means filling every required field, submitting, and asserting the outcome (callback called, navigation, success message).

test('submits registration form with correct data', async () => {
  const onSubmit = vi.fn()
  const user = userEvent.setup()

  render(<RegistrationForm onSubmit={onSubmit} />)

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/password/i), 'secret123')
  await user.click(screen.getByRole('button', { name: /register/i }))

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  })
})

Controlled inputs

Controlled inputs update React state on every keystroke. user.type() fires the correct keyboard event sequence, so controlled inputs work without any special setup:

await user.type(screen.getByRole('textbox', { name: /search/i }), 'react hooks')
expect(screen.getByRole('textbox', { name: /search/i })).toHaveValue('react hooks')

To clear before typing:

await user.clear(input)
await user.type(input, 'new value')

Select, checkbox, and radio

// Select
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'us')
expect(screen.getByRole('combobox')).toHaveDisplayValue('United States')

// Checkbox
const checkbox = screen.getByRole('checkbox', { name: /accept terms/i })
await user.click(checkbox)
expect(checkbox).toBeChecked()

// Radio
await user.click(screen.getByRole('radio', { name: /monthly/i }))
expect(screen.getByRole('radio', { name: /monthly/i })).toBeChecked()
expect(screen.getByRole('radio', { name: /annual/i })).not.toBeChecked()

Form validation

Test that the form shows validation errors when required fields are missing:

test('shows validation error for empty email', async () => {
  const user = userEvent.setup()
  render(<LoginForm />)

  // Submit without filling email
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i)
  expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
})

Testing conditional rendering

Use getBy* for elements that must be present, and queryBy* for elements that might not be:

test('hides error when input is valid', async () => {
  const user = userEvent.setup()
  render(<EmailInput />)

  await user.type(screen.getByRole('textbox'), 'not-an-email')
  expect(screen.getByRole('alert')).toBeInTheDocument()

  await user.clear(screen.getByRole('textbox'))
  await user.type(screen.getByRole('textbox'), 'valid@email.com')
  expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})

Testing a toggle:

test('accordion expands and collapses', async () => {
  const user = userEvent.setup()
  render(<Accordion title="Details"><p>Hidden content</p></Accordion>)

  expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()

  await user.click(screen.getByRole('button', { name: /details/i }))
  expect(screen.getByText('Hidden content')).toBeInTheDocument()

  await user.click(screen.getByRole('button', { name: /details/i }))
  expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
})

Testing components with context

Wrap the component in its required provider via the wrapper option, or create a custom render helper:

function renderWithTheme(ui, { theme = 'light' } = {}) {
  return render(ui, {
    wrapper: ({ children }) => (
      <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
    ),
  })
}

test('switches theme on toggle', async () => {
  const user = userEvent.setup()
  renderWithTheme(<ThemeToggle />)

  await user.click(screen.getByRole('button', { name: /dark mode/i }))
  expect(document.body).toHaveClass('dark')
})

Use the real provider — not a mocked context value — to catch integration bugs between the provider and consumer.

Testing components with React Router

Wrap with MemoryRouter to provide a router context without a real browser:

test('shows 404 for unknown route', () => {
  render(
    <MemoryRouter initialEntries={['/unknown']}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </MemoryRouter>
  )
  expect(screen.getByText('Page Not Found')).toBeInTheDocument()
})

test('navigates to profile after login', async () => {
  const user = userEvent.setup()
  render(
    <MemoryRouter initialEntries={['/login']}>
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route path="/profile" element={<ProfilePage />} />
      </Routes>
    </MemoryRouter>
  )

  await user.type(screen.getByLabelText(/email/i), 'user@test.com')
  await user.type(screen.getByLabelText(/password/i), 'pass123')
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  expect(await screen.findByText('My Profile')).toBeInTheDocument()
})

Testing modals and portals

screen queries target document.body, so portal content is automatically included — no special setup needed:

test('opens and closes a modal', async () => {
  const user = userEvent.setup()
  render(<ConfirmDeleteModal trigger={<button>Delete</button>} onConfirm={vi.fn()} />)

  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()

  await user.click(screen.getByRole('button', { name: /delete/i }))
  expect(screen.getByRole('dialog')).toBeInTheDocument()

  await user.keyboard('{Escape}')
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})

Use within() to scope queries inside the modal:

const dialog = screen.getByRole('dialog')
expect(within(dialog).getByRole('heading')).toHaveTextContent('Confirm Deletion')
await user.click(within(dialog).getByRole('button', { name: /confirm/i }))

Testing keyboard navigation

user.tab() moves focus forward; user.keyboard('{Shift>}{Tab}{/Shift}') moves it backward. Combine with toHaveFocus():

test('tab cycles through interactive elements', async () => {
  const user = userEvent.setup()
  render(<Modal open><input aria-label="Name" /><button>Save</button></Modal>)

  const input = screen.getByRole('textbox', { name: /name/i })
  const saveBtn = screen.getByRole('button', { name: /save/i })

  input.focus()
  await user.tab()
  expect(saveBtn).toHaveFocus()

  await user.tab()
  expect(input).toHaveFocus()   // focus trap wraps back
})

Testing error boundaries

Error boundaries catch children that throw during render. Suppress console.error to keep test output clean:

function ThrowOnMount() {
  throw new Error('Render failure')
}

test('renders fallback on child error', () => {
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

  render(
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <ThrowOnMount />
    </ErrorBoundary>
  )

  expect(screen.getByText('Something went wrong')).toBeInTheDocument()
  consoleSpy.mockRestore()
})

Integration tests for full user flows

Integration tests render a larger component tree and simulate a complete user journey. They give high confidence but run slower.

test('login → dashboard flow', async () => {
  const user = userEvent.setup()

  server.use(
    http.post('/api/login', () =>
      HttpResponse.json({ token: 'tok', user: { name: 'Alice' } })
    )
  )

  render(
    <MemoryRouter initialEntries={['/login']}>
      <App />
    </MemoryRouter>
  )

  await user.type(screen.getByLabelText(/email/i), 'alice@test.com')
  await user.type(screen.getByLabelText(/password/i), 'secret')
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  expect(await screen.findByText('Welcome, Alice')).toBeInTheDocument()
})

A good testing strategy:

  • Unit tests — pure logic (utils, reducers).
  • Component tests — individual component behavior.
  • Integration tests — critical multi-component flows (auth, checkout).

Snapshot testing: use sparingly

Snapshots catch unintentional markup changes but don't verify behavior. Prefer behavioral assertions:

// ❌ Snapshot — only catches "something changed", not "it broke"
expect(container.firstChild).toMatchSnapshot()

// ✅ Behavioral — verifies actual user-facing output
expect(screen.getByRole('heading')).toHaveTextContent('Dashboard')
expect(screen.getAllByRole('listitem')).toHaveLength(3)

Use inline snapshots for small, stable, purely presentational components.

Testing disabled states

test('disabled button does not fire callback', async () => {
  const onClick = vi.fn()
  const user = userEvent.setup()

  render(<button disabled onClick={onClick}>Go</button>)
  await user.click(screen.getByRole('button'))

  expect(onClick).not.toHaveBeenCalled()
  expect(screen.getByRole('button')).toBeDisabled()
})

Interview checklist

Before your next interview, make sure you can:

  • Test clicks, typing, form submission, and select/checkbox/radio.
  • Assert callback props are called with correct arguments using vi.fn().
  • Test conditional rendering with getBy* and queryBy*.
  • Wrap components in context providers using the wrapper option.
  • Test routing behavior with MemoryRouter.
  • Test portals through screen without special setup.
  • Test keyboard focus with user.tab() and toHaveFocus().
  • Write an integration test that covers a multi-step user flow.

Rule of thumb: Each test should cover exactly one behavior — one user action and its observable consequence. Compound tests that click five things and check three outcomes are hard to debug when they fail.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel