React Testing
Umfassende React-Testing-Muster für verhaltensorientierte Komponententests, Tests benutzerdefinierter Hooks, Barrierefreiheits-Assertions und Netzwerk-Mocking.
Wann aktivieren
- Schreiben von Tests für React-Komponenten, benutzerdefinierte Hooks oder Seiten
- Hinzufügen von Testabdeckung zu ungetesteten Legacy-Komponenten
- Migration von Enzyme oder Klassenkomponenten-Mustern zu React Testing Library
- Einrichten von Vitest oder Jest für ein neues React-Projekt
- Mocken von HTTP-Anfragen in Tests
- Assertions zu Barrierefreiheitsverstößen
- Entscheiden, welche Tests zu RTL vs. Playwright Component Testing vs. vollständigem E2E gehören
Kernprinzip
Teste, was der Benutzer sieht und tut, nicht Implementierungsdetails.
Ein Test sollte:
- Die Komponente mit denselben Providern rendern, die sie in Produktion hat
- Mit ihr über zugängliche Queries (Rolle, Label) und
userEventinteragieren - Sichtbare Ausgabe und beobachtbare Seiteneffekte (Callback ausgelöst, Anfrage gesendet) assertieren
Ein Test sollte NICHT:
- Komponentenstatus, an Kinder übergebene Props oder aufgerufene Hooks inspizieren
- React selbst oder Framework-Hooks mocken
- Auf die Anzahl der Renderings oder die DOM-Struktur assertieren, die keinen Einfluss auf Benutzer haben
Bibliothekswahl
| Runner | Wann | Hinweis |
|---|---|---|
| Vitest | Vite, Remix, moderne Setups | Schneller, nativer ESM, Jest-kompatible API |
| Jest | Next.js, CRA, etablierte Repos | Standard für viele React-Projekte |
| Playwright Component Testing | Echter Browser-Engine erforderlich | Verwenden, wenn JSDOM die benötigte Funktion nicht bietet |
| Cypress Component Testing | Echter Browser, Cypress bereits im Einsatz | Alternative zu Playwright CT |
Wähle einen aus. Verwende nicht RTL + Vitest UND Playwright CT im selben Repo, es sei denn, du hast eine klare Aufgabentrennung.
Query-Priorität
React Testing Library stellt Queries in drei Stufen bereit – verwende sie von oben nach unten:
- Für alle zugänglich:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - Semantisch:
getByAltText,getByTitle - Test-IDs (Notausgang):
getByTestId
// Am besten
screen.getByRole("button", { name: /save/i });
// OK für Eingaben
screen.getByLabelText("Email");
// Letzter Ausweg
screen.getByTestId("save-btn");
Varianten:
getBy*— wirft Fehler, wenn kein TrefferqueryBy*— gibtnullzurück (für "Abwesenheit assertieren")findBy*— asynchron, gibt ein Promise zurück (für Elemente, die nach asynchroner Arbeit erscheinen)
Benutzerinteraktion mit userEvent
import userEvent from "@testing-library/user-event";
test("submits the form", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
awaitbei userEvent-Aufrufen immer verwendenuserEvent.setup()einmal pro Test aufrufen, das zurückgegebeneuserwiederverwendenuserEventsimuliert eine echte Browser-Sequenz;fireEventsendet ein einzelnes synthetisches Ereignis aus —userEventbevorzugen
Asynchrone Muster
// Element, das nach asynchroner Arbeit erscheint
expect(await screen.findByText("Loaded")).toBeInTheDocument();
// Seiteneffekt-Assertion
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// Element, das verschwinden sollte
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
Niemals setTimeout + Assertion — anfällig. Verwende die obigen Matcher.
Netzwerk-Mocking mit MSW
Mock Service Worker mockt auf Netzwerkebene. Die Komponente, Hooks und die Fetch-Bibliothek verhalten sich exakt wie in Produktion.
Einrichtung
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Konfiguriere onUnhandledRequest: "error", damit jede nicht gemockte Anfrage den Test lautstark fehlschlagen lässt — stille Erfolge sind schlimmer als rote Tests.
Pro-Test-Überschreibung
test("renders error on 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
Provider-Wrapping
Provider einmal in einer test-utils.tsx wrappen:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
Dann in jeder Testdatei import { renderWithProviders, screen } from "test-utils" verwenden.
Testen benutzerdefinierter Hooks
import { renderHook, act } from "@testing-library/react";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter accepts initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser fetches user data", async () => {
// QueryClient ONCE pro Test außerhalb des Wrappers instanziieren, damit er Neu-Renderings überlebt.
// Wird er innerhalb des Wrapper-Closures erstellt, wird der Cache bei jedem Rendern zurückgesetzt, was zu flaky Tests führt.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});
- Statusändernde Aufrufe in
actwrappen - Nur über die öffentliche API des Hooks testen
- Für Hooks, die Kontext verwenden, einen
wrapperübergeben
Barrierefreiheits-Assertions
import { axe, toHaveNoViolations } from "jest-axe"; // oder vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard has no a11y violations", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
Führe axe in Komponententests für jede interaktive Komponente aus. Erkennt:
- Fehlende Labels auf Formulareingaben
- Ungültige ARIA-Nutzung
- Schlechten Farbkontrast (eingeschränkt – JSDOM hat keine echte CSS-Engine, daher funktioniert dies nur für Inline-Styles; visueller Kontrast gehört in Playwright)
- Fehlende Alt-Texte auf Bildern
- Verstöße gegen die Überschriftenreihenfolge
Querverweis: skills/accessibility/SKILL.md für das umfassendere A11y-Testspielbuch.
Wann KEINE Snapshot-Tests verwendet werden sollten
Snapshots von gerendertem Output:
- Brechen bei jeder Stiländerung
- Werden während der Code-Review durchgewunken
- Testen Implementierungsdetails (DOM-Struktur), nicht das Verhalten
Akzeptable Snapshot-Verwendungen:
- Reine Datenserialisierungsfunktionen (
formatInvoice(invoice)-> stabiler String) - Generierte Konfigurationsdateien (z.B. Webpack-Konfigurations-Output)
Für visuelle Regressionen von Komponenten Playwright/Cyprex-Screenshots oder Percy/Chromatic verwenden – echte visuelle Diffs, keine DOM-Strings.
Wann zu Playwright / Cypress greifen
JSDOM (von Vitest/Jest verwendet) kann nicht:
- Echtes Layout rendern (Flexbox, Grid, Viewport-Abfragen)
- Native Browser-Animationen, CSS-Übergänge ausführen
- Scroll-Verhalten, Drag-and-Drop, Einfügen aus der Zwischenablage testen
- Iframes, Popups, Downloads, Cross-Origin-Flows handhaben
- Echtes Netzwerk in einer kontrollierten Umgebung mit vollständiger DevTools-Unterstützung ausführen
Für all dies Playwright Component Testing (Komponententest im echten Browser) oder vollständiges E2E verwenden. Siehe e2e-testing skill.
Entscheidungsgrenze:
- Ein Hook, eine Präsentationskomponente, ein Formular mit Logik -> RTL
- Eine Komponente, deren Layout wichtig ist oder die Browser-APIs nutzt, die nicht in JSDOM enthalten sind -> Playwright CT
- Ein vollständiger Benutzerflow über mehrere Seiten -> Playwright/Cypress E2E
Coverage-Ziele
| Schicht | Ziel |
|---|---|
| Reine Hilfsfunktionen | >=90% |
| Benutzerdefinierte Hooks | >=85% |
| Präsentationskomponenten | >=80% — Verhalten, nicht Zeilen |
| Container-Komponenten | >=70% — goldene Pfade + Fehlerzustände |
| Seiten | E2E separat abgedeckt; Minimum Smoke-Test |
Konfiguriere über vitest.config.ts / jest.config.js:
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
Anti-Patterns
container.querySelector("...")— umgeht Barrierefreiheits-Queries, lässt Tests bestehen, wenn echte Benutzer scheitern würden- Assertieren der Anzahl von Renderings — Implementierungsdetail
jest.mock("react", ...)— niemals React mocken. Die Komponente umgestalten- Mocken von Kind-Komponenten standardmäßig — testet die Integration, nicht die Isolation. Nur mocken, wenn das Kind starke Seiteneffekte hat
- Ignorieren von
act()-Warnungen — sie signalisieren echte Fehler (Statusaktualisierung nach Unmount, fehlende asynchrone Wrappung) - Gemeinsame Nutzung von mutablem State über Tests hinweg — Flakes, wenn sich die Testreihenfolge ändert
- Tests, die mit entferntem
it.skip()bestehen — dein Test assertiert nicht das, was du denkst
TDD-Workflow
ROT -> Schreiben eines fehlschlagenden Tests für die nächste Anforderung
GRÜN -> Schreiben von minimalem Komponenten-Code, der den Test besteht
REFACTOR -> Verbessern der Komponente, Tests bleiben grün
WIEDERHOLEN -> Nächste Anforderung
Für neue Komponenten:
- Definiere den Prop-Typ und die Signatur der Komponente
- Schreibe den ersten Test für den einfachsten Fall
- Stelle sicher, dass er aus dem richtigen Grund fehlschlägt
- Implementiere gerade genug, um zu bestehen
- Füge den nächsten Testfall hinzu
- Refaktoriere, wenn der dritte ähnliche Test ein Muster offenbart
Test-Befehle
# Vitest
vitest # watch
vitest run # einmalig
vitest run --coverage # mit Coverage
vitest run path/to/file.test.tsx # einzelne Datei
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# CI-Modus
CI=true vitest run --coverage
Verwandt
- Regeln: rules/react/testing.md
- Skills: react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents:
react-reviewer(überprüft Testqualität während der Code-Review),tdd-guide(erzwingt TDD-Prozess) - Befehle:
/react-test,/react-review
Beispiele
Formularabsendung mit MSW und userEvent
test("submits user form and shows success", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});
Testen einer Error Boundary
function Broken() {
throw new Error("boom");
}
test("error boundary renders fallback", () => {
// Reagiert auf console.error-Rauschen für den erwarteten Fehler, dann wiederherstellen, damit
// der Spy nicht zwischen Tests leckt und echte Fehler anderswo verbirgt.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Testen einer Suspense Boundary
test("shows loading then content", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});