Vitest Testing Guide: Von null auf Produktionsvertrauen
Unit-Tests, Integrationstests, Komponententests mit Testing Library, Mocking-Strategien, Coverage-Konfiguration und die Testphilosophie, die tatsächlich zu besserer Software führt.
Die meisten Teams schreiben Tests so, wie die meisten Menschen Sport treiben: Sie wissen, dass sie es sollten, fühlen sich schuldig, wenn sie es nicht tun, und wenn sie es endlich angehen, übertreiben sie am ersten Tag und hören bis Freitag auf. Die Codebasis endet mit einer Handvoll oberflächlicher Snapshot-Tests, die bei jeder CSS-Klassen-Änderung brechen, ein paar Integrationstests, denen niemand traut, und einem Coverage-Badge in der README, der lügt.
Ich war auf beiden Seiten. Ich habe Projekte ohne einen einzigen Test ausgeliefert und bei jedem Deploy geschwitzt. Ich war auch in Teams, die 100 % Coverage jagten und mehr Zeit mit Testpflege als mit Feature-Entwicklung verbrachten. Beides funktioniert nicht. Was funktioniert, ist die richtigen Tests an den richtigen Stellen mit den richtigen Werkzeugen zu schreiben.
Vitest hat verändert, wie ich über Testing in JavaScript denke. Nicht weil es neue Konzepte erfunden hätte — die Grundlagen haben sich nicht geändert, seit Kent Beck vor Jahrzehnten darüber schrieb. Sondern weil es genug Reibung beseitigt hat, dass sich Testschreiben nicht mehr wie eine lästige Pflicht anfühlt, sondern wie ein Teil des Entwicklungszyklus. Wenn dein Test-Runner so schnell ist wie dein Dev-Server und dieselbe Config verwendet, lösen sich die Ausreden in Luft auf.
Dieser Beitrag ist alles, was ich über Testing mit Vitest weiß, vom initialen Setup bis zur Philosophie, die das Ganze lohnenswert macht.
Warum Vitest statt Jest#
Ich möchte fair sein: Jest funktioniert. Es ist bewährt, hat ein riesiges Ökosystem und wahrscheinlich betreiben Millionen von Projekten es in der Produktion. Wenn du Jest verwendest und es dich nicht bremst, gibt es keinen Grund zum Wechseln.
Aber wenn du Vite für deine Entwicklung nutzt (oder Vitest frisch anfängst), ist der Vorteil beträchtlich:
Geschwindigkeit. Vitest nutzt Vites Transformations-Pipeline. Es kompiliert TypeScript und JSX ohne einen separaten Build-Schritt. Es gibt kein ts-jest, das konfiguriert werden muss, kein babel-jest, das gewartet werden muss. Die gleiche Pipeline, die deinen Dev-Server antreibt, führt auch deine Tests aus.
Konfiguration. Vitest teilt sich vite.config.ts. Pfad-Aliase, Plugins, Resolve-Optionen — alles funktioniert einfach in Tests, weil es dieselbe Konfiguration ist. Mit Jest verbringst du am Anfang eine Stunde damit, moduleNameMapper für @/ Pfade zum Laufen zu bringen. Mit Vitest: null Konfiguration, da es die Aliase von Vite erbt.
API-Kompatibilität. Vitest implementiert die Jest-API nahezu vollständig. describe, it, expect, vi.fn(), vi.mock() — alles da. Die Migration von Jest ist ein Import-Swap: jest.fn() wird zu vi.fn(), jest.mock() wird zu vi.mock(). Die meisten Tests funktionieren mit nur einem Suchen-und-Ersetzen.
Hot Module Replacement für Tests. Du speicherst eine Datei, nur die betroffenen Tests laufen erneut. Keine vollständige Test-Suite-Ausführung bei jedem Speichern. Das fühlt sich im Entwicklungsmodus magisch an.
# Installation
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomGrundlegende Konfiguration#
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
css: false,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});Die Setup-Datei wird vor jeder Testdatei ausgeführt:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";Der @testing-library/jest-dom/vitest-Import gibt dir Matcher wie toBeInTheDocument(), toHaveClass(), toBeVisible() und viele andere. Diese machen Assertions auf DOM-Elemente lesbar und ausdrucksstark.
Gute Tests schreiben#
Das AAA-Muster#
Jeder Test folgt derselben Struktur: Arrange, Act, Assert. Auch wenn du keine expliziten Kommentare schreibst, sollte die Struktur erkennbar sein:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Wenn ich einen Test sehe, der Arrangement, Aktion und Assertion in eine einzige Kette von Methodenaufrufen vermischt, weiß ich, dass er schwer zu verstehen sein wird, wenn er fehlschlägt. Halte die drei Phasen visuell getrennt, auch wenn du die Kommentare weglässt.
Testbenennung#
Es gibt zwei Schulen: it('should calculate total with tax') und it('calculates total with tax'). Das „should"-Präfix ist wortreich ohne Information hinzuzufügen. Wenn der Test fehlschlägt, siehst du:
FAIL ✕ calculates total with tax
Das ist bereits ein vollständiger Satz. „Should" hinzuzufügen ist nur Rauschen. Ich bevorzuge die direkte Form: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Für describe-Blöcke verwende den Namen der zu testenden Einheit:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Lies es laut vor: „calculateTotal sums item prices." „calculateTotal applies tax rate." Wenn der Satz funktioniert, funktioniert die Benennung.
Eine Assertion pro Test vs. praktische Gruppierung#
Die puristische Regel sagt: eine Assertion pro Test. Die praktische Regel sagt: ein Konzept pro Test. Das ist nicht dasselbe.
// Das ist in Ordnung — ein Konzept, mehrere Assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// Das ist nicht in Ordnung — mehrere Konzepte in einem Test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});Der erste Test hat drei Assertions, aber alle überprüfen eine Sache: das Format eines Anzeigenamens. Wenn eine Assertion fehlschlägt, weißt du genau, was kaputt ist. Der zweite Test sind drei separate Tests zusammengequetscht. Wenn die zweite Assertion fehlschlägt, weißt du nicht, ob Erstellung oder Aktualisierung fehlerhaft ist, und die dritte Assertion wird nie ausgeführt.
Testbeschreibungen als Dokumentation#
Gute Testsuiten dienen als lebende Dokumentation. Jemand, der den Code nicht kennt, sollte die Testbeschreibungen lesen und das Verhalten des Features verstehen können:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});Wenn diese Testsuite läuft, liest sich die Ausgabe wie eine Spezifikation. Das ist das Ziel.
Mocking#
Mocking ist das mächtigste und gefährlichste Werkzeug in deinem Testing-Toolkit. Richtig eingesetzt isoliert es die zu testende Einheit und macht Tests schnell und deterministisch. Falsch eingesetzt erzeugt es Tests, die bestehen, egal was der Code tut.
vi.fn() — Mock-Funktionen erstellen#
Der einfachste Mock ist eine Funktion, die ihre Aufrufe aufzeichnet:
const mockCallback = vi.fn();
// Aufrufen
mockCallback("hello", 42);
mockCallback("world");
// Aufrufe prüfen
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Du kannst einen Rückgabewert definieren:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Oder bei aufeinanderfolgenden Aufrufen verschiedene Werte zurückgeben lassen:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Echte Methoden beobachten#
Wenn du eine Methode beobachten willst, ohne ihr Verhalten zu ersetzen:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn behält standardmäßig die Originalimplementierung bei. Du kannst sie bei Bedarf mit .mockImplementation() überschreiben und danach mit .mockRestore() wiederherstellen.
vi.mock() — Mocking auf Modulebene#
Das ist der große Hammer. vi.mock() ersetzt ein ganzes Modul:
// Das gesamte Modul mocken
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// Der Import verwendet jetzt die gemockte Version
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest hoisted vi.mock()-Aufrufe automatisch an den Anfang der Datei. Das bedeutet, der Mock ist aktiv, bevor irgendwelche Imports ausgeführt werden. Du musst dir keine Gedanken über die Import-Reihenfolge machen.
Automatisches Mocking#
Wenn du einfach jeden Export durch ein vi.fn() ersetzen willst:
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Ohne Factory-Funktion mockt Vitest alle Exports automatisch. Jede exportierte Funktion wird ein vi.fn(), das undefined zurückgibt. Das ist nützlich für Module, die du stumm schalten willst (wie Analytics oder Logging), ohne jede Funktion einzeln zu spezifizieren.
Clearing vs. Resetting vs. Restoring#
Das stolpert irgendwann jeden:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — setzt Aufrufhistorie zurück, behält Implementierung
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // gibt immer noch 42 zurück
// mockReset — setzt Aufrufhistorie UND Implementierung zurück
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // gibt nicht mehr 42 zurück
// mockRestore — für Spies, stellt Originalimplementierung wieder her
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() funktioniert jetzt wieder normalIn der Praxis verwende vi.clearAllMocks() in beforeEach, um die Aufrufhistorie zwischen Tests zurückzusetzen. Verwende vi.restoreAllMocks(), wenn du spyOn nutzt und die Originale zurückhaben willst:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Die Gefahr des Über-Mockings#
Das ist der wichtigste Mocking-Rat, den ich geben kann: Jeder Mock ist eine Lüge, die du deinem Test erzählst. Wenn du eine Abhängigkeit mockst, sagst du: „Ich vertraue darauf, dass dieses Ding korrekt funktioniert, also ersetze ich es durch eine vereinfachte Version." Wenn deine Annahme falsch ist, besteht der Test, aber das Feature ist kaputt.
// Über-gemockt — testet nichts Nützliches
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Dieser Test überprüft, dass processInput validate und format aufruft. Aber was, wenn processInput sie in der falschen Reihenfolge aufruft? Was, wenn es ihre Rückgabewerte ignoriert? Was, wenn die Validierung den Format-Schritt verhindern soll? Der Test weiß es nicht. Du hast das gesamte interessante Verhalten weggemockt.
Die Faustregel: Mocke an den Grenzen, nicht in der Mitte. Mocke Netzwerkanfragen, Dateisystemzugriffe und Drittanbieter-Services. Mocke nicht deine eigenen Hilfsfunktionen, es sei denn, es gibt einen zwingenden Grund (z. B. sie sind teuer in der Ausführung oder haben Seiteneffekte).
React-Komponenten testen#
Die Grundlagen mit Testing Library#
Testing Library erzwingt eine Philosophie: Teste Komponenten so, wie Nutzer mit ihnen interagieren. Kein Prüfen des internen Zustands, kein Inspizieren von Komponenteninstanzen, kein Shallow Rendering. Du renderst eine Komponente und interagierst mit ihr über das DOM, genau wie ein Nutzer es tun würde.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Queries: getBy vs. queryBy vs. findBy#
Hier kommen Anfänger durcheinander. Es gibt drei Query-Varianten und jede hat einen bestimmten Anwendungsfall:
getBy* — Gibt das Element zurück oder wirft einen Fehler, wenn nicht gefunden. Verwende es, wenn du erwartest, dass das Element existiert:
// Wirft einen Fehler, wenn kein Button gefunden wird — Test schlägt mit hilfreicher Nachricht fehl
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Gibt das Element oder null zurück, wenn nicht gefunden. Verwende es, wenn du prüfst, dass etwas NICHT vorhanden ist:
// Gibt null zurück — wirft keinen Fehler
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Gibt ein Promise zurück. Verwende es für Elemente, die asynchron erscheinen:
// Wartet bis zu 1000ms, bis das Element erscheint
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Barrierefreiheit-zuerst-Queries#
Testing Library stellt diese Queries in einer bewussten Prioritätsreihenfolge bereit:
getByRole— Die beste Query. Verwendet ARIA-Rollen. Wenn deine Komponente nicht per Rolle findbar ist, hat sie möglicherweise ein Barrierefreiheitsproblem.getByLabelText— Für Formularelemente. Wenn dein Input kein Label hat, behebe das zuerst.getByPlaceholderText— Akzeptabel, aber schwächer. Platzhalter verschwinden, wenn der Nutzer tippt.getByText— Für nicht-interaktive Elemente. Findet nach sichtbarem Textinhalt.getByTestId— Letzter Ausweg. Verwende es, wenn keine semantische Query funktioniert.
// Bevorzuge das
screen.getByRole("textbox", { name: "Email address" });
// Statt das
screen.getByPlaceholderText("Enter your email");
// Und definitiv statt das
screen.getByTestId("email-input");Die Reihenfolge ist nicht willkürlich. Sie entspricht der Art, wie assistive Technologien die Seite navigieren. Wenn du ein Element über seine Rolle und seinen zugänglichen Namen finden kannst, können Screenreader das auch. Wenn du es nur über eine Test-ID findest, hast du möglicherweise eine Barrierefreiheitslücke.
User Events#
Verwende nicht fireEvent. Verwende @testing-library/user-event. Der Unterschied ist wichtig:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type feuert keydown, keypress, input, keyup für JEDES Zeichen
// fireEvent.change setzt nur den Wert — überspringt den realistischen Event-Fluss
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent simuliert die vollständige Event-Kette, die ein echter Browser auslösen würde. fireEvent.change ist ein einzelnes synthetisches Event. Wenn deine Komponente auf onKeyDown lauscht oder onInput statt onChange verwendet, löst fireEvent.change diese Handler nicht aus, aber userEvent.type schon.
Rufe immer userEvent.setup() am Anfang auf und verwende die zurückgegebene user-Instanz. Das stellt die richtige Event-Reihenfolge und Zustandsverfolgung sicher.
Komponenten-Interaktionen testen#
Ein realistischer Komponententest sieht so aus:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});Beachte: Keine Inspektion des internen Zustands, kein component.setState(), keine Prüfung von Implementierungsdetails. Wir rendern, wir interagieren, wir prüfen, was der Nutzer sehen würde. Wenn die Komponente ihr internes State-Management von useState auf useReducer umstellt, bestehen diese Tests weiterhin. Das ist der Punkt.
Asynchronen Code testen#
waitFor#
Wenn eine Komponente sich asynchron aktualisiert, pollt waitFor, bis die Assertion besteht:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Zeigt zunächst Laden an
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Auf Inhalt warten
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Ladeanzeige sollte verschwunden sein
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor wiederholt den Callback alle 50 ms (standardmäßig), bis er besteht oder ein Timeout erreicht (standardmäßig 1000 ms). Du kannst beides anpassen:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Fake Timers#
Beim Testen von Code, der setTimeout, setInterval oder Date verwendet, erlauben dir Fake Timers die Zeitkontrolle:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // Timer zurücksetzen
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Wichtig: Rufe immer vi.useRealTimers() in afterEach auf. Fake Timers, die zwischen Tests durchsickern, verursachen die verwirrendsten Fehler, die du je debuggen wirst.
Testen mit Fake Timers und asynchronem Rendering#
Die Kombination von Fake Timers mit React-Komponententests erfordert Sorgfalt. Reacts internes Scheduling verwendet echte Timer, daher musst du oft Timer vorspulen UND React-Updates gleichzeitig flushen:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Gespeichert!" autoDismiss={5000} />);
expect(screen.getByText("Gespeichert!")).toBeInTheDocument();
// Timer innerhalb von act() vorspulen, um React-Updates zu flushen
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Gespeichert!")).not.toBeInTheDocument();
});
});API-Mocking mit MSW#
Zum Testen von Datenabruf fängt Mock Service Worker (MSW) Netzwerkanfragen auf Netzwerkebene ab. Das bedeutet, der Fetch-/Axios-Code deiner Komponente läuft genauso wie in der Produktion — MSW ersetzt nur die Netzwerkantwort:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Standard-Handler für diesen einen Test überschreiben
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW ist besser als fetch oder axios direkt zu mocken, weil:
- Der tatsächliche Datenabruf-Code deiner Komponente läuft — du testest die echte Integration.
- Du kannst die Fehlerbehandlung testen, indem du Handler pro Test überschreibst.
- Dieselben Handler funktionieren sowohl in Tests als auch im Browser-Entwicklungsmodus (z. B. Storybook).
Integrationstests#
Unit-Tests vs. Integrationstests#
Ein Unit-Test isoliert eine einzelne Funktion oder Komponente und mockt alles andere. Ein Integrationstest lässt mehrere Einheiten zusammenarbeiten und mockt nur externe Grenzen (Netzwerk, Dateisystem, Datenbanken).
Die Wahrheit ist: Die meisten Bugs, die ich in der Produktion gesehen habe, passieren an den Grenzen zwischen Einheiten, nicht innerhalb von ihnen. Eine Funktion funktioniert perfekt isoliert, versagt aber, weil der Aufrufer Daten in einem leicht anderen Format übergibt. Eine Komponente rendert einwandfrei mit Mock-Daten, bricht aber ab, wenn die tatsächliche API-Response eine zusätzliche Verschachtelungsebene hat.
Integrationstests fangen diese Bugs ab. Sie sind langsamer als Unit-Tests und schwerer zu debuggen, wenn sie fehlschlagen, aber sie geben mehr Vertrauen pro Test.
Mehrere Komponenten zusammen testen#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Nur die API-Schicht mocken — alles andere ist echt
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});In diesem Test arbeiten ShoppingCart und CartProvider und ihre internen Komponenten (Artikelzeilen, Mengenselektoren, Gesamtanzeige) alle mit echtem Code zusammen. Der einzige Mock ist der API-Aufruf, weil wir in Tests keine echten Netzwerkanfragen machen wollen.
Wann Integrations- vs. Unit-Tests verwenden#
Verwende Unit-Tests, wenn:
- Die Logik komplex ist und viele Randfälle hat (ein Datumsparser, eine State Machine, eine Berechnung).
- Du schnelles Feedback zum Verhalten einer bestimmten Funktion brauchst.
- Die Einheit relativ isoliert ist und nicht stark von anderen Einheiten abhängt.
Verwende Integrationstests, wenn:
- Mehrere Komponenten korrekt zusammenarbeiten müssen (ein Formular mit Validierung und Absendung).
- Daten durch mehrere Schichten fließen (Context → Komponente → Kindkomponente).
- Du einen Nutzer-Workflow testest, nicht den Rückgabewert einer Funktion.
In der Praxis hat eine gesunde Testsuite viele Integrationstests für Features und Unit-Tests für komplexe Hilfsfunktionen. Die Komponenten selbst werden durch Integrationstests getestet — du brauchst keinen separaten Unit-Test für jede kleine Komponente, wenn der Integrationstest sie abdeckt.
Coverage#
Coverage ausführen#
vitest run --coverageDu brauchst einen Coverage-Provider. Vitest unterstützt zwei:
# V8 — schneller, nutzt V8's eingebaute Coverage
npm install -D @vitest/coverage-v8
# Istanbul — ausgereifter, mehr Konfigurationsmöglichkeiten
npm install -D @vitest/coverage-istanbulKonfiguriere es in deiner Vitest-Config:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs. V8#
V8 Coverage nutzt die eingebaute Instrumentierung der V8-Engine. Es ist schneller, weil kein Code-Transformationsschritt nötig ist. Aber es kann bei einigen Randfällen weniger genau sein, besonders an ES-Modul-Grenzen.
Istanbul instrumentiert deinen Quellcode mit Zählern vor der Testausführung. Es ist langsamer, aber bewährter und liefert genauere Branch-Coverage. Wenn du Coverage-Schwellenwerte in CI erzwingst, könnte Istanbuls Genauigkeit wichtig sein.
Ich verwende V8 für lokale Entwicklung (schnelleres Feedback) und Istanbul in CI (genauere Durchsetzung). Du kannst verschiedene Provider pro Umgebung konfigurieren, falls nötig.
Was Coverage tatsächlich bedeutet#
Coverage sagt dir, welche Codezeilen während der Tests ausgeführt wurden. Das war's. Es sagt dir nicht, ob diese Zeilen korrekt getestet wurden. Betrachte das:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// Keine Assertion!
});Dieser Test liefert 100 % Coverage für die divide-Funktion. Er testet auch absolut nichts. Der Test würde bestehen, wenn divide null zurückgibt, einen Fehler wirft oder Raketen abschießt.
Coverage ist ein nützlicher negativer Indikator: Niedrige Coverage bedeutet, es gibt definitiv ungetestete Pfade. Aber hohe Coverage bedeutet nicht, dass dein Code gut getestet ist. Es bedeutet nur, dass jede Zeile während irgendeines Tests ausgeführt wurde.
Lines vs. Branches#
Line-Coverage ist die häufigste Metrik, aber Branch-Coverage ist wertvoller:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Ein Test mit getDiscount({ isPremium: true, yearsActive: 10 }) trifft jede Zeile (100 % Line-Coverage), testet aber nur zwei von drei Branches. Der isPremium: false-Pfad und der yearsActive <= 5-Pfad sind ungetestet.
Branch-Coverage erkennt das. Sie verfolgt jeden möglichen Pfad durch bedingte Logik. Wenn du einen Coverage-Schwellenwert durchsetzen willst, verwende Branch-Coverage.
Generierten Code ignorieren#
Mancher Code sollte nicht in der Coverage gezählt werden. Generierte Dateien, Typdefinitionen, Konfiguration — diese blähen deine Metriken auf, ohne Wert hinzuzufügen:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Du kannst auch bestimmte Zeilen oder Blöcke in deinem Quellcode ignorieren:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Oder für Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Verwende das sparsam. Wenn du dich dabei ertappst, große Code-Abschnitte zu ignorieren, brauchen diese Abschnitte entweder Tests oder sie sollten von vornherein nicht im Coverage-Bericht sein.
Next.js testen#
next/navigation mocken#
Next.js-Komponenten, die useRouter, usePathname oder useSearchParams verwenden, brauchen Mocks:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Für Tests, die überprüfen müssen, ob eine Navigation aufgerufen wurde:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});next-intl mocken#
Für internationalisierte Komponenten mit next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));Das ist der einfachste Ansatz — Übersetzungen geben den Schlüssel selbst zurück, sodass t("hero.title") "hero.title" liefert. In Assertions prüfst du den Übersetzungsschlüssel statt des tatsächlich übersetzten Strings. Das macht Tests sprachunabhängig.
Wenn du in einem bestimmten Test tatsächliche Übersetzungen brauchst:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));Route Handler testen#
Next.js Route Handler sind reguläre Funktionen, die ein Request nehmen und eine Response zurückgeben. Sie sind unkompliziert zu testen:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Middleware testen#
Next.js Middleware läuft an der Edge und verarbeitet jede Anfrage. Teste sie als Funktion:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Server Actions testen#
Server Actions sind asynchrone Funktionen, die auf dem Server laufen. Da sie einfach Funktionen sind, kannst du sie direkt testen — aber du musst möglicherweise Server-only-Abhängigkeiten mocken:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Pflichtfelder fehlen
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Praktische Muster#
Benutzerdefinierte Render-Funktion#
Die meisten Projekte brauchen dieselben Provider um jede Komponente. Erstelle einen benutzerdefinierten Render:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Alles aus Testing Library re-exportieren
export * from "@testing-library/react";
export { renderWithProviders as render };Jetzt importiert jede Testdatei aus deinen benutzerdefinierten Utils statt aus @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header und alle seine Kinder haben Zugriff auf ThemeProvider und CartProvider
});Custom Hooks testen#
Vitest funktioniert mit renderHook von @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // geht nicht unter min
});
});Error Boundaries testen#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// console.error für erwartete Fehler unterdrücken
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Etwas ist schiefgelaufen</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Etwas ist schiefgelaufen")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Fehler</div>}>
<div>Alles gut</div>
</ErrorBoundary>
);
expect(screen.getByText("Alles gut")).toBeInTheDocument();
expect(screen.queryByText("Fehler")).not.toBeInTheDocument();
});
});Snapshot Testing (mit Bedacht)#
Snapshot-Tests haben einen schlechten Ruf, weil man sie als Ersatz für echte Assertions verwendet. Ein Snapshot der gesamten HTML-Ausgabe einer Komponente ist eine Wartungslast — er bricht bei jeder CSS-Klassen-Änderung und niemand prüft den Diff sorgfältig.
Aber gezielte Snapshots können nützlich sein:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Gut — kleiner, gezielter Snapshot der Ausgabe einer reinen Funktion
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Schlecht — riesiger Snapshot, den niemand prüfen wird
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Mach das nicht
});Inline-Snapshots (toMatchInlineSnapshot) sind besser als Datei-Snapshots, weil der erwartete Wert direkt im Test sichtbar ist. Du siehst auf einen Blick, was die Funktion zurückgibt, ohne eine separate .snap-Datei zu öffnen.
Die Testphilosophie#
Verhalten testen, nicht Implementierung#
Dieses Prinzip ist so wichtig, dass es einen eigenen Abschnitt verdient. Betrachte zwei Tests für dasselbe Feature:
// Implementierungstest — spröde, bricht bei Refactors
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Verhaltenstest — robust, testet was der Nutzer sieht
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});Der erste Test bricht, wenn du von useState auf useReducer umsteigst, obwohl die Komponente genau gleich funktioniert. Der zweite Test bricht nur, wenn sich das Verhalten der Komponente tatsächlich ändert. Er kümmert sich nicht darum, wie der Zähler intern verwaltet wird — nur dass ein Klick auf „+" die Zahl erhöht.
Der Lackmustest ist einfach: Kannst du die Implementierung refaktorieren, ohne den Test zu ändern? Wenn ja, testest du Verhalten. Wenn nein, testest du Implementierung.
Die Testing Trophy#
Kent C. Dodds schlug die „Testing Trophy" als Alternative zur traditionellen Testpyramide vor:
╭─────────╮
│ E2E │ Wenige — teuer, langsam, hohes Vertrauen
├─────────┤
│ │
│ Integr. │ Die meisten — gutes Vertrauens-Kosten-Verhältnis
│ │
├─────────┤
│ Unit │ Einige — schnell, fokussiert, kostengünstig
├─────────┤
│ Statisch│ Immer — TypeScript, ESLint
╰─────────╯
Die traditionelle Pyramide setzt Unit-Tests unten (viele davon) und Integrationstests in der Mitte (weniger). Die Trophy kehrt das um: Integrationstests sind der Sweet Spot. Hier ist der Grund:
- Statische Analyse (TypeScript, ESLint) fängt Tippfehler, falsche Typen und einfache Logikfehler kostenlos ab. Du musst nicht einmal etwas ausführen.
- Unit-Tests eignen sich hervorragend für komplexe reine Logik, sagen dir aber nicht, ob die Teile zusammenarbeiten.
- Integrationstests überprüfen, ob Komponenten, Hooks und Contexts zusammenarbeiten. Sie geben das meiste Vertrauen pro geschriebenem Test.
- End-to-End-Tests überprüfen das gesamte System, sind aber langsam, instabil und teuer in der Wartung. Du brauchst ein paar für kritische Pfade, aber nicht Hunderte.
In der Praxis folge ich dieser Verteilung: TypeScript fängt die meisten meiner Typfehler ab, ich schreibe Unit-Tests für komplexe Hilfsfunktionen und Algorithmen, Integrationstests für Features und Nutzer-Workflows, und eine Handvoll E2E-Tests für den kritischen Pfad (Registrierung, Kauf, Kern-Workflow).
Was Vertrauen gibt vs. was Zeit verschwendet#
Tests existieren, um dir das Vertrauen zum Ausliefern zu geben. Nicht das Vertrauen, dass jede Codezeile läuft — sondern das Vertrauen, dass die Anwendung für Nutzer funktioniert. Das sind verschiedene Dinge.
Hohes Vertrauen, hoher Wert:
- Integrationstest eines Checkout-Flows — deckt Formularvalidierung, API-Aufrufe, Zustandsaktualisierungen und Erfolgs-/Fehler-UI ab.
- Unit-Test einer Preisberechnungsfunktion mit Randfällen — Gleitkomma, Rundung, Rabatte, Null-/Negativwerte.
- Test, dass geschützte Routen unauthentifizierte Nutzer weiterleiten.
Wenig Vertrauen, Zeitverschwendung:
- Snapshot-Test einer statischen Marketingseite — bricht bei jeder Textänderung, fängt nichts Bedeutsames ab.
- Unit-Test, dass eine Komponente ein Prop an ein Kind weitergibt — testet React selbst, nicht deinen Code.
- Test, dass
useStateaufgerufen wird — testet das Framework, nicht Verhalten. - 100 % Coverage einer Konfigurationsdatei — es sind statische Daten, TypeScript validiert bereits ihre Form.
Die Frage vor jedem Test: „Wenn dieser Test nicht existieren würde, welcher Bug könnte in die Produktion gelangen?" Wenn die Antwort „keiner, den TypeScript nicht abfangen würde" oder „keiner, den jemand bemerken würde" lautet, lohnt sich der Test wahrscheinlich nicht.
Testing als Designfeedback#
Schwer zu testender Code ist meist schlecht designter Code. Wenn du fünf Dinge mocken musst, um eine Funktion zu testen, hat diese Funktion zu viele Abhängigkeiten. Wenn du eine Komponente nicht ohne aufwendiges Context-Provider-Setup rendern kannst, ist die Komponente zu stark an ihre Umgebung gekoppelt.
Tests sind ein Nutzer deines Codes. Wenn deine Tests Schwierigkeiten haben, deine API zu verwenden, werden andere Entwickler das auch haben. Wenn du dich gegen das Test-Setup sträubst, nimm es als Signal, den zu testenden Code zu refaktorieren, nicht mehr Mocks hinzuzufügen.
// Schwer zu testen — Funktion macht zu viel
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Einfacher zu testen — Verantwortlichkeiten getrennt
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}Die zweite Version ist eine reine Funktion. Du kannst jede Kombination aus Inventar- und Zahlungsergebnissen testen, ohne eine Datenbank, einen Zahlungsanbieter oder einen E-Mail-Dienst zu mocken. Die Orchestrierungslogik (Daten abrufen, E-Mails senden) lebt in einer dünnen Schicht, die auf Integrationsebene getestet wird.
Das ist der wahre Wert des Testens: nicht Bugs abfangen, nachdem sie geschrieben wurden, sondern schlechte Designs verhindern, bevor sie committed werden. Die Disziplin des Testschreibens drängt dich zu kleineren Funktionen, klareren Schnittstellen und modularer Architektur. Die Tests sind der Nebeneffekt. Die Designverbesserung ist das Hauptereignis.
Schreibe Tests, denen du vertraust#
Das Schlimmste, was einer Testsuite passieren kann, ist nicht, dass sie Lücken hat. Es ist, dass die Leute aufhören, ihr zu vertrauen. Eine Testsuite mit ein paar instabilen Tests, die zufällig in CI fehlschlagen, bringt dem Team bei, rote Builds zu ignorieren. Sobald das passiert, ist die Testsuite schlimmer als nutzlos — sie bietet aktiv falsches Sicherheitsgefühl.
Wenn ein Test intermittierend fehlschlägt, behebe ihn oder lösche ihn. Wenn ein Test langsam ist, beschleunige ihn oder verschiebe ihn in eine separate Slow-Test-Suite. Wenn ein Test bei jeder unverwandten Änderung bricht, schreibe ihn um, damit er Verhalten statt Implementierung testet.
Das Ziel ist eine Testsuite, bei der jeder Fehlschlag bedeutet, dass etwas wirklich kaputt ist. Wenn Entwickler den Tests vertrauen, führen sie sie vor jedem Commit aus. Wenn sie den Tests nicht vertrauen, umgehen sie sie mit --no-verify und deployen mit gekreuzten Fingern.
Baue eine Testsuite, auf die du dein Wochenende wetten würdest. Nichts Geringeres lohnt sich zu pflegen.