41 Testing – Unit Tests und End-to-End Validierung

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.

41.1 Die Testing-Pyramide: Theorie vs. React-Realität

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.

41.2 Jest und React Testing Library: Das Fundament

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.

41.2.1 Setup in Create React App und Vite

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.

41.2.2 Der erste Component Test

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:

41.2.3 Query-Prioritäten: Was Nutzer sehen

RTL 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.

41.2.4 State und Re-Renders testen

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.

41.2.5 Asynchrone Operationen: API-Calls und Loading States

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 });

41.2.6 Formulare und Validierung

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');
});

41.2.7 Custom Hooks testen

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.

41.3 End-to-End Tests: Komplette User Journeys

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.

41.3.1 Playwright: Der moderne Standard

Playwright hat Cypress als bevorzugtes E2E-Tool überholt. Es ist schneller, stabiler und unterstützt mehr Browser out-of-the-box.

npm init playwright@latest

Das 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,
  },
});

41.3.2 Der erste E2E Test

// 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();
  });
});

41.3.3 API-Mocking in E2E Tests

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();
});

41.3.4 Komplexe User Journeys

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();
});

41.3.5 Page Object Model

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');
});

41.4 Häufige Fehler und Antipatterns

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();
});

41.5 CI/CD Integration

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.

41.6 Test-Driven Development in React

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.