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:
- Do not assert internal state. Instead of
component.state.isLoading === true, assert that the user sees a loading indicator. - Do not assert prop drilling. Instead of checking that a child received a particular prop, assert that the corresponding content appears on the page.
- Do not assert implementation details. Instead of verifying that a private function was called N times, assert what the user observes after the action.
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:
- Too easy to pass. Updating snapshots requires only
vitest --update-snapshots. Developers often update without scrutinizing the diff, eliminating any protective value. - They do not communicate intent. A snapshot failure tells you "something changed," not whether the change is correct or incorrect.
- High maintenance cost. Every intentional UI change requires snapshot updates.
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:
- Business logic branches: if/else conditions, conditional renders, boundary conditions
- User interaction flows: form submission, validation, success/failure states
- Error handling: network failures, invalid input, permission errors
- Accessibility critical paths: keyboard navigation, screen reader labels
Scenarios where testing adds little value:
- Pure presentational components (no state, no interaction, just renders data)
- Third-party library behavior (do not test that axios sends a request)
- Style details (colors, spacingโunless you have strict UI specifications)
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.