Skip to content

Component Interaction Testing Interview Questions & Answers

15 questions Updated 2026-06-24 Share:

React Testing Library interview questions on component interaction — user events, form testing, conditional rendering, context, portals, keyboard navigation, and integration testing patterns.

Read the in-depth guideReact Component Interaction Testing — Complete Guide(opens in new tab)
15 of 15

Use userEvent.click() from @testing-library/user-event. It fires the full sequence of pointer and mouse events a real click produces.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function Counter() {
  const [count, setCount] = React.useState(0)
  return (
    <>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </>
  )
}

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

  expect(screen.getByText('Count: 0')).toBeInTheDocument()

  await user.click(screen.getByRole('button', { name: /increment/i }))

  expect(screen.getByText('Count: 1')).toBeInTheDocument()
})

Key points:

  • userEvent.setup() returns a user instance; create it once per test outside any loops to keep fake timers consistent.
  • await every user.* call — they're async since user-event v14.
  • Query by role + name so the test doubles as an accessibility check.

Rule of thumb: await user.click() → assert. One interaction, one assertion block. Don't chain multiple un-awaited clicks.

The most reliable approach is to fill in all fields via userEvent.type, then submit via userEvent.click on the submit button (or userEvent.keyboard('{Enter}') if that's how users submit).

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

test('submits registration form', async () => {
  const handleSubmit = vi.fn()
  const user = userEvent.setup()

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

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

  await waitFor(() => {
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'secret123',
    })
  })
})

For native form submission (no JS handler), you can also listen for the submit event on the form element:

const handleSubmit = vi.fn(e => e.preventDefault())
render(<form onSubmit={handleSubmit}><button type="submit">Go</button></form>)
await user.click(screen.getByRole('button', { name: /go/i }))
expect(handleSubmit).toHaveBeenCalled()

Rule of thumb: Test the outcome of submission (callback called, success message shown, navigation triggered) rather than testing the form's internal state.

userEvent.type() fires a keydown/keypress/input/keyup sequence for each character, causing controlled inputs to update through the standard React synthetic event flow.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function SearchBox() {
  const [query, setQuery] = React.useState('')
  return (
    <input
      aria-label="Search"
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  )
}

test('updates value as user types', async () => {
  const user = userEvent.setup()
  render(<SearchBox />)

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

  expect(input).toHaveValue('react hooks')
})

To clear an existing value before typing, use user.clear():

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

To type into a <select>:

await user.selectOptions(screen.getByRole('combobox'), 'Option B')
expect(screen.getByRole('combobox')).toHaveDisplayValue('Option B')

Rule of thumb: Prefer user.type() over manually setting .value + dispatching a change event — it tests the full keyboard interaction path.

Assert presence with getByRole/getByText and absence with queryBy* returning null or not.toBeInTheDocument().

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function Alert({ type, message }) {
  if (!message) return null
  return <div role="alert" className={`alert-${type}`}>{message}</div>
}

test('renders nothing when message is empty', () => {
  render(<Alert type="error" message="" />)
  expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})

test('renders error message', () => {
  render(<Alert type="error" message="Something failed" />)
  expect(screen.getByRole('alert')).toHaveTextContent('Something failed')
})

Testing a toggle:

