React

Testing

14 Questions

The standard approach to testing React components is React Testing Library with a Jest test runner. You import render to mount the component and screen to query the rendered output by what users actually see. The key principle is testing from the user's perspective — query by text, roles, and labels, and assert on visible behavior rather than implementation details. This approach makes tests resilient to refactoring since they only break when real behavior changes.

import { render, screen } from '@testing-library/react';
  render(<Hello name="World" />);
  expect(screen.getByText('Hello, World!')).toBeInTheDocument();
});

Why it matters: Testing components from a user's perspective catches real bugs — tests that check rendered text and accessible roles stay valid even when you refactor internals.

Real applications: Test that a login form shows "Invalid credentials" after a failed login, not that a specific state variable was set to true.

Common mistakes: Testing implementation details like class names or state variables — these break when you refactor even though the component still works correctly.

React Testing Library's core philosophy is to test components the way users interact with them — query by accessible roles, visible text, and form labels rather than by class names, IDs, or component internals. If a user can't see it or interact with it, it shouldn't drive your test. This approach makes tests resilient to refactoring: renaming a CSS class or restructuring state doesn't break tests that only check visible behavior. It also encourages writing more accessible components, since accessible elements are the easiest to query.

Why it matters: Tests written this way are resilient to refactoring — they break only when actual behavior changes, not when you rename a CSS class.

Real applications: Use getByRole('button', { name: /submit/i }) instead of querySelector('.submit-btn') — the test describes what the user sees.

Common mistakes: Using getByTestId for everything — it's the last resort; prefer queries that reflect how accessible the component is.

Testing user interactions uses @testing-library/user-event, which simulates browser events far more realistically than the lower-level fireEvent. userEvent.click, userEvent.type, and userEvent.keyboard dispatch complete, realistic event sequences including focus, hover, pointer, and input events. Always await user events since they are asynchronous by default in v14+. Using userEvent catches more real-world bugs because it exercises the same event paths browsers use.

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

test('increments counter', async () => {
  render(<Counter />);
  await userEvent.click(screen.getByRole('button', { name: /increment/i }));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Why it matters: userEvent simulates real browser events (focus, hover, type, click) more accurately than fireEvent — tests catch more real-world bugs.

Real applications: Test that clicking a toggle button changes the label text, or that typing in an input shows the typed value.

Common mistakes: Using fireEvent.click instead of await userEvent.clickuserEvent is async and more realistic; skipping await leads to missed assertions.

Testing async operations requires findBy* queries, which return a Promise that resolves when the element appears (waiting up to 1000ms by default). For components that fetch data on mount, assert the loading state first synchronously, then await the final content. Pair this with MSW to mock API responses and keep tests isolated from real network calls. Never use getBy* for elements that appear asynchronously — it throws immediately because it doesn't wait.

test('loads user data', async () => {
  render(<UserProfile id="1" />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  await screen.findByText('John Doe'); // Waits for element
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

Why it matters: Async tests verify that loading states and final states are both correct — catching bugs where a spinner never disappears or data never appears.

Real applications: Test that a user profile page shows a loading spinner, then renders the user's name after the fetch completes.

Common mistakes: Not awaiting findBy* queries — the test passes before the async update happens, giving a false positive.

Mock Service Worker (MSW) is the recommended approach for mocking API calls in React tests. Instead of mocking fetch or axios directly, it intercepts actual HTTP requests at the network level using request interception in Node.js. This means your component code makes real fetch calls that MSW intercepts transparently — no code changes between tests and production. Handlers can be overridden per-test via server.use() to simulate error states, pagination, and edge cases.

import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(ctx.json([{ id: 1, name: 'John' }]));
  })
);

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

Why it matters: MSW intercepts real network requests at the service worker level — tests run against realistic responses without actually hitting servers.

Real applications: Test error states by returning a 500 response in a specific test; test pagination by returning different page results on repeated calls.

Common mistakes: Forgetting afterEach(() => server.resetHandlers()) — request overrides from one test bleed into the next, causing mysterious failures.

Custom hooks are tested using the renderHook utility from @testing-library/react, which mounts the hook in a minimal React environment without needing a wrapper component. The returned result.current gives access to everything the hook returns. Wrap state-updating calls in act() to flush React's state batching before asserting new values. This lets you test hook logic in complete isolation from any UI concerns.

import { renderHook, act } from '@testing-library/react';

test('useCounter hook', () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});

Why it matters: renderHook lets you test hook logic in isolation without needing a real component — cleaner and faster to write.

Real applications: Test that useAuth returns the correct user after login, or that useLocalStorage persists and retrieves values correctly.

Common mistakes: Forgetting to wrap state updates in act() — hooks that update state outside act trigger React warnings and unreliable test behavior.

Components consuming Context need the corresponding Provider in the test render tree — without it, useContext returns the context default (often undefined), causing confusing failures. The solution is a renderWithProviders helper that wraps the component under test with all necessary providers. Use mock providers with preset, controlled values rather than real providers that make API calls or have side effects. This keeps tests fast, isolated, and predictable.

function renderWithProviders(ui) {
  return render(
    <ThemeProvider>
      <AuthProvider>
        {ui}
      </AuthProvider>
    </ThemeProvider>
  );
}

test('shows user name', () => {
  renderWithProviders(<UserInfo />);
  expect(screen.getByText('John')).toBeInTheDocument();
});

Why it matters: Components that consume context need the provider in the test tree — without it useContext returns the default value or throws.

Real applications: Create a renderWithProviders helper that wraps with all app-level providers (auth, theme, router) so every test gets a realistic environment.

Common mistakes: Wrapping with the real auth provider that makes API calls in tests — use a mock provider with preset values to keep tests fast and isolated.

React Testing Library provides a family of query functions organized by how the element is found and when it's expected in the DOM. getByRole is the recommended starting point — it's the most resilient to refactoring and drives accessibility improvements. Each query type has three timing variants: getBy (throws immediately), queryBy (returns null), and findBy (async, waits). Use them in the correct timing context or tests will pass incorrectly or fail unexpectedly.

  • getByRole — by ARIA role (preferred)
  • getByText — by visible text
  • getByLabelText — by form label
  • getByPlaceholderText — by placeholder
  • getByTestId — by data-testid (last resort)
  • queryBy* — returns null if not found
  • findBy* — async, waits for element

Why it matters: Choosing the right query makes tests readable and stable — getByRole is both the most resilient and the most accessibility-friendly.

Real applications: Use getByLabelText for form inputs — it drives you to add proper labels, improving both the test and the accessibility of the component.

Common mistakes: Reaching for getByTestId first — it adds test-only attributes to your markup and doesn't verify accessibility; use it only as a last resort.

Form submission tests verify the full user workflow: filling each field with userEvent.type, submitting with userEvent.click, and asserting the submitted payload or success feedback. Use jest.fn() for the onSubmit handler to assert exactly what data was sent. Query form fields by accessible labels (getByLabelText) — this drives you to add proper <label> elements, improving both the test and the component's accessibility.

test('submits form data', async () => {
  const onSubmit = jest.fn();
  render(<LoginForm onSubmit={onSubmit} />);
  await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
  await userEvent.type(screen.getByLabelText('Password'), 'secret');
  await userEvent.click(screen.getByRole('button', { name: /submit/i }));
  expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'secret' });
});

Why it matters: Testing form submission end-to-end catches bugs in the full flow — validation, state sync, and the final submitted payload.

Real applications: Test login, registration, and settings forms with realistic inputs to catch edge cases before they hit production users.

Common mistakes: Testing form state instead of the submit callback payload — what matters is what data the form actually sends, not internal state variables.

Snapshot testing captures the rendered HTML output of a component on first run and stores it as a .snap file. Subsequent test runs compare current output to the stored snapshot and fail if anything changed. Snapshots are useful as a safety net for shared UI components to catch accidental regression, but they're a poor primary testing strategy — they break on every intentional change and large diffs are hard to review meaningfully. Prefer specific behavior assertions over snapshots wherever possible.

test('matches snapshot', () => {
  const { asFragment } = render(<Button label="Click" />);
  expect(asFragment()).toMatchSnapshot();
});

Use sparingly — snapshots break easily and provide less insight than specific assertions.

Why it matters: Snapshots catch unexpected UI changes automatically — good as a safety net, but poor as a primary testing strategy.

Real applications: Snapshot a shared UI library component to catch accidental style or structure changes during dependency updates.

Common mistakes: Blindly updating snapshots with --updateSnapshot without reviewing what changed — defeats the purpose of having a snapshot.

Components using useContext require the corresponding Provider in the test render tree with a controlled value. Wrap the component directly in a Provider with a specific test value, or use a test-specific mock provider that returns predictable values without side effects. For apps with many providers, a shared renderWithProviders wrapper eliminates the need to manually stack providers in every single test file. The Redux Toolkit docs provide a production-ready implementation of this pattern.

const ThemeContext = createContext('light');

function ThemeButton() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>Click</button>;
}

test('uses dark theme from context', () => {
  render(
    <ThemeContext.Provider value="dark">
      <ThemeButton />
    </ThemeContext.Provider>
  );
  expect(screen.getByRole('button')).toHaveClass('dark');
});

Create a custom renderWithProviders helper in large projects so every test doesn't need to manually wrap with multiple providers.

Why it matters: A shared render helper reduces boilerplate and ensures every test uses a consistent provider setup.

Real applications: Large apps with Auth, Theme, Router, and Redux providers — a single renderWithProviders wraps all of them, keeping tests concise.

Common mistakes: Using real context providers with side effects in tests — use mock providers with predictable values so tests don't make API calls or have external dependencies.

Testing error states requires intercepting the API call and returning an error response — override an MSW handler to return a 500 status, or mock the fetch/axios module to reject. Then assert the error message appears and any loading indicators have disappeared. Use findBy* queries to wait for async error state to render. Testing the error path is the most commonly skipped path and the most commonly broken in production — always cover it explicitly.

// With MSW (Mock Service Worker)
test('shows error message on fetch failure', async () => {
  server.use(
    rest.get('/api/users', (req, res, ctx) =>
      res(ctx.status(500))
    )
  );

  render(<UserList />);

  await screen.findByText(/failed to load users/i);
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

The findBy* queries wait for async updates — they return a promise that resolves when the element appears (or rejects after a timeout).

Why it matters: Testing error states confirms your error handling actually works — the error message renders and the loading indicator disappears.

Real applications: Override the MSW handler in a specific test to return a 500 error, then assert the error message appears in the UI.

Common mistakes: Not testing the error path at all — the happy path works but a network failure causes a blank screen nobody noticed in testing.

React Testing Library's three query families handle different timing scenarios: getBy* is synchronous and throws if the element isn't already in the DOM; queryBy* is synchronous and returns null (use for asserting absence); findBy* is asynchronous and waits for the element to appear. Using the wrong family leads to tests that pass incorrectly or fail with confusing messages. The rule: getBy for presence now, queryBy for absence, findBy for anything that requires waiting.

  • getBy* — synchronous, throws immediately if not found. Use when element should already be in the DOM.
  • queryBy* — synchronous, returns null if not found. Use for asserting an element is absent.
  • findBy* — asynchronous (returns a Promise), waits for the element to appear. Use for elements that appear after async work.
// Element is already rendered
const heading = screen.getByRole('heading', { name: /welcome/i });

// Assert element is NOT present
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();

// Wait for element to appear
const user = await screen.findByText('Alice'); // waits up to 1000ms

Why it matters: Picking the wrong query family causes tests to pass when they shouldn't or fail with confusing errors — knowing the difference prevents wasted debugging time.

Real applications: Use queryByText to assert a loading spinner is gone; use findByText to wait for a user's name to appear after an API call.

Common mistakes: Using getBy* for elements that appear asynchronously — the test throws immediately because getBy doesn't wait.

Integration tests cover the full interaction flow between multiple components working together — filling form fields, submitting, and asserting on the resulting UI state or callback payload. They give much higher confidence than unit tests because they exercise the same paths users actually follow. Use MSW to mock network calls so tests are isolated but realistic. The ideal integration test reads like a user story: fill the form, click submit, see the success message.

test('creates a new user on form submit', async () => {
  const mockCreate = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });
  render(<NewUserForm onCreate={mockCreate} />);

  // Fill in the form
  await userEvent.type(screen.getByLabelText(/name/i), 'Alice');
  await userEvent.type(screen.getByLabelText(/email/i), 'alice@example.com');

  // Submit
  await userEvent.click(screen.getByRole('button', { name: /create/i }));

  // Assert the handler was called with correct data
  expect(mockCreate).toHaveBeenCalledWith({
    name: 'Alice', email: 'alice@example.com'
  });

  // Assert success feedback appears
  await screen.findByText(/user created/i);
});

Integration tests give higher confidence than unit tests because they test multiple components working together as the user would experience them.

Why it matters: Integration tests catch bugs at the boundary between components — where unit tests often miss the fact that pieces don't work well together.

Real applications: Test the full flow: fill a form, submit it, assert the success message — this tests the form component, validation, and submission handler all at once.

Common mistakes: Only writing unit tests for each piece separately — individual units can pass while the integrated flow still fails.