React-Anwendungen ohne Tests sind wie Seiltänzer ohne Netz. Jede Code-Änderung wird zum Risiko, jede Refactoring zur Mutprobe. Tests schaffen Sicherheit – nicht die absolute Gewissheit, dass alles funktioniert, aber die Zuversicht, dass kritische Workflows auch nach Änderungen noch laufen.
Das React-Ökosystem hat sich auf zwei Test-Ebenen eingespielt: Unit Tests mit Jest und React Testing Library für einzelne Komponenten, und End-to-End Tests mit Tools wie Playwright für komplette User Journeys. Dazwischen existiert eine Grauzone von Integration Tests, die in React oft mit Unit Tests verschmelzen.
Die klassische Testing-Pyramide empfiehlt viele schnelle Unit Tests, weniger Integration Tests und nur wenige langsame E2E Tests. In React verschiebt sich diese Gewichtung. Komponenten sind keine isolierten Funktionen – sie rendern UI, verwalten State, reagieren auf Events. Ein “Unit Test” in React rendert oft mehrere verschachtelte Komponenten und testet deren Interaktion.
| Test-Typ | Scope | Geschwindigkeit | Wann verwenden |
|---|---|---|---|
| Unit (Helpers) | Einzelne Funktion | Sehr schnell | Pure Functions, Business Logic |
| Component Tests | Komponente + Children | Schnell | UI-Komponenten, Hooks |
| Integration Tests | Feature-Bereich | Mittel | Formulare, Listen, State-Management |
| E2E Tests | Kompletter Workflow | Langsam | Login, Checkout, kritische Prozesse |
Die Grenze zwischen Component Tests und Integration Tests ist fließend. Ein Formular-Test, der Input-Validierung, Submit-Logic und API-Mock umfasst, ist technisch ein Integration Test – wird aber mit denselben Tools geschrieben wie Unit Tests.
Jest ist der Test-Runner: Er findet Tests, führt sie aus, reportet Ergebnisse. React Testing Library (RTL) ist die Komponenten-Test-Bibliothek: Sie rendert Komponenten in eine Test-Umgebung und bietet Queries, um Elemente zu finden.
Create React App kommt mit vorkonfiguriertem Jest. Vite-Projekte benötigen Setup:
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});// src/test/setup.ts
import '@testing-library/jest-dom';Vitest ist eine moderne Jest-Alternative, optimiert für Vite. Die API ist nahezu identisch.
Tests folgen dem Arrange-Act-Assert-Pattern: Setup → Aktion → Erwartung prüfen.
// Button.tsx
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}
export function Button({ onClick, children, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
test('renders with text', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = vi.fn(); // Vitest Mock-Funktion
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button onClick={() => {}} disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Wichtige Prinzipien:
getByRole findet Elemente nach ihrer semantischen Rolle
– genau wie ScreenreaderuserEvent simuliert echte User-Interaktionen (besser
als fireEvent)vi.fn()) tracken AufrufeRTL bietet verschiedene Query-Typen, aber nicht alle sind gleich gut. Die Dokumentation empfiehlt eine klare Hierarchie:
1. Accessible Queries (bevorzugt)
// Nach Rolle (beste Option)
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
// Nach Label-Text
screen.getByLabelText('Password');
// Nach Placeholder
screen.getByPlaceholderText('Enter email...');
2. Semantic Queries
// Nach sichtbarem Text
screen.getByText('Welcome back');
// Nach Display Value (für Inputs)
screen.getByDisplayValue('john@example.com');
3. Test IDs (letzter Ausweg)
// Nur wenn nichts anderes funktioniert
screen.getByTestId('submit-button');
Die Query-Wahl beeinflusst Accessibility. Wenn getByRole
nicht funktioniert, hat die Komponente oft Accessibility-Probleme.
React-State-Updates sind asynchron. Tests müssen darauf warten.
// Counter.tsx
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
// Counter.test.tsx
test('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Nach user.click() wartet userEvent automatisch auf
State-Updates und Re-Renders. Kein manuelles waitFor
nötig.
Komponenten, die Daten laden, brauchen asynchrone Tests.
// UserProfile.tsx
interface User {
id: number;
name: string;
email: string;
}
export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// UserProfile.test.tsx
import { waitFor, screen } from '@testing-library/react';
// API mocken
vi.mock('./api', () => ({
fetchUser: vi.fn()
}));
import { fetchUser } from './api';
test('displays user after loading', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
render(<UserProfile userId={1} />);
// Loading State
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Warten bis User geladen
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// User-Daten prüfen
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
test('displays error on failed fetch', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
});
Alternativen zu waitFor:
// findBy-Queries warten automatisch
const heading = await screen.findByText('Alice');
// findByRole mit Timeout
const button = await screen.findByRole('button', { name: 'Submit' }, { timeout: 3000 });
Formular-Tests prüfen Input, Validierung und Submit-Logik.
// LoginForm.tsx
export function LoginForm({ onSubmit }: { onSubmit: (credentials: Credentials) => Promise<void> }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = 'Email required';
if (!password) newErrors.password = 'Password required';
if (email && !email.includes('@')) newErrors.email = 'Invalid email';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setLoading(true);
try {
await onSubmit({ email, password });
} catch (error) {
setErrors({ submit: 'Login failed' });
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <span id="email-error">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!errors.password}
/>
{errors.password && <span>{errors.password}</span>}
</div>
{errors.submit && <div role="alert">{errors.submit}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
// LoginForm.test.tsx
test('shows validation errors on empty submit', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByText('Email required')).toBeInTheDocument();
expect(screen.getByText('Password required')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
test('validates email format', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'invalid-email');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByText('Invalid email')).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
test('submits valid credentials', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'password123'
});
// Button zeigt Loading-State
expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled();
});
test('shows error on failed login', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn().mockRejectedValue(new Error('Invalid credentials'));
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'wrongpassword');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByRole('alert')).toHaveText('Login failed');
});
Custom Hooks können direkt getestet werden – ohne Wrapper-Komponente.
// useCounter.ts
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
act() stellt sicher, dass State-Updates verarbeitet
werden bevor Assertions laufen.
E2E Tests starten die echte Anwendung in einem echten Browser und simulieren reale Nutzer-Interaktionen. Sie testen nicht einzelne Komponenten, sondern komplette Workflows: Login → Dashboard → Create Item → Logout.
Playwright hat Cypress als bevorzugtes E2E-Tool überholt. Es ist schneller, stabiler und unterstützt mehr Browser out-of-the-box.
npm init playwright@latestDas Setup-Script konfiguriert Playwright automatisch und erstellt Beispiel-Tests.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('successful login redirects to dashboard', async ({ page }) => {
await page.goto('/login');
// Formular ausfüllen
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('password123');
// Submit
await page.getByRole('button', { name: 'Login' }).click();
// Warten auf Redirect
await expect(page).toHaveURL('/dashboard');
// Dashboard-Content prüfen
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Welcome, Alice')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Login' }).click();
// Error Message erscheint
await expect(page.getByText('Invalid credentials')).toBeVisible();
// Bleibt auf Login-Page
await expect(page).toHaveURL('/login');
});
test('validates required fields', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByText('Email required')).toBeVisible();
await expect(page.getByText('Password required')).toBeVisible();
});
});E2E Tests sollten gegen Mocks laufen, nicht gegen Production APIs. Playwright bietet Route-Interception:
test('displays user list from API', async ({ page }) => {
// Mock API Response
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
users: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
]
})
});
});
await page.goto('/users');
await expect(page.getByRole('row', { name: /Alice/ })).toBeVisible();
await expect(page.getByRole('row', { name: /Bob/ })).toBeVisible();
});
test('handles API error gracefully', async ({ page }) => {
await page.route('**/api/users', route => route.fulfill({ status: 500 }));
await page.goto('/users');
await expect(page.getByText('Failed to load users')).toBeVisible();
});E2E Tests sollten kritische Workflows abdecken:
// e2e/create-project.spec.ts
test('complete project creation flow', async ({ page }) => {
// Login
await page.goto('/login');
await page.getByLabel('Email').fill('alice@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard');
// Navigate zu Projects
await page.getByRole('link', { name: 'Projects' }).click();
await expect(page).toHaveURL('/projects');
// Neues Projekt erstellen
await page.getByRole('button', { name: 'New Project' }).click();
await page.getByLabel('Project Name').fill('Website Relaunch');
await page.getByLabel('Description').fill('Complete redesign of company website');
await page.getByLabel('Due Date').fill('2025-12-31');
// Mock Create API
await page.route('**/api/projects', async route => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 123,
name: 'Website Relaunch',
description: 'Complete redesign of company website',
dueDate: '2025-12-31'
})
});
}
});
await page.getByRole('button', { name: 'Create' }).click();
// Redirect zu Projekt-Detail
await expect(page).toHaveURL('/projects/123');
await expect(page.getByRole('heading', { name: 'Website Relaunch' })).toBeVisible();
// Aufgabe hinzufügen
await page.getByRole('button', { name: 'Add Task' }).click();
await page.getByLabel('Task Title').fill('Design mockups');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Design mockups')).toBeVisible();
});Für wartbare E2E Tests: Page Objects kapseln Selektoren und Aktionen.
// e2e/pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Login' }).click();
}
async expectErrorMessage(message: string) {
await expect(this.page.getByText(message)).toBeVisible();
}
}
// Verwendung
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('alice@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});Fehler 1: Implementierungsdetails testen
// ❌ Falsch: State direkt testen
test('counter state updates', () => {
const { result } = renderHook(() => useState(0));
// Das testet React selbst, nicht deine Komponente
});
// ✓ Richtig: Sichtbares Verhalten testen
test('counter displays incremented value', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Fehler 2: CSS-Klassen als Selektoren
// ❌ Falsch: Fragile, bricht bei Styling-Änderungen
screen.getByClassName('submit-btn');
// ✓ Richtig: Semantische Queries
screen.getByRole('button', { name: 'Submit' });
Fehler 3: Fehlende Async-Handling
// ❌ Falsch: Wartet nicht auf State-Update
test('loads data', () => {
render(<UserProfile userId={1} />);
expect(screen.getByText('Alice')).toBeInTheDocument(); // Fehler!
});
// ✓ Richtig: Async Query
test('loads data', async () => {
render(<UserProfile userId={1} />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
Fehler 4: Übermäßiges Mocking
// ❌ Falsch: Child-Komponenten mocken
vi.mock('./UserCard', () => ({
UserCard: () => <div>Mocked</div>
}));
// ✓ Richtig: Nur externe Dependencies mocken
vi.mock('./api', () => ({
fetchUsers: vi.fn()
}));
Fehler 5: E2E Tests für alles
// ❌ Falsch: Button-Validierung im E2E Test
test('button is disabled when loading', async ({ page }) => {
await page.goto('/form');
// Zu langsam für simple Validierung
});
// ✓ Richtig: Component Test für UI-Details
test('button is disabled when loading', () => {
render(<SubmitButton loading />);
expect(screen.getByRole('button')).toBeDisabled();
});
Tests gehören in die Pipeline – automatisch bei jedem Commit.
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- run: npm run test:unit
- run: npm run test:coverage
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
- uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/E2E Tests sollten nach Unit Tests laufen – schnelles Feedback zuerst.
TDD mit React: Test schreiben → Komponente implementieren → Refactoren.
// 1. Test schreiben (Red)
test('shows search results', async () => {
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'react');
expect(await screen.findByText('React Documentation')).toBeInTheDocument();
});
// 2. Minimale Implementation (Green)
export function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const handleSearch = async (value: string) => {
setQuery(value);
const data = await searchAPI(value);
setResults(data);
};
return (
<div>
<input
type="search"
role="searchbox"
value={query}
onChange={(e) => handleSearch(e.target.value)}
/>
{results.map(result => <div key={result}>{result}</div>)}
</div>
);
}
// 3. Refactoren (mit Test-Sicherheit)
// Debouncing hinzufügen, Loading State, Error Handling
// Tests bleiben grün
Tests definieren die API aus Nutzersicht. Die Implementation folgt den Tests – nicht umgekehrt.