Die RTL-Philosophie: Test wie der Benutzer

React Testing Library (RTL) folgt einem einzigen Kernprinzip: "The more your tests resemble the way your software is used, the more confidence they can give you." Das bedeutet: Kein Zugriff auf interne State-Variablen, keine Enzyme-Shallow-Renders, keine Implementierungsdetails — nur das, was ein echter Nutzer sieht und tut.

RTL Philosophie

Falsch: wrapper.state('isLoading') — testet Implementierung
Richtig: screen.getByRole('progressbar') — testet, was der Nutzer sieht

Claude Code versteht diese Philosophie und generiert Tests, die gegen die öffentliche Schnittstelle testen: ARIA-Rollen, sichtbarer Text, zugängliche Labels — genau das, worauf Screenreader und echte Nutzer angewiesen sind.

Setup: RTL mit Claude Code in 60 Sekunden

Starte ein neues Projekt und lass Claude Code das Testing-Setup übernehmen:

# Claude Code Prompt:
"Richte React Testing Library mit Vitest und @testing-library/user-event@14 ein.
Erstelle eine test-utils.tsx mit custom render-Wrapper für Provider."

# Installierte Pakete (Claude Code führt das aus):
npm install -D @testing-library/react
         @testing-library/user-event
         @testing-library/jest-dom
         vitest
         jsdom
// test-utils.tsx — von Claude Code generiert
import { render, RenderOptions } from '@testing-library/react'
import { ReactElement } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const AllProviders = ({ children }: { children: React.ReactNode }) => {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}

const customRender = (ui: ReactElement, options?: RenderOptions) =>
  render(ui, { wrapper: AllProviders, ...options })

export * from '@testing-library/react'
export { customRender as render }

render, screen, userEvent: Die wichtigsten APIs

render — Komponente ins DOM bringen

render() mountet die Komponente in ein echtes DOM-Environment (jsdom). Claude Code bevorzugt den custom render aus test-utils, damit alle Provider vorhanden sind.

import { render, screen } from '../test-utils'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('zeigt Fehler bei leerem Submit', async () => {
    render(<LoginForm />)

    // screen = globales Abfrage-Objekt — kein wrapper.find() mehr!
    await userEvent.click(screen.getByRole('button', { name: /einloggen/i }))

    expect(screen.getByText(/E-Mail ist erforderlich/i)).toBeInTheDocument()
  })
})

screen — Queries wie ein Benutzer

screen bietet alle Query-Methoden. Die Priorität laut RTL-Docs:

PrioritätQueryWann nutzen
1 (höchste)getByRoleButtons, Links, Headings, Inputs
2getByLabelTextFormularfelder mit Label
3getByPlaceholderTextInputs ohne Label (notfalls)
4getByTextSichtbarer Text im DOM
5getByTestIdLetzter Ausweg — data-testid
Claude Code Tipp: Wenn du Claude Code einen Test schreiben lässt, wird es automatisch getByRole gegenüber getByTestId bevorzugen. Falls keine ARIA-Rolle passt, schlägt Claude Code vor, die Komponente zugänglicher zu machen.

userEvent — Echte Browser-Interaktionen

userEvent aus @testing-library/user-event@14 simuliert echte Browser-Events — inklusive keydown, keyup, Focus-Management und Pointer-Events. Immer await verwenden:

import userEvent from '@testing-library/user-event'

it('Nutzer tippt E-Mail und sendet Formular', async () => {
  const user = userEvent.setup() // setup() = beste Praxis in v14
  render(<LoginForm onSubmit={mockSubmit} />)

  await user.type(
    screen.getByLabelText(/e-mail/i),
    'test@example.com'
  )
  await user.type(
    screen.getByLabelText(/passwort/i),
    'geheim123'
  )
  await user.click(screen.getByRole('button', { name: /einloggen/i }))

  expect(mockSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'geheim123'
  })
})

Async Testing: waitFor und findBy

