Was macht React-Tests?
Umfassende Patterns für verhaltensorientierte Komponententests, Custom-Hook-Tests, Barrierefreiheitsprüfungen und netzwerkseitiges Mocking in React.
Wann aktivieren
- Schreiben von Tests für React-Komponenten, Custom Hooks oder Seiten
- Hinzufügen von Testabdeckung für bisher ungetestete Komponenten
- Migration von Enzyme oder Patterns aus der Klassenkomponenten-Ära zu React Testing Library
- Einrichten von Vitest oder Jest für ein neues React-Projekt
- Mocken von HTTP-Requests in Tests
- Prüfen auf Barrierefreiheitsverstöße
- Entscheidung, welche Tests zu RTL, Playwright Component Testing oder vollständigen E2E-Tests gehören
Kernprinzip
Testen Sie, was der Benutzer sieht und tut, nicht Implementierungsdetails.
Ein Test sollte:
- Die Komponente mit denselben Providern rendern wie in der Produktion
- Mit ihr über accessible Queries (role, label) und
userEventinteragieren - Sichtbare Ausgaben und beobachtbare Seiteneffekte (Callback aufgerufen, Request gesendet) überprüfen
Ein Test sollte NICHT:
- Den Komponentenstatus, an Kinder übergebene Props oder aufgerufene Hooks inspizieren
- React selbst oder Framework-Hooks mocken
- Die Anzahl der Renderings oder die DOM-Struktur über das hinaus prüfen, was den Benutzer betrifft
Bibliotheksauswahl
| Runner | Wann | Hinweis |
|---|---|---|
| Vitest | Vite, Remix, moderne Setups | Schneller, natives ESM, Jest-kompatible API |
| Jest | Next.js, CRA, etablierte Repos | Standard für viele React-Projekte |
| Playwright Component Testing | Echter Browser-Engine nötig | Verwenden, wenn JSDOM die erforderliche Funktion nicht bietet |
| Cypress Component Testing | Echter Browser, Cypress bereits im Einsatz | Alternative zu Playwright CT |
Wählen Sie eine aus. Betreiben Sie nicht RTL + Vitest UND Playwright CT im selben Repository, es sei denn, Sie haben eine klare Trennung der Aufgabenbereiche.
Abfragepriorität
React Testing Library bietet Abfragen in drei Ebenen – verwenden Sie diese von oben nach unten:
- Für alle zugänglich:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - Semantisch:
getByAltText,getByTitle - Test-IDs (Notausstieg):
getByTestId
// Am besten
screen.getByRole("button", { name: /save/i });
// OK für Eingabefelder
screen.getByLabelText("Email");
// Letzter Ausweg
screen.getByTestId("save-btn");
Varianten:
getBy*— wirft einen Fehler, wenn keine ÜbereinstimmungqueryBy*— gibtnullzurück (für "Abwesenheit bestätigen")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("sendet das Formular ab", 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]" });
});
userEvent-Aufrufe immer mitawaitversehenuserEvent.setup()einmal pro Test aufrufen, das zurückgegebeneuserwiederverwendenuserEventsimuliert eine echte Browser-Sequenz;fireEventlöst ein einzelnes synthetisches Ereignis aus – bevorzugen SieuserEvent
Asynchrone Patterns
// Element, das nach asynchroner Arbeit erscheint
expect(await screen.findByText("Geladen")).toBeInTheDocument();
// Seiteneffekt-Behauptung
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// Element, das verschwinden soll
await waitForElementToBeRemoved(() => screen.queryByText("Laden..."));
Verwenden Sie niemals setTimeout + Behauptung — das ist flaky. Nutzen Sie die obigen Matcher.
Netzwerk-Mocking mit MSW
Mock Service Worker mockt auf Netzwerkebene. Die Komponente, Hooks und die Fetch-Bibliothek verhalten sich genau wie in der 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());
Konfigurieren Sie onUnhandledRequest: "error", damit jeder nicht gemockte Request den Test lautstark zum Scheitern bringt – stille Erfolge sind schlimmer als rote Tests.
Überschreibung pro Test
test("zeigt Fehler bei 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/etwas ist schief gelaufen/i)).toBeInTheDocument();
});
Provider kapseln
Kapseln Sie Provider einmal in einer test-utils.tsx:
// 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.
Custom Hooks testen
import { renderHook, act } from "@testing-library/react";
test("useCounter erhöht und verringert", () => {
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 akzeptiert Startwert", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser ruft Benutzerdaten ab", async () => {
// QueryClient EINMAL pro Test außerhalb des Wrappers instanziieren, damit er Neu-Renderings überlebt.
// Wird er innerhalb der Wrapper-Closure erstellt, setzt er den Cache bei jedem Rendering zurück, 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" });
});
- Zustandsändernde Aufrufe in
actkapseln - Nur über die öffentliche API des Hooks testen
- Für Hooks, die Kontext verwenden, einen
wrapperübergeben
Barrierefreiheitsprüfungen
import { axe, toHaveNoViolations } from "jest-axe"; // oder vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard hat keine a11y-Verstöße", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
Führen Sie axe in Komponententests für jede interaktive Komponente aus. Erfasst:
- Fehlende Labels für Formulareingaben
- Ungültige ARIA-Nutzung
- Schlechten Farbkontrast (eingeschränkt – JSDOM hat keine echte CSS-Engine, daher funktioniert dies nur für Inline-Stile; visueller Kontrast gehört zu Playwright)
- Fehlenden Alternativtext bei Bildern
- Verstöße gegen die Überschriftenreihenfolge
Querverweis: skills/accessibility/SKILL.md für das erweiterte a11y-Test-Handbuch.
Wann KEINE Snapshot-Tests verwenden
Snapshots von gerenderten Ausgaben:
- Brechen bei jeder Style-Änderung
- Werden während des Reviews einfach durchgewunken
- Testen Implementierungsdetails (DOM-Struktur), nicht das Verhalten
Akzeptable Snapshot-Verwendungen:
- Reine Daten-Serialisierungsfunktionen (
formatInvoice(invoice)-> stabiler String) - Generierte Konfigurationsdateien (z.B. Ausgabe der Webpack-Konfiguration)
Für visuelle Regressionen bei Komponenten verwenden Sie Playwright/Cypress-Screenshots oder Percy/Chromatic – echte visuelle Unterschiede, keine DOM-Strings.
Wann zu Playwright / Cypress wechseln
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-Abläufe handhaben
- Echtes Netzwerk in einer kontrollierten Umgebung mit vollständiger DevTools-Unterstützung ausführen
Für all das verwenden Sie Playwright Component Testing (Komponententest im echten Browser) oder vollständige E2E-Tests. Siehe e2e-testing skill.
Entscheidungsgrenze:
- Ein Hook, eine präsentative Komponente, ein Formular mit Logik -> RTL
- Eine Komponente, deren Layout wichtig ist oder die Browser-APIs verwendet, die nicht in JSDOM vorhanden sind -> Playwright CT
- Ein vollständiger Benutzerfluss über mehrere Seiten -> Playwright/Cypress E2E
Coverage-Ziele
| Schicht | Ziel |
|---|---|
| Reine Hilfsfunktionen | >=90% |
| Custom Hooks | >=85% |
| Präsentative Komponenten | >=80% — Verhalten, nicht Zeilen |
| Container-Komponenten | >=70% — Happy Paths + Fehlerzustände |
| Seiten | E2E separat abgedeckt; Mindestmaß an Smoke-Tests |
Konfigurieren ü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 accessible Queries, lässt Tests bestehen, wenn echte Benutzer scheitern würden- Auf die Anzahl der Renderings beharren — Implementierungsdetail
jest.mock("react", ...)— React niemals mocken. Stattdessen die Komponente umgestalten- Kind-Komponenten standardmäßig mocken — testet die Integration, nicht die Isolation. Mocken Sie nur, wenn das Kind starke Seiteneffekte hat
act()-Warnungen ignorieren — sie weisen auf echte Fehler hin (Statusaktualisierung nach Unmount, fehlende asynchrone Kapselung)- Veränderlichen Status zwischen Tests teilen — flaky, wenn sich die Testreihenfolge ändert
- Tests, die mit entferntem
it.skip()bestehen — Ihr Test behauptet nicht das, was Sie denken
TDD-Workflow
ROT -> Schreiben Sie einen fehlschlagenden Test für die nächste Anforderung
GRÜN -> Schreiben Sie minimalen Komponentencode, um den Test zu bestehen
UMBAU -> Verbessern Sie die Komponente, die Tests bleiben grün
WIEDERHOLEN -> Nächste Anforderung
Für neue Komponenten:
- Definieren Sie den Prop-Typ und die Signatur der Komponente
- Schreiben Sie den ersten Test für den einfachsten Fall
- Stellen Sie sicher, dass er aus dem richtigen Grund fehlschlägt
- Implementieren Sie gerade genug, um zu bestehen
- Fügen Sie den nächsten Testfall hinzu
- Bauen Sie um, wenn der dritte ähnliche Test ein Pattern offenbart
Testbefehle
# Vitest
vitest # Watch-Modus
vitest run # Einmalige Ausführung
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
Verwandtes
- Regeln: rules/react/testing.md
- Skills: react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents:
react-reviewer(überprüft Testqualität während des Code-Reviews),tdd-guide(erzwingt TDD-Prozess) - Befehle:
/react-test,/react-review
Beispiele
Formularübermittlung mit MSW und userEvent
test("sendet Benutzerformular ab und zeigt Erfolg", 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(/erfolgreich gespeichert/i)).toBeInTheDocument();
});
Testen einer Error Boundary
function Broken() {
throw new Error("knall");
}
test("Error Boundary rendert Fallback", () => {
// console.error von React für den erwarteten Fehler unterdrücken, dann wiederherstellen,
// damit der Spy nicht zwischen Tests durchsickert und echte Fehler anderswo versteckt.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Etwas ist schief gelaufen</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Etwas ist schief gelaufen")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Testen einer Suspense Boundary
test("zeigt Laden dann Inhalt", async () => {
renderWithProviders(
<Suspense fallback={<div>Laden...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Laden...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});