React Testing Tutorial: Component Tests and User Interactions

Learn React Testing Tutorial: Component Tests and User Interactions with practical examples, explanations, best practices, and real-world coding techniques.

React testing verifies that components behave correctly when users interact with them, data changes, and asynchronous operations succeed or fail. Tests help identify regressions early and make applications safer to refactor as features grow.

A useful React test focuses on observable behavior. Instead of checking internal state variables or implementation details, render the component, interact with it as a user would, and verify the result displayed in the interface.

What you will learn

  • Set up a React testing environment with Vitest and React Testing Library.
  • Test rendering, props, events, forms, and conditional UI.
  • Handle asynchronous behavior and API requests.
  • Mock dependencies and test custom Hooks.
  • Understand unit, integration, and end-to-end testing.
  • Write reliable tests without depending on implementation details.

1. Understand React Testing

React applications can be tested at different levels. Each level provides a different balance between speed, confidence, and maintenance cost.

Testing level Purpose Example
Unit testing Checks a small unit of logic in isolation. Testing a price calculation.
Component testing Checks rendered UI and its interactions. Testing a form's validation message.
Integration testing Checks multiple connected parts working together. Testing a form that submits data and displays the result.
End-to-end testing Checks a complete user journey in a browser. Testing login, navigation, and checkout.

Most React projects benefit from a combination of unit and component tests, a smaller number of integration tests, and end-to-end tests for critical workflows.

2. Choose a Testing Toolchain

Vitest is a test runner that works well with Vite-based React projects. React Testing Library provides utilities for rendering components and querying the resulting DOM. The user-event library simulates user interactions, while jest-dom adds convenient DOM assertions.

Vitest

Runs tests, provides assertions and mocking utilities, and integrates with Vite configuration.

React Testing Library

Renders components and encourages tests based on accessible, user-visible behavior.

user-event

Simulates interactions such as clicking, typing, and selecting values.

jest-dom

Adds readable assertions for DOM properties, accessibility-related attributes, and element visibility.

React Testing Library can be used with different test runners. This guide uses Vitest because it fits naturally into a typical React and Vite project. Jest is another valid option.

3. Install Testing Dependencies

In an existing React project created with Vite, install the testing libraries and a DOM environment. Use compatible versions of the packages for your React, Vite, and TypeScript setup.

npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

Configure Vitest in vite.config.ts. The following example assumes the project uses the Vite React plugin.

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: false,
    clearMocks: true,
  },
});

Create the setup file to load the DOM matchers before each test file runs.

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

Add a test script to the project's package.json.

{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Run npm run test to start Vitest in watch mode or npm run test:run to execute the suite once. The exact build script may differ between projects.

4. Write Your First Component Test

Consider a counter component that displays a number and increments it when the user clicks a button. The test should verify the initial display and the updated value after the interaction.

Counter component

// Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <p>Count: {count}</p>
      <button onClick={() => setCount((current) => current + 1)}>
        Increment
      </button>
    </section>
  );
}

Counter test

// Counter.test.tsx
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('increments the displayed count', async () => {
    const user = userEvent.setup();

    render(<Counter />);

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

    await user.click(
      screen.getByRole('button', { name: 'Increment' })
    );

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

The test uses render to mount the component, screen to find visible content, and userEvent to simulate a click. The assertion checks the result the user can observe instead of inspecting the component's internal state.

5. Query Elements the Way Users Find Them

React Testing Library offers several query methods. Prefer queries based on accessible roles and names because they encourage accessible interfaces and closely match how users and assistive technologies identify controls.

Query Behavior Typical use
getByRole Returns a matching element or throws if none or multiple matches are found. Buttons, headings, links, form controls.
getByLabelText Finds a form control through its associated label. Text inputs and checkboxes.
getByText Finds an element by its text content. Messages and visible descriptions.
findByRole Returns a Promise that resolves when a matching element appears. Content that appears asynchronously.
queryByRole Returns a matching element or null without throwing when absent. Asserting that an element is absent.

For example, use screen.getByRole('button', { name: 'Save' }) rather than selecting an element by an implementation-specific CSS class. Use getByTestId when a suitable user-facing query is not practical, not as the default for every element.

6. Test Props and Conditional Rendering

Props influence how a component renders. Test representative inputs and the visible outcomes they produce. Include empty or boundary cases when they are meaningful to the component's behavior.

// Greeting.tsx
type GreetingProps = {
  name: string;
  signedIn?: boolean;
};

export function Greeting({
  name,
  signedIn = false,
}: GreetingProps) {
  if (!signedIn) {
    return <p>Please sign in.</p>;
  }

  return <h1>Welcome, {name}!</h1>;
}
// Greeting.test.tsx
import { expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

it('shows the welcome message for a signed-in user', () => {
  render(<Greeting name="Taylor" signedIn />);

  expect(
    screen.getByRole('heading', { name: 'Welcome, Taylor!' })
  ).toBeInTheDocument();
});

it('asks signed-out users to sign in', () => {
  render(<Greeting name="Taylor" />);

  expect(screen.getByText('Please sign in.')).toBeInTheDocument();
});

These tests cover both branches of the component. They do not require knowledge of how the component stores or evaluates its props internally.

7. Test Forms and User Input

Form tests should cover entering values, validation feedback, submission, and relevant success or failure behavior. Use labels that make fields easy to find and accessible to users.

// EmailForm.tsx
import { useState } from 'react';

type EmailFormProps = {
  onSubmitEmail: (email: string) => void;
};

export function EmailForm({ onSubmitEmail }: EmailFormProps) {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      setError('Enter a valid email address.');
      return;
    }

    setError('');
    onSubmitEmail(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        aria-invalid={Boolean(error)}
        aria-describedby={error ? 'email-error' : undefined}
      />
      {error && (
        <p id="email-error" role="alert">{error}</p>
      )}
      <button type="submit">Continue</button>
    </form>
  );
}
// EmailForm.test.tsx
import { expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EmailForm } from './EmailForm';

it('shows validation feedback for an invalid email', async () => {
  const user = userEvent.setup();
  const onSubmitEmail = vi.fn();

  render(<EmailForm onSubmitEmail={onSubmitEmail} />);

  await user.type(
    screen.getByRole('textbox', { name: 'Email address' }),
    'not-an-email'
  );

  await user.click(
    screen.getByRole('button', { name: 'Continue' })
  );

  expect(
    screen.getByRole('alert')
  ).toHaveTextContent('Enter a valid email address.');

  expect(onSubmitEmail).not.toHaveBeenCalled();
});

it('submits a valid email address', async () => {
  const user = userEvent.setup();
  const onSubmitEmail = vi.fn();

  render(<EmailForm onSubmitEmail={onSubmitEmail} />);

  await user.type(
    screen.getByRole('textbox', { name: 'Email address' }),
    'alex@example.com'
  );

  await user.click(
    screen.getByRole('button', { name: 'Continue' })
  );

  expect(onSubmitEmail).toHaveBeenCalledWith('alex@example.com');
});

This example checks both an invalid submission and a successful submission. The regular expression is a basic client-side format check, not a complete email validation standard. Production applications should also handle server-side validation and errors. See React forms.

8. Test Asynchronous UI

API-driven components often display loading indicators before rendering data or errors. Tests should verify the visible transitions without relying on arbitrary delays. React Testing Library's findBy queries wait for an element to appear, while waitFor retries an assertion until it succeeds or times out.

// UserProfile.tsx
import { useEffect, useState } from 'react';

type User = {
  name: string;
};

export function UserProfile() {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      try {
        const response = await fetch('/api/user', {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error('Request failed');
        }

        const data = (await response.json()) as User;
        setUser(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(true);
        }
      }
    }

    loadUser();

    return () => controller.abort();
  }, []);

  if (error) {
    return <p role="alert">Unable to load profile.</p>;
  }

  if (!user) {
    return <p role="status">Loading profile...</p>;
  }

  return <h1>{user.name}</h1>;
}