test('shows/hides details on button click', async () => {
  const user = userEvent.setup()
  render(<ExpandableSection title="Details" content="Hidden text" />)

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

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

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

Rule of thumb: Always use queryBy* (not getBy*) when asserting an element is absent — getBy* throws before your assertion runs.

Wrap the component with the real provider (not a mock) in the wrapper option or a custom render helper.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from './ThemeContext'
import ThemeToggle from './ThemeToggle'

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

test('switches to dark mode', async () => {
  const user = userEvent.setup()
  renderWithTheme(<ThemeToggle />)

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

test('reads initial theme from context', () => {
  renderWithTheme(<ThemeToggle />, { theme: 'dark' })
  expect(screen.getByRole('button', { name: /light mode/i })).toBeInTheDocument()
})

Avoid mocking the context value directly (vi.mock) unless the context comes from a third-party library you can't control. Testing through the real provider catches integration bugs between the provider and consumer.

Rule of thumb: Use the real provider with controlled initial values; mock only external context sources (auth libraries, feature-flag SDKs).

Wrap with MemoryRouter (or createMemoryRouter + RouterProvider for v6 data routers) so the component has a router context without a real browser history.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'

// Testing useParams
test('renders product with correct id', () => {
  render(
    <MemoryRouter initialEntries={['/products/42']}>
      <Routes>
        <Route path="/products/:id" element={<ProductPage />} />
      </Routes>
    </MemoryRouter>
  )
  expect(screen.getByText('Product 42')).toBeInTheDocument()
})

// Testing useNavigate
test('navigates to home after logout', async () => {
  const user = userEvent.setup()
  render(
    <MemoryRouter initialEntries={['/dashboard']}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/" element={<p>Home</p>} />
      </Routes>
    </MemoryRouter>
  )

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

For components using the newer data router APIs, use createMemoryRouter + RouterProvider:

import { createMemoryRouter, RouterProvider } from 'react-router-dom'

const router = createMemoryRouter(routes, { initialEntries: ['/dashboard'] })
render(<RouterProvider router={router} />)

Rule of thumb: MemoryRouter for simple path/param tests; createMemoryRouter when you need loaders, actions, or data-router features.

Query all list items with getAllBy* or queryAllBy* and check the array length, then assert individual items by their content.

import { render, screen } from '@testing-library/react'

const todos = [
  { id: 1, text: 'Buy milk' },
  { id: 2, text: 'Walk dog' },
  { id: 3, text: 'Read book' },
]

test('renders correct number of todo items', () => {
  render(<TodoList todos={todos} />)

  const items = screen.getAllByRole('listitem')
  expect(items).toHaveLength(3)
})

test('renders each todo text', () => {
  render(<TodoList todos={todos} />)

  expect(screen.getByText('Buy milk')).toBeInTheDocument()
  expect(screen.getByText('Walk dog')).toBeInTheDocument()
  expect(screen.getByText('Read book')).toBeInTheDocument()
})

test('shows empty state when no todos', () => {
  render(<TodoList todos={[]} />)
  expect(screen.getByText(/no todos yet/i)).toBeInTheDocument()
  expect(screen.queryAllByRole('listitem')).toHaveLength(0)
})

Rule of thumb: Test the count and a sample of items; don't assert every item in a large list — that couples tests to data and makes them brittle.

RTL's screen queries target document.body, which includes portal content — no special setup needed.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Modal from './Modal'

// Modal renders into document.body via createPortal
test('opens and closes modal', async () => {
  const user = userEvent.setup()
  render(<Modal trigger={<button>Open</button>}>Dialog content</Modal>)

  // Before open — modal not in DOM
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()

  // Open the modal
  await user.click(screen.getByRole('button', { name: /open/i }))
  expect(screen.getByRole('dialog')).toBeInTheDocument()
  expect(screen.getByText('Dialog content')).toBeInTheDocument()

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

If the portal target is a custom element (not document.body), you may need to create it in setup:

beforeEach(() => {
  const el = document.createElement('div')
  el.setAttribute('id', 'modal-root')
  document.body.appendChild(el)
})
afterEach(() => {
  document.getElementById('modal-root')?.remove()
})

Rule of thumb: Test portals through screen like any other element — their implementation detail (portal vs inline) is irrelevant to user-facing behavior.

Use userEvent.keyboard() or userEvent.tab() to simulate keyboard input and check focus with toHaveFocus().

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

test('traps focus inside modal', async () => {
  const user = userEvent.setup()
  render(<Modal open><input aria-label="Name" /><button>Close</button></Modal>)

  const nameInput = screen.getByRole('textbox', { name: /name/i })
  const closeButton = screen.getByRole('button', { name: /close/i })

  // Focus starts on first focusable element
  expect(nameInput).toHaveFocus()

  // Tab moves to next element
  await user.tab()
  expect(closeButton).toHaveFocus()

  // Tab wraps back to first element (focus trap)
  await user.tab()
  expect(nameInput).toHaveFocus()
})

test('closes dropdown on Escape', async () => {
  const user = userEvent.setup()
  render(<Dropdown label="Options"><li>Item 1</li></Dropdown>)

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

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

Useful keyboard shortcuts for user.keyboard():

'{Tab}'    '{Shift>}{Tab}{/Shift}'    '{Enter}'
'{Space}'  '{Escape}'                  '{ArrowDown}'

Rule of thumb: Always test keyboard navigation for interactive components (modals, dropdowns, menus) — it's both a UX and accessibility requirement.

Render a child component that throws inside an error boundary, and assert the fallback UI. Since React logs errors to console.error, suppress that to keep test output clean.

import { render, screen } from '@testing-library/react'
import ErrorBoundary from './ErrorBoundary'

function ThrowOnMount() {
  throw new Error('Test error')
}

test('renders fallback when child throws', () => {
  // Suppress React's error output in test logs
  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()
})

test('renders children when no error', () => {
  render(
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <p>All good</p>
    </ErrorBoundary>
  )
  expect(screen.getByText('All good')).toBeInTheDocument()
  expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
})

For error boundaries with a "retry" button, update the child after error to test recovery:

test('recovers after reset', async () => {
  const user = userEvent.setup()
  let shouldThrow = true
  function MaybeThrow() {
    if (shouldThrow) throw new Error('oops')
    return <p>Recovered</p>
  }
  // ... render, suppress, click retry, update shouldThrow, assert
})

Rule of thumb: Always mock console.error in error boundary tests — RTL and React both log thrown errors, cluttering test output without it.

Render the top-level component that composes the components you want to test together, provide any required context or mocks at the boundary, and simulate the full user flow from start to finish.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import App from './App'

const server = setupServer(
  rest.post('/api/login', (req, res, ctx) =>
    res(ctx.json({ token: 'abc123', user: { name: 'Alice' } }))
  )
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

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

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

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

  // After successful login, should show dashboard
  expect(await screen.findByText('Welcome, Alice')).toBeInTheDocument()
  expect(screen.queryByRole('form')).not.toBeInTheDocument()
})

Integration tests give the highest confidence but are slower and harder to debug. Strategy:

  • Unit tests for complex logic (reducers, utilities).
  • Component tests for individual component behavior.
  • Integration tests for critical user flows (login, checkout, onboarding).

Rule of thumb: Write integration tests for flows that cross component boundaries and involve real user journeys, not for individual component rendering.

Pass a vi.fn() (or jest.fn()) as the prop and assert on it after the interaction.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, expect } from 'vitest'
import ProductCard from './ProductCard'

test('calls onAddToCart with product id when button clicked', 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)
})

For multiple interactions:

await user.click(incrementBtn)
await user.click(incrementBtn)
expect(onChange).toHaveBeenCalledTimes(2)
expect(onChange).toHaveBeenNthCalledWith(1, 1)
expect(onChange).toHaveBeenNthCalledWith(2, 2)

Rule of thumb: Use vi.fn() for callback props; use toHaveBeenCalledWith rather than checking internal state — you're testing the contract between parent and child.

RTL has dedicated userEvent methods for each form element type.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

const user = userEvent.setup()

// Checkbox
test('toggles checkbox', async () => {
  render(<label><input type="checkbox" />Accept terms</label>)
  const checkbox = screen.getByRole('checkbox', { name: /accept terms/i })

  expect(checkbox).not.toBeChecked()
  await user.click(checkbox)
  expect(checkbox).toBeChecked()
  await user.click(checkbox)
  expect(checkbox).not.toBeChecked()
})

// Radio buttons
test('selects radio option', async () => {
  render(
    <fieldset>
      <legend>Size</legend>
      <label><input type="radio" name="size" value="S" />Small</label>
      <label><input type="radio" name="size" value="L" />Large</label>
    </fieldset>
  )
  await user.click(screen.getByRole('radio', { name: /small/i }))
  expect(screen.getByRole('radio', { name: /small/i })).toBeChecked()
  expect(screen.getByRole('radio', { name: /large/i })).not.toBeChecked()
})

// Select
test('selects option from dropdown', async () => {
  render(
    <select aria-label="Country">
      <option value="">--</option>
      <option value="us">US</option>
      <option value="ca">Canada</option>
    </select>
  )
  await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'us')
  expect(screen.getByRole('combobox', { name: /country/i })).toHaveDisplayValue('US')
})

Rule of thumb: Always query form elements by their accessible role + name (derived from <label>) — this forces you to write accessible markup.

Assert the disabled state with toBeDisabled() and verify that clicking a disabled button does not invoke callbacks.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

test('submit button is disabled while loading', () => {
  render(<SubmitButton loading={true} />)
  expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled()
})

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

  render(<button disabled onClick={onClick}>Click me</button>)

  await user.click(screen.getByRole('button'))
  // userEvent respects the disabled attribute and does not fire the click
  expect(onClick).not.toHaveBeenCalled()
})

test('submit button becomes enabled after required fields filled', async () => {
  const user = userEvent.setup()
  render(<Form />)

  const button = screen.getByRole('button', { name: /submit/i })
  expect(button).toBeDisabled()

  await user.type(screen.getByLabelText(/email/i), 'user@test.com')
  expect(button).toBeEnabled()
})

Rule of thumb: toBeDisabled() and toBeEnabled() from @testing-library/jest-dom are clearer than toHaveAttribute('disabled').

Snapshot testing captures the rendered output as a string on first run and compares future runs against that snapshot. Vitest/Jest provide this via toMatchSnapshot() or toMatchInlineSnapshot().

import { render } from '@testing-library/react'

test('renders badge correctly', () => {
  const { container } = render(<Badge count={5} />)
  expect(container.firstChild).toMatchSnapshot()
})

RTL's stance: use sparingly. Snapshots are good for:

  • Preventing unintentional markup changes in leaf UI components.
  • Locking down third-party rendered output you don't control.

Snapshots are bad for:

  • Testing behavior — they catch "it changed", not "it's wrong".
  • Large components — snapshots become huge and are mindlessly updated.
  • Fast-moving codebases — you spend more time updating snapshots than fixing real bugs.

Prefer inline snapshots when you do use them:

expect(container.firstChild).toMatchInlineSnapshot(`
  <span class="badge badge--blue">5</span>
`)

Rule of thumb: Write behavioral assertions (text content, role, visibility) instead of snapshots. Use snapshots only for small, stable, purely presentational components.

More ways to practice

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

or
Join our WhatsApp Channel