Moderne React-Apps sind async — Datenabruf, Animationen, Debouncing. RTL bietet zwei Hauptwerkzeuge für asynchrone Assertions:

Async APIs im Überblick

findBy* = waitFor + getBy kombiniert — für einzelne Elemente
waitFor = beliebige Assertion wiederholen bis sie passt oder Timeout
waitForElementToBeRemoved = warten bis Loading-Spinner weg ist

// findBy* — Element taucht asynchron auf
it('lädt Nutzerliste nach API-Call', async () => {
  render(<UserList />)

  // findByRole wartet automatisch (Standard: 1000ms)
  const listItems = await screen.findAllByRole('listitem')
  expect(listItems).toHaveLength(3)
})

// waitFor — komplexere Assertions
it('zeigt Erfolgsmeldung nach Submit', async () => {
  const user = userEvent.setup()
  render(<ContactForm />)

  await user.click(screen.getByRole('button', { name: /senden/i }))

  await waitFor(() => {
    expect(screen.getByText(/danke für deine nachricht/i))
      .toBeInTheDocument()
  }, { timeout: 2000 })
})

// waitForElementToBeRemoved — Loading-State testen
it('versteckt Spinner nach Laden', async () => {
  render(<Dashboard />)

  const spinner = screen.getByRole('progressbar')
  await waitForElementToBeRemoved(spinner)

  expect(screen.getByText(/willkommen zurück/i)).toBeVisible()
})
Achtung: act() manuell aufrufen ist in RTL fast nie nötig. render, userEvent und die find*-Queries wrappen alles automatisch. Claude Code gibt eine Warnung aus, wenn es in deinem Code unnötige act()-Wrapper entdeckt.

Custom Hooks testen mit renderHook

renderHook aus RTL ermöglicht das isolierte Testen von Custom Hooks — ohne eine Wrapper-Komponente bauen zu müssen. Claude Code nutzt es für alle Hook-spezifischen Tests:

renderHook Workflow
  1. Hook mit renderHook() rendern
  2. result.current = aktueller Rückgabewert
  3. State-Änderungen via act() triggern
  4. Re-Renders: rerender() mit neuen Props aufrufen
// useCounter.ts — der Hook
export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue)
  const increment = () => setCount(c => c + 1)
  const reset = () => setCount(initialValue)
  return { count, increment, reset }
}

// useCounter.test.ts — renderHook Test
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('startet mit initialValue', () => {
    const { result } = renderHook(() => useCounter(5))
    expect(result.current.count).toBe(5)
  })

  it('increment erhöht den Zähler', () => {
    const { result } = renderHook(() => useCounter())
    act(() => { result.current.increment() })
    expect(result.current.count).toBe(1)
  })

  it('reset kehrt zu initialValue zurück', () => {
    const { result } = renderHook(() => useCounter(10))
    act(() => {
      result.current.increment()
      result.current.increment()
      result.current.reset()
    })
    expect(result.current.count).toBe(10)
  })

  it('reagiert auf Prop-Änderungen via rerender', () => {
    const { result, rerender } = renderHook(
      ({ init }) => useCounter(init),
      { initialProps: { init: 0 } }
    )
    rerender({ init: 99 })
    act(() => result.current.reset())
    expect(result.current.count).toBe(99)
  })
})

Mock Service Worker (MSW): API-Mocks ohne Mocking-Framework

MSW (Mock Service Worker) ist der moderne Standard für API-Mocks in React-Tests. Kein jest.mock('axios'), kein Monkey-Patching — MSW interceptiert echte Fetch/XHR-Requests auf Netzwerkebene. Claude Code integriert MSW Handler direkt in Testdateien:

Warum MSW statt jest.mock?
// msw/handlers.ts — geteilte API-Definitionen
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Anna Schmidt', role: 'admin' },
      { id: 2, name: 'Max Müller',  role: 'user'  },
    ])
  }),
  http.post('/api/users', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({ id: 3, ...body }, { status: 201 })
  }),
]

