Chapter 20

Testing Strategy: Complete Guide to Vitest + React Testing Library

Tests are engineering insurance, but tests written poorly become a liability. Many teams produce test suites that are brittle, expensive to maintain, and break every time a component is refactoredโ€”even when the behavior is unchanged. The root cause is almost never a wrong tool choice. It is a wrong philosophy: tests that verify implementation details rather than user behavior. This chapter covers the philosophy and practice of building a genuinely valuable test suite with Vitest and React Testing Library.

The Core Philosophy: Test Behavior, Not Implementation

React Testing Library's design is rooted in a single guiding principle from its author Kent C. Dodds:

"The more your tests resemble the way your software is used, the more confidence they can give you."

In concrete terms, this means:

This philosophy makes tests reflect business value rather than code structure. When you safely refactor a component's internals, tests should keep passing. If a refactor causes a cascade of test failures, the tests are coupled to implementationโ€”and coupling is the enemy of maintenance.

Setup

Installing Dependencies

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

vitest.config.ts

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',       // simulates a browser DOM environment
    globals: true,              // no need to import describe/it/expect in every file
    setupFiles: ['./src/test/setup.ts'],
  },
});

Global Setup File

// src/test/setup.ts
import '@testing-library/jest-dom'; // adds semantic DOM matchers: toBeInTheDocument, etc.

@testing-library/jest-dom extends expect with a set of readable DOM assertions:

expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('Confirm');
expect(input).toHaveValue('[email protected]');

The render + screen + userEvent Pattern

This is the fundamental usage pattern of React Testing Library:

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

test('clicking the button fires the onClick callback', async () => {
  // 1. setup: userEvent.setup() returns a user simulation instance
  const user = userEvent.setup();
  const handleClick = vi.fn();

  // 2. render: mount the component into a virtual DOM
  render(<Button onClick={handleClick}>Submit</Button>);

  // 3. screen: query elements the way a user perceives them
  const button = screen.getByRole('button', { name: 'Submit' });

  // 4. simulate user interaction
  await user.click(button);

  // 5. assert the outcome
  expect(handleClick).toHaveBeenCalledOnce();
});

The screen Query Hierarchy

screen query methods are ordered by priority. Prefer methods higher on the list:

Method When to use
getByRole First choice. Queries by ARIA roleโ€”closest to how assistive technology sees the page
getByLabelText Form elements associated with a label
getByPlaceholderText Input fields lacking a label (not ideal, but common in practice)
getByText Non-interactive elements matched by visible text
getByDisplayValue The current value of a form control
getByAltText Images matched by their alt attribute
getByTestId Last resort. Uses data-testidโ€”reach for this only when nothing else works

getBy* throws when the element is not found (fail fast). queryBy* returns null when not found (use for asserting absence). findBy* returns a Promise and waits for the element to appear (use for async content).

Async Testing with waitFor

Real components involve asynchronous operationsโ€”data fetching, debounced search, animations.

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

test('displays the user name after data loads', async () => {
  const user = userEvent.setup();

  render(<UserProfile userId="123" />);

  // initial state: loading indicator is visible
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // findBy* internally uses waitForโ€”it polls until the element appears or times out
  const nameElement = await screen.findByText('Jane Smith');
  expect(nameElement).toBeInTheDocument();

  // loading state is gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

// waitFor accepts any assertion callback
test('search results appear after debounce', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  await user.type(screen.getByRole('searchbox'), 'react');

  await waitFor(() => {
    expect(screen.getAllByRole('listitem')).toHaveLength(5);
  }, { timeout: 3000 }); // default timeout is 1000ms
});

An important note: do not manually wrap assertions in act. Both userEvent and findBy*/waitFor correctly handle React's batched update cycle internally. Manual act usage is error-prone and usually unnecessary.

Mocking Strategies

vi.mock for Module Mocking

// mock the entire module
vi.mock('../services/api', () => ({
  fetchUser: vi.fn().mockResolvedValue({
    id: '1',
    name: 'Jane Smith',
    email: '[email protected]',
  }),
}));

test('displays user information', async () => {
  render(<UserProfile userId="1" />);
  await screen.findByText('Jane Smith');
  expect(screen.getByText('[email protected]')).toBeInTheDocument();
});

MSW for API Mocking

For HTTP request mocking, Mock Service Worker (MSW) is the more robust solution. It intercepts requests at the Service Worker layer, decoupling tests completely from your networking code:

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Jane Smith',
      email: '[email protected]',
    });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 'new-id', ...body }, { status: 201 });
  }),
];

// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.ts โ€” add MSW lifecycle hooks
import { server } from './server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // prevent handler leakage between tests
afterAll(() => server.close());

Override a handler for a single test:

test('shows error state on server failure', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserProfile userId="1" />);
  await screen.findByText('Failed to load. Please try again.');
});

MSW's key advantage: your component code has no idea it is being tested. The fetch or axios call goes out as usual and is intercepted at the network layer. This is true end-to-end testing of the entire data flow, minus the actual server.

Testing Custom Hooks

renderHook is designed for testing hook logic that is not tied to a specific piece of UI:

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

test('counter increments correctly', () => {
  const { result } = renderHook(() => useCounter(0));

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

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

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

test('count does not go below minimum', () => {
  const { result } = renderHook(() => useCounter(0, { min: 0 }));

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

  expect(result.current.count).toBe(0); // stays at 0, does not become -1
});

When a hook depends on context, provide it via the wrapper option:

const { result } = renderHook(() => useAuth(), {
  wrapper: ({ children }) => (
    <AuthProvider initialUser={mockUser}>{children}</AuthProvider>
  ),
});

Snapshot Tests: When They Help and When They Hurt

Snapshot testing (toMatchSnapshot) has a limited but legitimate use caseโ€”pure presentational components in a component library where visual regression detection is the goal:

// Useful: establishing a visual baseline for a stable UI primitive
test('Button renders consistently', () => {
  const { container } = render(<Button variant="primary">Submit</Button>);
  expect(container).toMatchSnapshot();
});

The problems with snapshot testing:

Recommendation: For behavior tests (clicks, input, state transitions), use getBy* with specific assertions. For visual regression testing of stable design system components, consider Storybook's visual regression integration (Chromatic, Percy) rather than DOM snapshots.

Coverage: What to Measure and What to Ignore

Vitest has built-in coverage support:

// vitest.config.ts
test: {
  coverage: {
    provider: 'v8',           // or 'istanbul'
    reporter: ['text', 'html', 'lcov'],
    include: ['src/**/*.{ts,tsx}'],
    exclude: ['src/**/*.stories.tsx', 'src/test/**'],
    thresholds: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
}

Coverage numbers are indicators, not goals. Eighty percent coverage with thoughtfully designed behavioral tests provides far more confidence than one hundred percent coverage consisting of implementation assertions and meaningless snapshots.

Scenarios that deserve tests:

Scenarios where testing adds little value:

CI Integration: GitHub Actions Example

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run type check
        run: npm run type-check

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info
          fail_ci_if_error: true

Corresponding package.json scripts:

{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "type-check": "tsc --noEmit"
  }
}

A Complete Integration Test Example

A login form demonstrates the full picture of production-quality testing:

// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { server } from '../test/server';
import { http, HttpResponse } from 'msw';

describe('LoginForm', () => {
  test('navigates to home page after successful login', async () => {
    const user = userEvent.setup();
    const onSuccess = vi.fn();

    render(<LoginForm onSuccess={onSuccess} />);

    await user.type(screen.getByLabelText('Email'), '[email protected]');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    await waitFor(() => {
      expect(onSuccess).toHaveBeenCalledWith(
        expect.objectContaining({ email: '[email protected]' })
      );
    });
  });

  test('shows error message on invalid credentials', async () => {
    server.use(
      http.post('/api/auth/login', () => {
        return HttpResponse.json(
          { message: 'Invalid email or password' },
          { status: 401 }
        );
      })
    );

    const user = userEvent.setup();
    render(<LoginForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('Email'), '[email protected]');
    await user.type(screen.getByLabelText('Password'), 'wrongpassword');
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    await screen.findByText('Invalid email or password');
    expect(screen.getByRole('button', { name: 'Sign In' })).not.toBeDisabled();
  });

  test('disables submit button while request is in flight', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('Email'), '[email protected]');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    // button should be disabled while the request is pending
    expect(screen.getByRole('button', { name: /Signing in/ })).toBeDisabled();
  });
});

Good tests are a product of engineering culture, not a process checkpoint. When a team genuinely internalizes "test behavior, not implementation," the test suite becomes an asset that provides confidence during refactoringโ€”rather than a maintenance burden that resists change.

Rate this chapter
4.8  / 5  (9 ratings)

๐Ÿ’ฌ Comments