The component uses an AbortController to cancel its request during cleanup. A production application should validate untrusted API data at runtime instead of assuming a TypeScript type assertion validates the response.

Test the loading and success states

// UserProfile.test.tsx
import { afterEach, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

afterEach(() => {
  cleanup();
  vi.unstubAllGlobals();
});

it('shows a loading message and then the user name', async () => {
  vi.stubGlobal(
    'fetch',
    vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ name: 'Morgan' }),
    })
  );

  render(<UserProfile />);

  expect(screen.getByRole('status')).toHaveTextContent(
    'Loading profile...'
  );

  expect(
    await screen.findByRole('heading', { name: 'Morgan' })
  ).toBeInTheDocument();
});

it('shows an error when the request fails', async () => {
  vi.stubGlobal(
    'fetch',
    vi.fn().mockRejectedValue(new Error('Network unavailable'))
  );

  render(<UserProfile />);

  expect(await screen.findByRole('alert')).toHaveTextContent(
    'Unable to load profile.'
  );
});

These tests replace the global fetch function and restore it after each test. The examples assume the component is rendered in a DOM environment that provides AbortController. In more complex applications, a dedicated API mocking tool can provide more realistic request handling.

9. Mock Dependencies Carefully

Mocking replaces a dependency with a controlled implementation. It is useful when a test needs to isolate a unit, simulate a failure, or avoid making real network requests. However, excessive mocking can hide integration problems and make tests pass even when the application is incorrectly wired together.

import { expect, it, vi } from 'vitest';

async function loadProducts(
  fetcher: typeof fetch = fetch
) {
  const response = await fetcher('/api/products');

  if (!response.ok) {
    throw new Error('Unable to load products');
  }

  return response.json();
}

it('returns products from the API', async () => {
  const fetcher = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => [{ id: 1, name: 'Keyboard' }],
  } as Response);

  const products = await loadProducts(fetcher);

  expect(products).toEqual([
    { id: 1, name: 'Keyboard' },
  ]);

  expect(fetcher).toHaveBeenCalledWith('/api/products');
});

Injecting a dependency can make isolated logic easier to test. The cast in this example keeps the snippet short; a production test can create a more complete Response mock or use a request-mocking library. Prefer mocking the network boundary rather than replacing every function inside the component.

10. Test Custom Hooks

Custom Hooks encapsulate reusable stateful logic. If a Hook's behavior can be verified through a component, testing it through the component often gives useful confidence in how users experience that behavior. For Hook logic that needs focused testing, React Testing Library provides renderHook.

// useCounter.ts
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  function increment() {
    setCount((current) => current + 1);
  }

  function reset() {
    setCount(initialValue);
  }

  return { count, increment, reset };
}
// useCounter.test.ts
import { act, renderHook } from '@testing-library/react';
import { expect, it } from 'vitest';
import { useCounter } from './useCounter';

it('increments and resets the counter', () => {
  const { result } = renderHook(() => useCounter(3));

  expect(result.current.count).toBe(3);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(4);

  act(() => {
    result.current.reset();
  });

  expect(result.current.count).toBe(3);
});

Use act when directly triggering updates outside the usual Testing Library interaction helpers. It ensures React processes the updates before assertions run. See custom Hooks.

11. Test Context Providers and Routing

Components may depend on Context providers, router state, or application-level configuration. Tests should provide the same essential dependencies the component expects, without unnecessarily rendering the entire application for every test.

import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { expect, it } from 'vitest';

function ProductPage() {
  return <h1>Product details</h1>;
}

it('renders a page at the configured route', () => {
  render(
    <MemoryRouter initialEntries={['/products/42']}>
      <Routes>
        <Route
          path="/products/:id"
          element={<ProductPage />}
        />
      </Routes>
    </MemoryRouter>
  );

  expect(
    screen.getByRole('heading', { name: 'Product details' })
  ).toBeInTheDocument();
});

This example verifies route matching and rendering. For components that read route parameters, test the resulting behavior with different paths. For Context-dependent components, render them with the required provider and test the values and interactions visible to users. See React Context and React Router.