// vitest.setup.ts — Server einmal starten
import { setupServer } from 'msw/node'
import { handlers } from './msw/handlers'

const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // Wichtig: Test-Isolation!
afterAll(() => server.close())

// UserList.test.tsx — Error-State testen mit Override
it('zeigt Fehlermeldung bei API-Fehler', async () => {
  server.use(
    http.get('/api/users', () =>
      HttpResponse.json({ error: 'Verbindung unterbrochen' }, { status: 503 })
    )
  )
  render(<UserList />)
  expect(await screen.findByText(/fehler beim laden/i)).toBeInTheDocument()
})

Testing Best Practices: Was man NICHT testen sollte

Claude Code kennt die häufigsten Testing-Fehler und vermeidet sie aktiv. Diese Dinge solltest du nicht testen:

❌ Implementierungsdetails — NICHT testen
// FALSCH — testet interne State-Variable
expect(component.state('isOpen')).toBe(true)

// FALSCH — testet CSS-Klassenname (bricht bei Refactoring)
expect(element).toHaveClass('dropdown--active')

// RICHTIG — testet sichtbares Verhalten
expect(screen.getByRole('listbox')).toBeVisible()

Die 5 Test-Verbote (Claude Code Checkliste)

  1. PropTypes/TypeScript-Typen — das ist der Compiler-Job
  2. Styling und CSS-Klassen — Screenshot-Tests oder Storybook dafür nutzen
  3. Third-Party-Libraries — React Query, Axios etc. sind bereits getestet
  4. Snapshot-Tests ohne Bedeutung — Snapshots bei jeder Änderung aktualisieren = Null-Aussage
  5. Private Methoden / interne Helpers — nur über Public API testen
Claude Code Workflow: Prompt "Schreibe RTL-Tests für diese Komponente, vermeide alle Implementierungsdetails und nutze getByRole als erste Query-Wahl" liefert sofort vollständige Testsuites — inklusive Happy Path, Error State und Edge Cases. Claude Code generiert auch automatisch MSW-Handler aus deinen Fetch-Calls.

Claude Code + RTL: Der komplette Workflow

So setzt Claude Code RTL in der Praxis ein — vom Prompt zum grünen Test:

# 1. Komponente beschreiben
"Schreibe Tests für eine SearchBar-Komponente:
- Debounced Input (300ms)
- API-Call mit MSW mocken
- Loading-State während Suche
- Ergebnisliste mit getByRole('listitem')
- Leerer Zustand wenn keine Treffer"

# 2. Claude Code generiert automatisch:
#    - test-utils.tsx mit Providern
#    - msw/handlers.ts für /api/search
#    - SearchBar.test.tsx mit 8 Tests
#    - vitest.config.ts mit jsdom

# 3. Ausführen
npx vitest run → ✓ 8 Tests grün

Zusammenfassung: RTL mit Claude Code 2026

FeatureToolClaude Code Nutzen
Komponenten rendernrender + custom wrapperGeneriert Provider-Setup automatisch
DOM abfragenscreen.getByRoleBevorzugt ARIA-Queries, schlägt Zugänglichkeit vor
Nutzer-InteraktionuserEvent.setup()Echte Events mit Keyboard + Focus
Async ElementefindBy*, waitForErkennt async Patterns im Komponentencode
Custom HooksrenderHookIsolierte Hook-Tests ohne Wrapper
API-MocksMSW http.get/postGeneriert Handler aus OpenAPI / Fetch-Calls

React Testing Library zwingt dich, besser zugängliche Komponenten zu schreiben. Claude Code kennt diese Philosophie und generiert Tests, die auf Anhieb grün sind — und beim Refactoring stabil bleiben, weil sie Verhalten statt Implementierung testen.

Claude Code für dein React-Projekt nutzen

Tests schreiben, Bugs finden, Komponenten refactoren — Claude Code ist dein AI-Entwicklungspartner für moderne React-Stacks. Jetzt kostenlos testen.

Kostenlos starten →