Testing React Components with Vitest and Testing Library
Writing tests feels like extra work until the day you refactor something and your tests catch a bug before it reaches production. Then you're a believer.
Let me show you how to write tests that actually give you confidence.
Why Vitest and Testing Library?
Vitest is a blazing-fast test runner built for Vite projects. It's like Jest but faster and better integrated with modern tooling.
React Testing Library encourages you to test components the way users interact with them. No implementation details, no internal state—just what users see and do.
Philosophy: "The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds
Setup
First, install the dependencies:
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Configure Vitest in vitest.config.ts:
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.ts',
},
});
Create the setup file at src/test/setup.ts:
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});
Add test script to package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
Your First Test: A Simple Button
Let's start with something basic—a button component:
// Button.tsx
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}
export function Button({ onClick, children, disabled }: ButtonProps) {
return (
);
}
Now let's test it:
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render();
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render();
await user.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render();
await user.click(screen.getByText('Click me'));
expect(handleClick).not.toHaveBeenCalled();
});
});
Key concepts here:
render() renders your component into a virtual DOM. screen gives you methods to find elements. vi.fn() creates a mock function so you can assert it was called. userEvent simulates real user interactions.
Testing Form Inputs
Forms are everywhere. Let's test a search input:
// SearchInput.tsx
interface SearchInputProps {
onSearch: (query: string) => void;
placeholder?: string;
}
export function SearchInput({ onSearch, placeholder }: SearchInputProps) {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
);
}
The test:
// SearchInput.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { SearchInput } from './SearchInput';
describe('SearchInput', () => {
it('calls onSearch with the input value', async () => {
const handleSearch = vi.fn();
const user = userEvent.setup();
render( );
const input = screen.getByLabelText('Search');
const button = screen.getByRole('button', { name: 'Search' });
await user.type(input, 'laptop');
await user.click(button);
expect(handleSearch).toHaveBeenCalledWith('laptop');
});
it('updates input value as user types', async () => {
const user = userEvent.setup();
render( {}} />);
const input = screen.getByLabelText('Search');
await user.type(input, 'phone');
expect(input).toHaveValue('phone');
});
});
Notice: We're using getByLabelText('Search') and getByRole('button'). This tests accessibility too—if your label isn't properly connected or your button isn't a button, the test fails.
Query Methods: When to Use What
Testing Library gives you different query methods. Choosing the right one matters.
// getBy* - Element must exist, throws if not found
const button = screen.getByText('Submit');
// queryBy* - Returns null if not found (good for asserting absence)
const error = screen.queryByText('Error message');
expect(error).not.toBeInTheDocument();
// findBy* - Returns a promise, waits for element to appear (async)
const data = await screen.findByText('Products loaded');
// getAllBy* - Returns array of matching elements
const items = screen.getAllByRole('listitem');
Aha moment: I once used getByText() to check if an error message wasn't shown. The test failed because getBy* throws when it can't find something. Use queryBy* for negative assertions.
Testing Async Components with Data Fetching
Let's test a component that fetches data. This is where most tests get complicated.
// ProductList.tsx
interface Product {
id: number;
name: string;
price: number;
}
export function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
setProducts(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return Loading products...;
if (error) return Error: {error};
return (
{products.map(product => (
-
{product.name} - ${product.price}
))}
);
}
To test this, we need to mock fetch:
// ProductList.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductList } from './ProductList';
describe('ProductList', () => {
beforeEach(() => {
// Reset fetch mock before each test
global.fetch = vi.fn();
});
it('shows loading state initially', () => {
// Don't resolve the promise yet
global.fetch = vi.fn(() => new Promise(() => {}));
render( );
expect(screen.getByText('Loading products...')).toBeInTheDocument();
});
it('displays products after successful fetch', async () => {
const mockProducts = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 25 },
];
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockProducts),
} as Response)
);
render( );
// Wait for products to appear
expect(await screen.findByText('Laptop - $999')).toBeInTheDocument();
expect(screen.getByText('Mouse - $25')).toBeInTheDocument();
});
it('displays error message on fetch failure', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
status: 500,
} as Response)
);
render( );
expect(await screen.findByText(/Error: Failed to fetch/)).toBeInTheDocument();
});
it('displays error on network failure', async () => {
global.fetch = vi.fn(() =>
Promise.reject(new Error('Network error'))
);
render( );
expect(await screen.findByText(/Error: Network error/)).toBeInTheDocument();
});
});
Key points: We use findBy* for async assertions—it waits for the element to appear. We mock different fetch scenarios: success, HTTP error, and network failure.
Common mistake: Forgetting to await async queries. If you use findBy* without await, your assertion runs before the element appears, and the test fails.
Testing User Interactions
Let's test a more complex component with multiple interactions:
// Counter.tsx
export function Counter() {
const [count, setCount] = useState(0);
return (
Count: {count}
);
}
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';
describe('Counter', () => {
it('starts at zero', () => {
render( );
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments count when increment button is clicked', async () => {
const user = userEvent.setup();
render( );
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('decrements count when decrement button is clicked', async () => {
const user = userEvent.setup();
render( );
await user.click(screen.getByRole('button', { name: 'Decrement' }));
expect(screen.getByText('Count: -1')).toBeInTheDocument();
});
it('resets count to zero', async () => {
const user = userEvent.setup();
render( );
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();
});
});
Testing Conditional Rendering
Components often show different content based on state or props:
// UserGreeting.tsx
interface User {
name: string;
isAdmin: boolean;
}
interface UserGreetingProps {
user: User | null;
}
export function UserGreeting({ user }: UserGreetingProps) {
if (!user) {
return Please log in;
}
return (
Welcome, {user.name}!
{user.isAdmin && You have admin privileges
}
);
}
// UserGreeting.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { UserGreeting } from './UserGreeting';
describe('UserGreeting', () => {
it('shows login prompt when no user', () => {
render( );
expect(screen.getByText('Please log in')).toBeInTheDocument();
});
it('shows welcome message for logged in user', () => {
const user = { name: 'Alice', isAdmin: false };
render( );
expect(screen.getByText('Welcome, Alice!')).toBeInTheDocument();
});
it('shows admin message for admin users', () => {
const user = { name: 'Bob', isAdmin: true };
render( );
expect(screen.getByText('You have admin privileges')).toBeInTheDocument();
});
it('does not show admin message for regular users', () => {
const user = { name: 'Charlie', isAdmin: false };
render( );
expect(screen.queryByText('You have admin privileges')).not.toBeInTheDocument();
});
});
Custom Render Function
If your components need providers (like React Router or Context), create a custom render function:
// test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { ThemeProvider } from './ThemeProvider';
interface CustomRenderOptions extends Omit {
theme?: 'light' | 'dark';
}
export function renderWithProviders(
ui: ReactElement,
{ theme = 'light', ...options }: CustomRenderOptions = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return {children} ;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything
export * from '@testing-library/react';
export { renderWithProviders as render };
Now use your custom render:
import { render, screen } from './test/utils';
it('renders with dark theme', () => {
render( , { theme: 'dark' });
// ...
});
Common Mistakes to Avoid
Testing implementation details: Don't test internal state or private methods. Test what users see and do.
// ❌ Bad - testing implementation
expect(component.state.isOpen).toBe(true);
// ✓ Good - testing behavior
expect(screen.getByText('Modal is open')).toBeInTheDocument();
Not waiting for async updates: Use findBy* or waitFor when testing async behavior.
Using poor selectors: Prefer accessible queries like getByRole and getByLabelText over getByTestId or getByClassName.
// ❌ Bad
screen.getByTestId('submit-button');
screen.getByClassName('error-message');
// ✓ Good
screen.getByRole('button', { name: 'Submit' });
screen.getByText('Invalid email address');
Over-mocking: Only mock what you need. If you mock everything, you're not testing reality.
Not cleaning up: Always clean up after tests. Our setup file handles this with afterEach(cleanup).
Aha moment: I spent an hour debugging why my tests were failing randomly. Turns out, I wasn't cleaning up between tests, so state from one test leaked into another. Always clean up.
Running Your Tests
# Run tests once
npm test
# Run tests in watch mode
npm test -- --watch
# Run with UI (opens browser)
npm run test:ui
# Run with coverage
npm run test:coverage
Final Thoughts
Good tests give you confidence to refactor. They catch bugs before they reach users. They document how your components should behave.
Start small. Test critical user flows first—forms, buttons, data loading. Don't aim for 100% coverage immediately. Build the habit.
Remember: test behavior, not implementation. Use accessible queries. Wait for async updates. Mock only what's necessary.
Your future self will thank you when you can refactor with confidence.