On this page

Testing with Vitest and Testing Library: Confidence Through Tests

14 min read TextCh. 5 — Production

Why Test React Components?

Automated tests give you confidence that your application works correctly as it evolves. They catch regressions — bugs introduced when changing existing code — before users encounter them.

The Testing Trophy (Kent C. Dodds) suggests the ideal test distribution for React:

  • Static (TypeScript, ESLint): catches type errors and code quality issues
  • Unit: tests individual functions and hooks in isolation
  • Integration: tests components working together (the majority of your tests)
  • End-to-End: tests the full user journey in a real browser (fewer, slower)

Vitest is the test runner — it executes your tests and reports results. Testing Library provides utilities for rendering components and querying the DOM in a way that mimics how real users interact with the UI.

Setup

Install the necessary packages:

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

Configure Vitest in vite.config.ts:

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

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

Create src/test-setup.ts:

import '@testing-library/jest-dom';
// Now you can use matchers like .toBeInTheDocument(), .toBeDisabled(), etc.

Writing Your First Test

Let us test this simple component:

// Counter.tsx
interface CounterProps {
  initialValue?: number;
  min?: number;
  max?: number;
  onCountChange?: (count: number) => void;
}

function Counter({ initialValue = 0, min = -Infinity, max = Infinity, onCountChange }: CounterProps) {
  const [count, setCount] = useState(initialValue);

  function change(delta: number) {
    const next = count + delta;
    if (next < min || next > max) return;
    setCount(next);
    onCountChange?.(next);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button type="button" onClick={() => change(-1)} disabled={count <= min} aria-label="Decrement">

      </button>
      <button type="button" onClick={() => change(1)} disabled={count >= max} aria-label="Increment">
        +
      </button>
    </div>
  );
}

Core Testing Library Queries

Testing Library provides multiple query strategies:

// Get by accessible role (most preferred)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });

// Get by label text
screen.getByLabelText('Email address');

// Get by placeholder
screen.getByPlaceholderText('Search...');

// Get by text content
screen.getByText('No results found.');

// Get by test ID (last resort)
screen.getByTestId('loading-spinner');

// Variants:
// getBy* — throws if not found
// queryBy* — returns null if not found (for asserting absence)
// findBy* — async, waits for element to appear

Async Testing with waitFor

For components that update asynchronously (after fetch, after state updates):

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

it('shows search results after typing', async () => {
  const user = userEvent.setup();
  render(<SearchInput />);

  const input = screen.getByRole('searchbox', { name: /search/i });
  await user.type(input, 'React');

  // Wait for async results to appear
  await waitFor(() => {
    expect(screen.getByText(/React Hooks/)).toBeInTheDocument();
  });
});

// Or use findBy* which implicitly waits
it('shows loading text then data', async () => {
  render(<AsyncDataComponent />);

  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // findByText waits up to 1000ms by default
  const heading = await screen.findByRole('heading', { name: /data loaded/i });
  expect(heading).toBeInTheDocument();
});

Testing Forms

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

describe('LoginForm', () => {
  it('submits with valid credentials', async () => {
    const user = userEvent.setup();
    const onSuccess = vi.fn();
    render(<LoginForm onSuccess={onSuccess} />);

    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'securepassword');
    await user.click(screen.getByRole('button', { name: /log in/i }));

    await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
  });

  it('shows validation error for invalid email', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText(/email/i), 'not-an-email');
    await user.click(screen.getByRole('button', { name: /log in/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent(/valid email/i);
  });
});

Testing Custom Hooks with renderHook

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

describe('useToggle', () => {
  it('starts with the initial value', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current.value).toBe(true);
  });

  it('toggles the value', () => {
    const { result } = renderHook(() => useToggle(false));
    act(() => result.current.toggle());
    expect(result.current.value).toBe(true);
    act(() => result.current.toggle());
    expect(result.current.value).toBe(false);
  });

  it('setTrue always sets to true', () => {
    const { result } = renderHook(() => useToggle(false));
    act(() => result.current.setTrue());
    expect(result.current.value).toBe(true);
    act(() => result.current.setTrue()); // idempotent
    expect(result.current.value).toBe(true);
  });
});

Mocking with vi.fn() and vi.mock()

import { vi } from 'vitest';

// Mock a function
const handleSubmit = vi.fn();

// Assert it was called
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith({ email: '[email protected]' });

// Mock a module
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  deleteUser: vi.fn().mockResolvedValue({ success: true }),
}));

// Mock fetch globally
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
mockFetch.mockResolvedValue({
  ok: true,
  json: async () => ({ items: [1, 2, 3] }),
});

Testing with Context Providers

Components that consume context need to be wrapped with their provider in tests:

import { type ReactNode } from 'react';

function renderWithProviders(ui: ReactNode) {
  return render(
    <QueryClientProvider client={new QueryClient()}>
      <AuthProvider>
        <ThemeProvider>
          {ui}
        </ThemeProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

it('shows user name when authenticated', async () => {
  renderWithProviders(<UserMenu />);
  // test against the actual context state
});

Accessibility Testing with axe

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('has no accessibility violations', async () => {
  const { container } = render(<RegisterForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

This automatically checks for common WCAG violations including missing labels, poor contrast ratios, and invalid ARIA attributes.

In the final lesson, you will apply everything you have learned by building a complete Kanban task board with drag-and-drop, filtering, and persistent state.

Query by role and accessible name, not by test IDs
Prefer `getByRole('button', { name: /submit/i })` over `getByTestId('submit-btn')`. Role-based queries validate accessibility at the same time as functionality — if a screen reader cannot find the element, neither can your test. Use `getByTestId` only as a last resort.
userEvent vs fireEvent
`userEvent` from `@testing-library/user-event` simulates real user interactions including focus, hover, typing character by character, and keyboard events. `fireEvent` is lower-level and only dispatches a single DOM event. Always prefer `userEvent` for more realistic tests.
Mock at the network layer, not the module layer
Use `vi.stubGlobal('fetch', mockFetch)` or MSW (Mock Service Worker) to intercept HTTP requests instead of mocking individual service modules. This tests the full data flow through your hooks and components, giving you higher confidence that everything works together.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter component', () => {
  it('renders the initial count', () => {
    render(<Counter initialValue={5} />);
    expect(screen.getByText('Count: 5')).toBeInTheDocument();
  });

  it('increments the count when the + button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} />);

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

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

  it('decrements the count when the − button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={3} />);

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

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

  it('calls onCountChange with the new value', async () => {
    const user = userEvent.setup();
    const onCountChange = vi.fn();
    render(<Counter initialValue={0} onCountChange={onCountChange} />);

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

    expect(onCountChange).toHaveBeenCalledOnce();
    expect(onCountChange).toHaveBeenCalledWith(1);
  });

  it('does not go below min value', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={0} min={0} />);
    const decrementBtn = screen.getByRole('button', { name: /decrement/i });

    expect(decrementBtn).toBeDisabled();
    await user.click(decrementBtn);

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