Skip to main content

Unit Testing React Components with Vitest

Unit testing a React component means testing that component in isolation—verifying it renders correctly given certain props, responds to user events, and updates state as expected. A good unit test suite catches bugs early and makes refactoring safe because you have instant feedback when you break something (React Testing Library community, 2026). This article teaches the patterns and thought process for writing maintainable component tests.

What Is a Unit Test vs. an Integration Test?

A unit test verifies one component in isolation with mocked dependencies. An integration test verifies multiple components working together. For example:

  • Unit test: <Button label="Click" onClick={vi.fn()} /> with a mocked callback.
  • Integration test: <Form onSubmit={vi.fn()} /> → user fills inputs → clicks submit → real form validation runs.

Start with unit tests because they're faster and pinpoint bugs precisely. Add integration tests later to verify components work together. Most of this article focuses on unit tests.

Common Test Scenarios and Patterns

Scenario 1: Props → Output

Test that a component renders the right content based on props.

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Card } from './Card';

describe('Card', () => {
it('renders title and description from props', () => {
render(
<Card
title="React 2026"
description="Learn modern patterns"
/>
);

expect(screen.getByText('React 2026')).toBeInTheDocument();
expect(screen.getByText('Learn modern patterns')).toBeInTheDocument();
});

it('renders optional image when provided', () => {
const { rerender } = render(<Card title="Test" />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();

// Re-render with image prop
rerender(<Card title="Test" image="cover.png" />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
});

Pattern: Render with props, assert the DOM reflects those props. Use rerender() to test behavior as props change.

Scenario 2: User Events → State Update

Test that clicks, form inputs, and other events trigger the expected state changes.

it('increments counter on button click', async () => {
render(<Counter />);

const btn = screen.getByRole('button', { name: /increment/i });
const count = screen.getByText('Count: 0');

await userEvent.click(btn);
expect(screen.getByText('Count: 1')).toBeInTheDocument();

await userEvent.click(btn);
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});

Pattern: Render, find the trigger (button/input), interact with userEvent, and assert the DOM updated.

Scenario 3: Conditional Rendering

Test that components show/hide elements based on state or props.

it('shows error message when validation fails', async () => {
render(<SignupForm />);

// Initially no error
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

// Submit empty form
const submit = screen.getByRole('button', { name: /sign up/i });
await userEvent.click(submit);

// Now error appears
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Name is required');
});

Pattern: Assert absence with queryBy*, trigger the state change, then assert presence.

Structure Your Tests with describe() Blocks

Organize tests into logical groups using describe():

describe('Button', () => {
describe('when disabled', () => {
it('does not respond to clicks', async () => {
render(<Button disabled onClick={vi.fn()} label="Click" />);
const btn = screen.getByRole('button');

await userEvent.click(btn);
expect(btn).toHaveAttribute('disabled');
});
});

describe('when loading', () => {
it('shows spinner instead of label', () => {
render(<Button loading label="Submit" />);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByText('Submit')).not.toBeInTheDocument();
});
});
});

This nesting mirrors your mental model and makes test output readable: Button › when disabled › does not respond to clicks.

Testing Child Components and Props Drilling

When testing a parent component that passes props to children, test only the parent's interface. The children's behavior is tested in their own tests.

// ❌ Don't reach into children
it('passes data to List', () => {
const items = [{ id: 1, name: 'Alice' }];
render(<Dashboard data={items} />);
// Avoid testing <List> internals here
});

// ✓ Do test the parent's behavior
it('displays items from props', () => {
render(<Dashboard items={[{ id: 1, name: 'Alice' }]} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});

Keep tests focused on one component's responsibility.

Testing Edge Cases

Edge cases are boundaries where bugs hide. Always test them.

describe('Avatar', () => {
it('renders initials when image fails to load', () => {
render(<Avatar name="Alice Smith" image="broken.jpg" />);
expect(screen.getByText('AS')).toBeInTheDocument();
});

it('handles empty name gracefully', () => {
render(<Avatar name="" />);
expect(screen.getByAltText('User avatar')).toBeInTheDocument();
});

it('truncates long names to fit circle', () => {
const longName = 'A'.repeat(100);
render(<Avatar name={longName} />);
const avatar = screen.getByText(/A/);
expect(avatar.textContent?.length).toBeLessThanOrEqual(2);
});
});

Testing Accessibility Props

Always test that accessible attributes are present:

it('has proper ARIA attributes', () => {
render(<Modal isOpen title="Delete Item" onClose={vi.fn()} />);

const modal = screen.getByRole('dialog');
expect(modal).toHaveAttribute('aria-labelledby');

const closeBtn = screen.getByRole('button', { name: /close/i });
expect(closeBtn).toHaveAttribute('aria-label');
});

Avoiding Common Pitfalls

Don't test implementation details: Avoid testing internal state, component methods, or refs.

// ❌ Brittle: relies on internal state name
expect(component.state.count).toBe(1);

// ✓ Robust: tests observable behavior
expect(screen.getByText('Count: 1')).toBeInTheDocument();

Don't test third-party libraries: If your component uses a date picker library, don't test the library's behavior—test that your component calls it correctly.

// ❌ Testing the library, not your code
expect(datePicker.isOpen()).toBe(true);

// ✓ Testing your component's integration
render(<DateSelector onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /select date/i })).toBeInTheDocument();

Key Takeaways

  • A unit test verifies one component in isolation with mocked dependencies.
  • Test three core scenarios: props produce correct output, events trigger state updates, conditional rendering works.
  • Use describe() blocks to organize tests into logical groups.
  • Test observable behavior (DOM), not implementation details (internal state, methods, refs).
  • Always test edge cases and accessibility attributes.
  • Keep tests focused on one component's responsibility; avoid testing children's internals.

Frequently Asked Questions

How many tests should a component have?

Aim for 3–5 tests per component: one for happy path, one for each error case, and one for edge cases. Don't chase 100% coverage—focus on tests that catch real bugs. A well-tested component has 2–3 high-value tests, not 10 low-value ones.

How do I test a component that uses a custom hook?

Test the component, not the hook in isolation (hooks are tested separately). If your component uses a hook like useForm, render the component and verify the hook's behavior through the component's output. For example, if useForm provides values and onChange, verify the component displays those values and updates them on input.

Should I test every condition in an if statement?

Test behaviors that affect the user experience. If you have if (isAdmin) return <AdminPanel />, test that admins see the admin panel and non-admins don't. You don't need to test the language feature if itself—you're testing your component's logic.

How do I test a component that makes API calls?

Mock the API in your test setup. Use vi.mock() to stub the API module, then render the component and verify it displays the mocked data. This is covered in depth in later articles on mocking.

How do I test ref-based behavior (focus, blur, etc.)?

Refs are implementation details. Instead, test the observable effect. For example, instead of testing inputRef.current.focus(), verify that autofocus works by checking that the input has the native autoFocus attribute or that focus moves correctly when the component mounts.

Further Reading