Testing with Vitest and React Testing Library in React
Testing with Vitest and React Testing Library
~17 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
So far we've manually checked every change in the browser – it works, but quickly becomes impractical as the project grows: with EVERY change, you'd have to re-click through ALL previous features by hand. Automated tests take over exactly that job.
Installing Vitest and React Testing Library
Vitest is a test runner built specifically for Vite projects (uses the same config, the same speed) – the direct, modern equivalent of the older Jest. React Testing Library ("RTL") is the library for testing components the way a REAL user would interact with them (clicks, typing, visible text), instead of checking internal implementation details.
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomExtending vite.config.js with test configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // simulates a browser in Node.js, no real browser needed
globals: true, // allows describe/it/expect without importing them in every test file
setupFiles: './src/test/setup.js',
},
});import '@testing-library/jest-dom';
// extends expect() with matchers like .toBeInTheDocument(), .toHaveTextContent(), etc.The first test: a pure function
We'll start with the SIMPLEST case: you already know bmiCategory() from the "React Native Reference" series as this pattern – a pure function (no component, no state) is easiest to test. Our project doesn't have a BMI example, but filterItemsSlowly's filter logic from SearchDemoPage (chapter 33) is structurally similar. Instead, let's test something from our actual project: the total-price calculation from CartPage, extracted as its own, testable function:
export function calculateCartTotal(items) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}import { describe, it, expect } from 'vitest';
import { calculateCartTotal } from './cartTotal';
describe('calculateCartTotal', () => {
it('returns 0 for an empty cart', () => {
expect(calculateCartTotal([])).toBe(0);
});
it('calculates the total for a single item', () => {
const items = [{ price: 10, quantity: 2 }];
expect(calculateCartTotal(items)).toBe(20);
});
it('correctly sums multiple different items', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 },
];
expect(calculateCartTotal(items)).toBe(35);
});
});describe groups related tests, it (alias: test) describes ONE concrete test case in plain language, expect(...).toBe(...) is the assertion itself. Add a script to package.json: "test": "vitest", then run npm test – Vitest automatically watches files and re-runs on every change.
Testing a component: ProductCard
Component tests do NOT check internal state directly, they check VISIBLE behavior – EXACTLY how a user experiences the app. RTL's render() renders the component into a virtual DOM environment, screen finds elements inside it:
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProductCard from './ProductCard';
describe('ProductCard', () => {
const defaultProps = {
name: 'Hiking Boots',
price: 89.99,
imageUrl: 'https://example.com/boots.jpg',
onSelect: vi.fn(),
onAddToCart: vi.fn(),
};
it('displays the name and price', () => {
render(<ProductCard {...defaultProps} />);
expect(screen.getByText('Hiking Boots')).toBeInTheDocument();
expect(screen.getByText('$89.99')).toBeInTheDocument();
});
it('calls onAddToCart when the cart button is clicked', async () => {
const user = userEvent.setup();
render(<ProductCard {...defaultProps} />);
await user.click(screen.getByText('Add to Cart'));
expect(defaultProps.onAddToCart).toHaveBeenCalledOnce();
expect(defaultProps.onSelect).not.toHaveBeenCalled();
});
it('toggles favorite status when the heart is clicked', async () => {
const user = userEvent.setup();
render(<ProductCard {...defaultProps} />);
expect(screen.getByText('♡')).toBeInTheDocument();
await user.click(screen.getByText('♡'));
expect(screen.getByText('♥')).toBeInTheDocument();
});
});The details that make this test good
vi.fn()creates a "mock function" – a test stand-in that gets recorded but does nothing itself.toHaveBeenCalledOnce()checks it was called EXACTLY once.screen.getByText(...)searches for VISIBLE text – exactly what a real user would see, not internal variable names or CSS classes.expect(defaultProps.onSelect).not.toHaveBeenCalled()in the "Add to Cart" test INDIRECTLY verifies thatevent.stopPropagation()from chapter 29 actually works – the EXACT bugstopPropagation()is meant to prevent would make this test fail.userEvent.setup()instead of the olderfireEventsimulates REAL user interaction more realistically (including intermediate steps like focusing before clicking).
Achtung: Don't test isFavorite as internal state directly (e.g. via component instance access) – RTL deliberately doesn't offer that. The test instead checks the VISIBLE outcome (♡ becomes ♥) – the test stays valid even if you later change the internal implementation (e.g. moving isFavorite into a Zustand store), as long as the visible behavior stays the same.
Tipp: React Testing Library's often-quoted guiding principle: "The more your tests resemble the way your software is used, the more confidence they can give you." Tests that check implementation details break on every refactor, even when the VISIBLE behavior stays unchanged – the exact opposite of what good tests are supposed to accomplish.