Teststrategien für moderne Web-Apps: Was wirklich funktioniert
Praktischer Leitfaden für eine Teststrategie, die echte Bugs findet: Unit-, Integrations- und End-to-End-Tests mit Vitest und Playwright.

Tests sind ein Design-Werkzeug, keine Checkbox
Die meisten Teams schreiben Tests in erster Linie, um Regressionen zu vermeiden. Das ist nachvollziehbar, greift aber zu kurz. Eine gut durchdachte Testsuite zwingt dich dazu, modularen, entkoppelten Code zu schreiben. Sie macht Refactoring sicher. Und sie dokumentiert das beabsichtigte Verhalten ehrlicher, als es Kommentare je könnten.
Das Problem ist, dass schlechte Teststrategien Testsuites hervorbringen, die langsam und brüchig sind und nur eine trügerische Sicherheit vermitteln. In diesem Leitfaden geht es darum, die richtige Abdeckung aufzubauen – die Art, die echte Bugs findet, ohne bei jedem Merge Widerstand zu leisten.
Die Test-Trophäe statt der Pyramide
Die Testpyramide kennst du wahrscheinlich: viele Unit-Tests an der Basis, weniger Integrationstests in der Mitte, ganz wenige E2E-Tests an der Spitze. Diese Pyramide wurde für eine andere Ära entworfen.
Für moderne Web-Apps – insbesondere React- und API-getriebene Architekturen – beschreibt die Test-Trophäe die Realität besser:
- Statische Analyse (TypeScript, ESLint): fängt ganze Fehlerklassen quasi kostenlos ab
- Unit-Tests: reine Logik, Hilfsfunktionen, isolierte Funktionen
- Integrationstests: der Großteil deiner Suite – Komponenten mit echtem Verhalten, API-Handler mit einer echten Datenbank
- End-to-End-Tests: die kritischen Pfade, die ein echter Nutzer tatsächlich durchläuft
Die meisten Teams investieren zu wenig in Integrationstests und zu viel in eng gefasste Unit-Tests, die zwar Refactorings überstehen, systemische Fehler aber übersehen.
Vitest in einem Next.js-Projekt einrichten
Vitest ist Jest-kompatibel, nativ ESM-basiert und deutlich schneller. Es ist die richtige Standardwahl für moderne TypeScript-Projekte.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
exclude: ["node_modules/", "src/test/", "**/*.d.ts"],
},
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});// src/test/setup.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
});
// Mock next/navigation globally — avoids router errors in component tests
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));Unit-Tests: eng gefasst, schnell und deterministisch
Unit-Tests spielen ihre Stärken bei reiner Logik aus: Datentransformationen, Validierungsregeln, Hilfsfunktionen. Das Ziel ist vollständige Isolation – kein Netzwerk, keine Datenbank, kein DOM.
// utils/format.ts
export function formatCurrency(amount: number, locale = "en-US", currency = "USD"): string {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + "...";
}// utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, truncate } from "./format";
describe("formatCurrency", () => {
it("formats USD by default", () => {
expect(formatCurrency(1234.5)).toBe("$1,234.50");
});
it("handles zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("supports other currencies", () => {
expect(formatCurrency(100, "de-DE", "EUR")).toBe("100,00 €");
});
});
describe("truncate", () => {
it("returns string unchanged when within limit", () => {
expect(truncate("hello", 10)).toBe("hello");
});
it("truncates and appends ellipsis", () => {
expect(truncate("hello world", 8)).toBe("hello...");
});
it("handles exact boundary", () => {
expect(truncate("hello", 5)).toBe("hello");
});
});Halte Unit-Tests DAMP (Descriptive And Meaningful Phrases) statt übertrieben DRY – ein fehlschlagender Unit-Test sollte dir genau sagen, was kaputt ist, ohne dass du dich durch mehrere Abstraktionsebenen graben musst.
Integrationstests: Wo das Vertrauen herkommt
Integrationstests rendern Komponenten mit ihren echten Abhängigkeiten, lösen echte Events aus und prüfen echte Ausgaben. Genau hier ist Testing Library unverzichtbar.
// components/LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { LoginForm } from "./LoginForm";
// Mock only the network boundary — everything else is real
vi.mock("@/services/auth", () => ({
signIn: vi.fn(),
}));
import { signIn } from "@/services/auth";
describe("LoginForm", () => {
const user = userEvent.setup();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders email and password fields", () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it("disables submit while request is in flight", async () => {
vi.mocked(signIn).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 500))
);
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button", { name: /signing in/i })).toBeDisabled();
});
it("shows a field-level error for invalid email", async () => {
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.tab(); // trigger blur validation
expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
});
it("redirects to dashboard on success", async () => {
const mockPush = vi.fn();
vi.mocked(signIn).mockResolvedValueOnce({ success: true });
// Provide router mock at the test level for more control
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/dashboard");
});
});
});Grundprinzip: Selektiere nach Rolle und Label, nicht nach CSS-Klasse oder Test-ID. Tests, die so interagieren wie Nutzer es tun, überstehen UI-Refactorings; Tests, die .btn-primary abfragen, tun das nicht.
API-Route-Handler testen
Next.js-App-Router-Handler sind schlichte async-Funktionen – sie lassen sich leicht testen, ohne einen Server hochzufahren.
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await db.post.findUnique({ where: { id: params.id } });
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(post);
}// app/api/posts/[id]/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
vi.mock("@/lib/db", () => ({
db: {
post: {
findUnique: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
describe("GET /api/posts/:id", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the post when found", async () => {
const mockPost = { id: "1", title: "Hello", content: "World" };
vi.mocked(db.post.findUnique).mockResolvedValueOnce(mockPost);
const request = new Request("http://localhost/api/posts/1");
const response = await GET(request, { params: { id: "1" } });
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toEqual(mockPost);
});
it("returns 404 when post does not exist", async () => {
vi.mocked(db.post.findUnique).mockResolvedValueOnce(null);
const request = new Request("http://localhost/api/posts/99");
const response = await GET(request, { params: { id: "99" } });
expect(response.status).toBe(404);
});
});End-to-End-Tests mit Playwright
E2E-Tests laufen gegen einen echten Browser mit einer echten (oder vorbefüllten) Datenbank. Sie sind langsam und sollten sparsam eingesetzt werden – reserviert für die Nutzerflows, die im Fall eines Bruchs den größten Schaden anrichten würden.
// tests/e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => {
// Seed a test user via API before each test
await fetch("http://localhost:3000/api/test/seed", { method: "POST" });
await page.goto("/login");
});
test("user can log in with valid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
test("shows error for invalid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("wrong-password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
await expect(page).toHaveURL("/login");
});
test("redirects unauthenticated users to login", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
});// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "bun run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});Tests wartbar strukturieren
Eine Testdatei, die sich nicht lesen lässt, ist fast so schlimm wie gar keine Tests. Ein paar Konventionen, die helfen:
// ✅ Structure: Arrange → Act → Assert, every time
it("calculates total with discount applied", () => {
// Arrange
const cart = [
{ id: "1", price: 100, quantity: 2 },
{ id: "2", price: 50, quantity: 1 },
];
const discount = 0.1; // 10%
// Act
const total = calculateCartTotal(cart, discount);
// Assert
expect(total).toBe(225); // (100*2 + 50) * 0.9
});
// ✅ Test behavior, not implementation
// ❌ Bad: tests internal state
it("sets isLoading to true when fetching", () => {
const { result } = renderHook(() => useData());
expect(result.current.isLoading).toBe(true); // brittle — tied to internal naming
});
// ✅ Good: tests observable behavior
it("shows a loading indicator while data is fetching", async () => {
render(<DataTable />);
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByRole("status"));
});CI-Integration
Tests schaffen nur dann Mehrwert, wenn sie automatisch laufen. Eine minimale CI-Pipeline für ein Next.js-Projekt:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun run lint
- run: bun run test --coverage
e2e:
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx playwright install --with-deps chromium
- run: bun run build
- run: bunx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/E2E-Tests laufen erst, nachdem die Unit-Tests bestanden wurden – es bringt nichts, den kompletten Browser-Flow zu testen, wenn der Build schon kaputt ist.
Was gute Coverage wirklich bedeutet
Coverage-Prozentzahlen sind eine Vanity-Metrik. 90 % Coverage mit Tests, die nur expect(component).toBeTruthy() prüfen, ist wertlos.
Eine gesunde Teststrategie stellt die besseren Fragen:
- Kann ich den Code dieses Features löschen und ein Test schlägt fehl? Wenn nicht, testet der Test das Feature nicht wirklich.
- Schlägt ein Test fehl, wenn ich einen echten Bug einbaue? Prüfe das regelmäßig mit Mutation-Testing über Stryker.
- Wie lange braucht die gesamte Suite? Unter 30 Sekunden für Unit-/Integrationstests sorgt dafür, dass Entwickler sie auch wirklich ausführen.
- Sind Fehlermeldungen umsetzbar? Ein fehlschlagender Test sollte das kaputte Verhalten benennen, nicht nur "snapshot mismatch" ausgeben.
Eine Testsuite, der Entwickler vertrauen – und die sie lokal vor jedem Commit ausführen – findet Bugs früher, beschleunigt den Code-Review und macht Refactoring deutlich weniger angsteinflößend. Das ist die eigentliche Rendite dieser Investition.