12. Organize Tests in a React Project

Tests can live beside the component they cover or in a dedicated test directory. Co-location makes it easier to discover and maintain tests, while a separate structure may suit projects with established testing conventions.

src/
  components/
    Button.tsx
    Button.test.tsx
  features/
    products/
      ProductList.tsx
      ProductList.test.tsx
      useProducts.ts
      useProducts.test.ts
  test/
    setup.ts
  App.tsx
  main.tsx

Use clear test descriptions that explain the behavior being verified. Group related tests with describe, keep setup focused, and avoid repeating large blocks of configuration. Extract shared test helpers when they reduce duplication without hiding important dependencies.

13. Common React Testing Mistakes

Mistake Better approach
Testing internal state directly Assert the rendered output and observable behavior.
Using arbitrary timeouts Use asynchronous queries and wait utilities.
Using CSS selectors for every query Prefer roles, accessible names, labels, and visible text.
Mocking all dependencies Mock external boundaries while preserving meaningful integration.
Ignoring error states Test expected failures and recovery behavior.
Leaving global mocks in place Restore mocks and clean up shared state between tests.
Writing tests tied to implementation details Verify behavior that matters to users and the application contract.

14. React Testing Best Practices

  • Write tests around user-visible behavior and meaningful requirements.
  • Prefer accessible queries such as getByRole and getByLabelText.
  • Use userEvent for realistic interactions and await its methods.
  • Use findBy and waitFor for genuinely asynchronous behavior.
  • Test success, loading, empty, validation, and error states where applicable.
  • Keep mocks limited to dependencies that need isolation or controlled outcomes.
  • Restore global mocks and reset test state between cases.
  • Run tests in CI so regressions are detected before deployment.
  • Keep tests independent and avoid relying on execution order.
  • Use end-to-end tests for critical journeys that span multiple application layers.

15. Run Tests in Continuous Integration

Running tests locally is useful, but a CI pipeline helps ensure changes are tested consistently before they are merged or deployed. A typical pipeline installs dependencies, runs linting and tests, and builds the production application.

npm ci
npm run test:run
npm run build

Configure your CI provider to fail the workflow when any required step fails. Avoid treating code coverage as the only measure of test quality: high coverage does not guarantee that important behaviors are tested meaningfully.

Continue Learning

Build testing skills alongside the React concepts you use in production. Start with components and state, then practice forms, custom Hooks, and asynchronous API behavior.

Code Examples

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { Counter } from "./Counter";

it("increments the count", async () => {
  const user = userEvent.setup();
  render(<Counter />);

  await user.click(
    screen.getByRole("button", { name: "Increment" })
  );

  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
    
it("shows an error for invalid input", async () => {
  const user = userEvent.setup();
  render(<EmailForm onSubmitEmail={vi.fn()} />);

  await user.type(
    screen.getByRole("textbox", { name: "Email address" }),
    "invalid"
  );
  await user.click(screen.getByRole("button", { name: "Continue" }));

  expect(screen.getByRole("alert")).toHaveTextContent(
    "Enter a valid email address."
  );
});
    
render(<UserProfile />);

expect(screen.getByRole("status")).toHaveTextContent(
  "Loading profile..."
);

expect(
  await screen.findByRole("heading", { name: "Morgan" })
).toBeInTheDocument();
    
const { result } = renderHook(() => useCounter(2));

expect(result.current.count).toBe(2);

act(() => {
  result.current.increment();
});

expect(result.current.count).toBe(3);
    
npm ci
npm run test:run
npm run build
    

Pro Tip

Test what users can observe, not how components are implemented. Prefer accessible queries, realistic interactions, and explicit coverage of loading and error states. Keep mocks focused on external boundaries so tests remain useful when the implementation changes.

🎨 IgKnight Docs

Master HTML, CSS, JS, Python, .NET and more with easy-to-follow lessons and practical code examples.

© 2025 IgKnight Learning Platform. All rights reserved.