Skip to content

Testing React Apps — React Testing Library & Vitest (2026)

DodaTech Updated 2026-06-20 7 min read

In this tutorial, you'll learn about Testing React Apps. We cover key concepts, practical examples, and best practices.

Testing React applications involves verifying that components render correctly, respond to user interactions, handle asynchronous operations, and maintain accessibility — using tools like React Testing Library and Vitest.

What You'll Learn

You'll understand the Testing Library philosophy (test behavior, not implementation), set up Vitest with React Testing Library, test hooks and async behavior, mock API calls, and test for accessibility.

Why Testing React Apps Matters

React components are the building blocks of modern web UIs. A broken component can break the entire user experience. At DodaTech, Doda Browser's settings panels and tab management UI are tested with React Testing Library to ensure every button, form, and navigation element works correctly.

React Testing Learning Path

flowchart LR
  A[Jest] --> B[React Testing Library]
  B --> C[Testing Hooks]
  B --> D[Mocking API Calls]
  B --> E[Accessibility Testing]
  style B fill:#f90,color:#fff

Testing Library Philosophy

The guiding principle: test how users interact with your application, not implementation details.

Test This Not This
Button text visible Component state value
Form submission behavior Internal handler name
Accessible labels CSS class names
Rendered output Lifecycle method calls

Setting Up Vitest + React Testing Library

npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.js',
  },
});
// src/test/setup.js
import '@testing-library/jest-dom';

Testing Basic Components

// Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);
  expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});

Expected output:

PASS  ./Greeting.test.jsx
  ✓ renders greeting with name (12 ms)

Testing User Interactions

Use @testing-library/user-event for realistic user interactions.

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

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

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

test('increments count when button is clicked', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  const button = screen.getByRole('button', { name: 'Increment' });
  await user.click(button);
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

test('resets count to zero', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole('button', { name: 'Increment' }));
  await user.click(screen.getByRole('button', { name: 'Increment' }));
  await user.click(screen.getByRole('button', { name: 'Reset' }));
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
});

Expected output:

PASS  ./Counter.test.jsx
  ✓ increments count when button is clicked (25 ms)
  ✓ resets count to zero (18 ms)

Testing Hooks

Custom hooks can be tested with renderHook.

// useCounter.js
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = useCallback(() => setCount(c => c + 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  return { count, increment, reset };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('initializes with default value', () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
});

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

test('resets to initial value', () => {
  const { result } = renderHook(() => useCounter(10));
  act(() => { result.current.increment(); result.current.reset(); });
  expect(result.current.count).toBe(10);
});

Expected output:

PASS  ./useCounter.test.js
  ✓ initializes with default value (3 ms)
  ✓ increments count (2 ms)
  ✓ resets to initial value (2 ms)

Testing Async Behavior

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

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { setUser(data); setLoading(false); });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  return <div><h2>{user.name}</h2><p>{user.email}</p></div>;
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

beforeEach(() => {
  global.fetch = jest.fn();
});

test('shows loading state initially', () => {
  render(<UserProfile userId={1} />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
});

test('renders user data after fetch', async () => {
  global.fetch.mockResolvedValue({
    json: async () => ({ id: 1, name: 'Alice', email: 'alice@test.com' }),
  });
  render(<UserProfile userId={1} />);
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

Expected output:

PASS  ./UserProfile.test.jsx
  ✓ shows loading state initially (5 ms)
  ✓ renders user data after fetch (15 ms)

Testing Accessibility

Testing Library includes built-in accessibility queries.

// NavMenu.jsx
function NavMenu() {
  return (
    <nav aria-label="Main navigation">
      <ul>
        <li><a href="/home">Home</a></li>
        <li><a href="/settings">Settings</a></li>
      </ul>
    </nav>
  );
}

// NavMenu.test.jsx
import { render, screen } from '@testing-library/react';
import NavMenu from './NavMenu';

test('finds navigation by role', () => {
  render(<NavMenu />);
  const nav = screen.getByRole('navigation', { name: 'Main navigation' });
  expect(nav).toBeInTheDocument();
});

test('finds links by accessible name', () => {
  render(<NavMenu />);
  expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/home');
});

Expected output:

PASS  ./NavMenu.test.jsx
  ✓ finds navigation by role (7 ms)
  ✓ finds links by accessible name (3 ms)

Best Practices

1. Find Elements Like Users Do

Use getByRole, getByLabelText, getByText — not getByTestId unless necessary.

2. Avoid Testing Implementation Details

Don't assert on component state, props, or internal methods. Test rendered output.

3. Use userEvent Over fireEvent

userEvent simulates real browser interactions (hover, focus, keyboard) — more realistic.

4. Keep Tests Isolated

Each test should render the component fresh. No shared state.

5. Mock External Boundaries Only

Mock API calls and browser APIs, not internal modules.

Common Mistakes

1. Over-Using data-testid

Adding test IDs everywhere couples tests to implementation. Prefer semantic queries.

2. Testing Implementation Not Behavior

Asserting on component state instead of visible output makes tests brittle.

3. Not Using waitFor for Async

Testing async updates without waitFor leads to flaky tests.

4. Too Many Snapshot Tests

Large snapshots obscure real changes. Use targeted assertions.

5. Not Cleaning Up Mocks

Mocked global functions leak between tests. Use beforeEach to clear.

6. Testing Every Component the Same Way

Presentation components need different testing than container components.

7. Ignoring Accessibility in Tests

If you can't find an element by role, neither can screen readers.

Practice Questions

1. What is the Testing Library philosophy? Test how users interact with your application, not implementation details. Use semantic queries over test IDs.

2. What is userEvent and why is it preferred over fireEvent? userEvent simulates real browser interactions — click, type, hover — with realistic event sequences.

3. How do you test a custom React hook? Use renderHook from Testing Library and act to wrap state updates.

4. How do you mock a global fetch call? Assign global.fetch = jest.fn() in beforeEach and use mockResolvedValue.

5. Challenge: Build and test a search input with debounce. Create a component that fetches results after the user stops typing for 300ms. Test the loading, results, and empty states.

Mini Project: Todo App Tests

// TodoApp.jsx
import { useState } from 'react';

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, { id: Date.now(), text: input, done: false }]);
      setInput('');
    }
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
  };

  return (
    <div>
      <h1>Todo App</h1>
      <div>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Add a todo"
          aria-label="New todo"
        />
        <button onClick={addTodo}>Add</button>
      </div>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => toggleTodo(todo.id)}
              />
              <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
                {todo.text}
              </span>
            </label>
          </li>
        ))}
      </ul>
      {todos.length === 0 && <p data-testid="empty">No todos yet</p>}
    </div>
  );
}

FAQ

What is the difference between React Testing Library and Enzyme?

Enzyme tests implementation details (state, props, lifecycle). Testing Library tests behavior (rendered output, accessibility, user interactions).

Should I test every React component?

Focus on components with logic: containers, forms, connected components. Simple presentational components may not need dedicated tests.

How do I test React Router components?

Wrap the component in a MemoryRouter and test that the correct routes render based on the current location.

Can I use Testing Library with Vitest?

Yes. Vitest is a drop-in replacement for Jest with the same API. Testing Library works identically with both.

How do I test context providers?

Create a wrapper component that provides the context value, then render the component inside it.

What's Next

Accessibility Testing — Automated a11y Testing
Test Strategy — Planning Your Testing Approach
Snapshot Testing — When & How to Use

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro