Zum Inhalt springen

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.

6 Min. Lesezeit
Terminal, das eine grüne Testsuite mit schnellem Feedback zeigt

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.

tstypescript
// 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"),
    },
  },
});
tstypescript
// 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.

tstypescript
// 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) + "...";
}
tstypescript
// 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.

tstypescript
// 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.

tstypescript
// 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);
}
tstypescript
// 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.

tstypescript
// 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/);
  });
});
tstypescript
// 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:

tstypescript
// ✅ 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:

ymlyaml
# .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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX