Moderne Teststrategien: Von Unit bis End-to-End

Jedes Softwareteam schreibt Tests. Nicht jedes Team schreibt Tests, die Fehler verhindern, Refactorings überstehen und genug Vertrauen geben, um am Freitagnachmittag auszuliefern. Der Unterschied zwischen hilfreichen und schädlichen Tests liegt nicht am Framework oder an der Abdeckungsquote. Er liegt in der Strategie – zu wissen, was auf welcher Ebene mit welchem Kompromiss getestet wird.

Viele Teams schreiben standardmäßig für alles Unit-Tests und halten die Sache damit für erledigt. Der Abdeckungsbericht zeigt 90 Prozent, die CI ist grün, und alle fühlen sich gut – bis eine Änderung an einer Funktion drei Features zerlegt, die kein Unit-Test erfasst hat. Das Problem ist nicht fehlende Testdisziplin, sondern Testen auf der falschen Ebene. Eine Komponente, die fünf Dienste verdrahtet, kann 100 Prozent Unit-Abdeckung haben und trotzdem in Produktion scheitern, weil das Zusammenspiel dieser Dienste nie geprüft wurde.

Dieser Leitfaden deckt das ganze Spektrum moderner Teststrategien ab – von schnellen, isolierten Unit-Tests bis zu langsamen, aber verlässlichen End-to-End-Tests – und erklärt, wann jede Strategie Wert schafft und wann sie nur Aufwand erzeugt. Das Ziel ist nicht, Sie zu mehr Tests zu überreden. Es ist, Tests zu schreiben, die ihre Existenz rechtfertigen: Sie finden Fehler, die zählen, laufen schnell genug, um oft zu laufen, und überstehen die unvermeidliche Umstrukturierung Ihrer Codebasis.

Unit-Tests: bewährte Praxis und was zu testen ist

Unit-Tests sind die Grundlage der meisten Teststrategien, weil sie schnell, deterministisch und isoliert sind. Ein gut geschriebener Unit-Test läuft in Millisekunden, deckt genau ein logisches Verhalten ab und hängt nicht von externen Systemen wie Datenbanken, Dateisystemen oder Netzwerk-APIs ab. Wenn er fehlschlägt, wissen Sie genau, welches Stück Logik kaputt ist und warum.

Der häufigste Fehler beim Unit-Testen ist, Implementierungsdetails statt Verhalten zu prüfen. Zu testen, dass eine Funktion eine bestimmte interne Methode aufruft oder ein privates Feld setzt, macht den Test spröde – ein Refactoring, das das äußere Verhalten bewahrt, bricht ihn trotzdem. Der Test wird zur Last statt zum Sicherheitsnetz. Prüfen Sie die beobachtbare Ausgabe oder die Seiteneffekte einer Funktion, nicht den Weg dorthin.

Gute Kandidaten für Unit-Tests sind reine Hilfsfunktionen, Geschäftslogik ohne Ein- und Ausgabe, Validierung und Parsing, Datentransformationen, algorithmische Berechnungen und Zustandsautomaten. Schlechte Kandidaten sind Funktionen mit Netzwerkaufrufen, Komponenten, die Oberfläche rendern, und jeder Code, der eng an Framework-Interna gekoppelt ist. Diese testet man besser auf Integrations- oder E2E-Ebene.

import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";

describe("calculateDiscount", () => {
  it("returns 0 for orders below the threshold", () => {
    const order: Order = {
      items: [{ price: 30, quantity: 1 }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });

  it("applies 10 percent for orders over $100", () => {
    const order: Order = {
      items: [
        { price: 80, quantity: 1 },
        { price: 40, quantity: 1 },
      ],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(12);
  });

  it("applies loyalty bonus for customers over 2 years", () => {
    const order: Order = {
      items: [{ price: 100, quantity: 1 }],
      customerSince: new Date("2022-06-01"),
    };

    expect(calculateDiscount(order)).toBe(15);
  });

  it("does not apply discount when items are on sale", () => {
    const order: Order = {
      items: [{ price: 200, quantity: 1, onSale: true }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });
});

Das Beispiel oben prüft vier verschiedene Verhaltensweisen einer einzigen Funktion mit unterschiedlichen Eingaben und erwarteten Ausgaben. Jeder Test ist unabhängig, deterministisch und läuft in unter 10 Millisekunden. Ändert sich die Preislogik, sagen die Tests genau, welche Szenarien betroffen sind. Das ist das Wertversprechen von Unit-Tests: schnelle Rückmeldung zu reiner Logik.

  • Testen Sie Verhalten, nicht Implementierung. Prüfen Sie Ausgaben und Seiteneffekte, nicht interne Methodenaufrufe oder privaten Zustand.
  • Eine logische Zusicherung pro Testfall. Ein Test sollte aus genau einem Grund fehlschlagen, damit sofort klar ist, was kaputtging.
  • Verwenden Sie sprechende Testnamen, die sich wie Sätze lesen. „gibt 0 zurück für Bestellungen unter der Schwelle“ ist besser als „test_rabatt_kleiner_betrag“.
  • Vermeiden Sie geteilten veränderlichen Zustand zwischen Tests. Jeder Test richtet seine eigenen Daten ein und räumt hinter sich auf.
  • Halten Sie Testwerte einfach und aussagekräftig. Nutzen Sie realistische Daten, die echten Domänenobjekten entsprechen.

Integrationstests mit echten Abhängigkeiten

Integrationstests liegen bei Geschwindigkeit und Umfang zwischen Unit- und E2E-Tests. Sie prüfen, wie mehrere Einheiten zusammenspielen – ein Repository, das eine echte Datenbank anspricht, ein Dienst, der einen tatsächlichen API-Endpunkt aufruft, oder eine Komponente, die mit einem echten Store rendert. Der entscheidende Unterschied zu Unit-Tests: Integrationstests nutzen echte oder containerisierte Abhängigkeiten statt Attrappen.

Die wertvollsten Integrationstests prüfen, ob Ihr Code korrekt mit externen Systemen umgeht. Ein Unit-Test, der die Datenbankschicht nachbildet, sagt Ihnen, ob die Abfragelogik isoliert stimmt, aber nicht, ob die tatsächliche SQL-Abfrage gegen das echte Schema läuft. Integrationstests mit Testcontainers oder einer Testdatenbank finden Schemaabweichungen, verletzte Bedingungen, Fehler an Transaktionsgrenzen und Serialisierungsprobleme, die Unit-Tests komplett entgehen.

Integrationstests glänzen bei Repository-Schichten, API-Routen, Verbrauchern von Nachrichtenwarteschlangen, Datenbankmigrationen und überall dort, wo Daten über eine Grenze hinweg serialisiert werden. Der Preis ist Geschwindigkeit – ein Test, der einen Postgres-Container startet, braucht Sekunden statt Millisekunden –, doch die gefundenen Fehler sind entsprechend teurer, wenn man sie erst in Produktion entdeckt.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";

describe("OrderRepository", () => {
  let db: Awaited<ReturnType<typeof createDatabase>>;
  let repo: OrderRepository;

  beforeAll(async () => {
    db = await createDatabase();
    repo = new OrderRepository(db);
  });

  afterAll(async () => {
    await teardownDatabase(db);
  });

  it("persists and retrieves an order", async () => {
    const order = {
      id: "ord_001",
      customerId: "cus_001",
      total: 1250,
      status: "pending" as const,
      createdAt: new Date("2025-06-01"),
    };

    await repo.save(order);
    const retrieved = await repo.findById("ord_001");

    expect(retrieved).toEqual(order);
  });

  it("returns null for a non-existent order", async () => {
    const result = await repo.findById("ord_nonexistent");
    expect(result).toBeNull();
  });

  it("updates order status", async () => {
    await repo.save({
      id: "ord_002",
      customerId: "cus_001",
      total: 500,
      status: "pending",
      createdAt: new Date(),
    });

    await repo.updateStatus("ord_002", "shipped");
    const updated = await repo.findById("ord_002");

    expect(updated?.status).toBe("shipped");
  });
});

Die entscheidende Regel für Integrationstests lautet: Halten Sie den Datenbankzustand zwischen Läufen isoliert. Jede Testsuite sollte ihr eigenes Schema oder ihre eigene Datenbank anlegen, Migrationen ausführen, testen und alles wieder abbauen. Tests gegen eine geteilte Datenbank führen zu flatternden Tests, bei denen Daten eines Tests in die Zusicherungen eines anderen sickern. Nutzen Sie Testcontainers, Docker Compose oder die Testdatenbank-Unterstützung Ihres Frameworks, damit jeder Lauf von einem bekannten Zustand startet.

Ein Integrationstest mit echter Datenbank ist hundert Unit-Tests wert, die die Datenbank nachbilden. Die Attrappe sagt Ihnen, was Sie von der Datenbank erwarten. Die echte Datenbank sagt Ihnen, was sie tatsächlich tut. Nach der ersten Schemamigration sind beide selten identisch.

End-to-End-Tests mit Playwright

End-to-End-Tests simulieren echte Nutzerinteraktionen durch den gesamten Anwendungsstapel – Browser, Frontend, API, Datenbank und externe Dienste. Sie sind die langsamste und teuerste Testart, finden aber die realistischsten Fehler: kaputte Navigation, fehlende Formularvalidierung, falsch angezeigte API-Antworten, gescheiterte Anmeldeabläufe und Darstellungsprobleme zwischen Browsern.

Playwright hat sich wegen seiner Browser-Unterstützung, der automatisch wartenden Zusicherungen, der Netzwerkabfangung und der Entwicklerfreundlichkeit als führendes E2E-Werkzeug für Webanwendungen durchgesetzt. Anders als frühere Werkzeuge, die explizite Wartezeiten und Wiederholungen verlangten, wartet Playwright, bis ein Element tatsächlich bedienbar ist – und beseitigt damit die häufigste Ursache flatternder Tests.

import { test, expect } from "@playwright/test";

test("user completes a purchase flow", async ({ page }) => {
  await page.goto("/products");

  await page.getByRole("link", { name: "Wireless Headphones" }).click();
  await page.getByRole("button", { name: "Add to Cart" }).click();
  await page.getByRole("button", { name: "View Cart" }).click();

  await expect(page.getByText("Wireless Headphones")).toBeVisible();
  await expect(page.getByText("$89.99")).toBeVisible();

  await page.getByRole("button", { name: "Checkout" }).click();

  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Card Number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/28");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay Now" }).click();

  await expect(page.getByText("Order confirmed")).toBeVisible();
  await expect(page.getByText("ord_001")).toBeVisible();
});

Der größte Fehler bei E2E-Tests ist, zu viele davon zu schreiben. Jeder E2E-Test verlängert die CI-Pipeline um Minuten, und eine Suite mit 200 solcher Tests kann leicht 30 Minuten dauern. Die Ökonomie verlangt Auswahl. Konzentrieren Sie sich auf die entscheidenden Nutzerwege – Registrierung, Anmeldung, Kauf, Suche, Zahlung – und überlassen Sie Randfälle und Fehlerzustände den tieferen Ebenen.

  • Priorisieren Sie E2E-Tests für umsatzkritische Abläufe: Kasse, Abo-Upgrades, Zahlungsabwicklung.
  • Halten Sie E2E-Tests unabhängig. Jeder Test legt seine eigenen Daten an und räumt hinter sich auf.
  • Nutzen Sie im Testaufbau API-Aufrufe statt Oberflächeninteraktionen, um den Ausgangszustand schneller zu erreichen.
  • Führen Sie E2E-Tests gegen eine Staging- oder Vorschauumgebung aus, nicht gegen Produktion. Steuern Sie die Sichtbarkeit von Testdaten über Feature-Flags.
  • Investieren Sie in Beobachtbarkeit der Tests – Screenshots, Traces und Videos fehlgeschlagener Läufe sparen Stunden beim Debuggen.

Testgetriebene Entwicklung in der Praxis

Testgetriebene Entwicklung handelt nicht vom Testen, sondern vom Entwurf. Der Zyklus aus Rot, Grün und Refactoring zwingt Sie, über die Schnittstelle Ihres Codes nachzudenken, bevor Sie ihn implementieren. Den Test zuerst zu schreiben heißt, die Frage zu beantworten: Was soll diese Funktion entgegennehmen, und was soll sie zurückgeben? Diese Einschränkung führt zu besseren APIs, loserer Kopplung und modularerem Code.

Der Widerstand gegen TDD kommt meist aus einer von zwei Richtungen. Entweder hat jemand es an einem Problem versucht, für das es sich nicht eignet (Oberflächencode, exploratives Arbeiten), oder es wurde dogmatisch angewandt und mehr Zeit im Kampf mit dem Test-Framework verbracht als mit nützlichem Code. TDD ist ein Werkzeug, keine Religion. Am besten funktioniert es bei algorithmischer Logik, Datentransformationen, API-Entwurf und überall dort, wo der Vertrag zwischen Ein- und Ausgabe vorab klar ist.

Ein praktischer Ablauf sieht so aus: Schreiben Sie einen einzelnen Test, der das nächste gewünschte Verhalten beschreibt. Führen Sie ihn aus und sehen Sie ihn scheitern – das bestätigt, dass der Test gültig ist und das Fehlen des Features erkennt. Schreiben Sie das Minimum an Code, damit er besteht; die einfachste Umsetzung, die den Test erfüllt, ist die richtige. Anschließend verbessern Sie die Struktur, ohne das Verhalten zu ändern, und verlassen sich dabei auf die bestehenden Tests.

TDD bringt Sie nicht dazu, mehr Tests zu schreiben. Es bringt Sie dazu, besseren Code zu schreiben. Der Test ist ein Nebenprodukt des Entwurfsprozesses, nicht das Ziel. Wer nach der Implementierung Tests schreibt, testet. Wer vorher Tests schreibt, entwirft.

Besonders wirksam ist TDD bei Fehlerbehebungen. Wird ein Fehler gemeldet, schreiben Sie einen Test, der ihn reproduziert (rot), beheben den Code (grün) und prüfen, dass die Korrektur nichts Bestehendes zerlegt (alle Tests bestehen weiterhin). Der geschriebene Test wird zur dauerhaften Absicherung gegen Rückfälle. Mit der Zeit entsteht so eine Suite, die die tatsächliche Fehlergeschichte der Codebasis abbildet – weit wertvoller als eine Suite, die nur eine Abdeckungsvorgabe erfüllt.

Eigenschaftsbasiertes Testen und Fuzzing

Klassische beispielbasierte Tests prüfen bestimmte Eingaben gegen erwartete Ausgaben. Eigenschaftsbasiertes Testen geht anders vor: Es definiert eine Eigenschaft, die für alle Eingaben gelten soll, und erzeugt dann Hunderte oder Tausende zufälliger Eingaben, um sie zu prüfen. So werden Randfälle gefunden, an die niemand als Testbeispiel gedacht hätte.

Statt fünf konkreter Testfälle für eine Sortierfunktion definieren Sie etwa die Eigenschaft: Für jede Liste vergleichbarer Elemente muss das Ergebnis dieselben Elemente in nicht absteigender Reihenfolge enthalten. Das Framework erzeugt zufällige Listen unterschiedlicher Größe, mit Duplikaten, leeren Listen und Extremwerten, und prüft die Eigenschaft für jede davon.

import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";

describe("sortByPrice", () => {
  it("returns items in ascending price order for any input", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      for (let j = 1; j < sorted.length; j++) {
        expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
      }
    }
  });

  it("preserves all original elements", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      expect(sorted).toHaveLength(items.length);
      expect(sorted.map((i) => i.name).sort()).toEqual(
        items.map((i) => i.name).sort()
      );
    }
  });
});

Fast-check ist eine verbreitete Bibliothek für eigenschaftsbasiertes Testen in TypeScript, die sich in Vitest und Jest einfügt. Sie bringt Generatoren für gängige Datentypen, Kombinatoren für komplexe Strukturen und automatisches Verkleinern mit: Wird ein fehlschlagender Fall gefunden, versucht die Bibliothek, ihn auf die kleinste noch scheiternde Eingabe zu reduzieren – das erleichtert die Fehlersuche erheblich.

Besonders wertvoll ist der Ansatz bei Serialisierung und Deserialisierung, Sortier- und Filteralgorithmen, Validierungsregeln, Zustandsübergängen und überall, wo der Raum möglicher Eingaben groß ist. Zusammen mit Fuzzing-Techniken, die fehlerhafte oder unerwartete Daten einspeisen, legen eigenschaftsbasierte Tests Sicherheitslücken und Abstürze offen, die sonst erst in Produktion auffallen würden.

Visuelle Regressionstests

Funktionale Tests prüfen, ob sich die Anwendung richtig verhält. Visuelle Regressionstests prüfen, ob sie richtig aussieht. CSS-Änderungen, Abhängigkeitsaktualisierungen und umgebaute Komponenten können feine Layoutverschiebungen, Farbabweichungen, Schriftwechsel oder Probleme an Umbruchpunkten verursachen, die kein funktionaler Test entdeckt. Visuelle Regressionstests nehmen Screenshots von Komponenten oder Seiten auf, vergleichen sie mit Referenzbildern und melden jede Abweichung zur menschlichen Prüfung.

Werkzeuge wie Chromatic, Percy und Playwrights eingebaute Screenshot-Funktion setzen das auf verschiedenen Ebenen um. Chromatic fügt sich in Storybook ein und vergleicht Pixel für Pixel mit einer Diff-Logik, die Kantenglättung und Subpixel-Rendering ignoriert. Percy bietet Ähnliches mit breiterer Framework-Unterstützung. Playwrights Zusicherung toHaveScreenshot erlaubt visuelle Prüfungen direkt in Ihren E2E-Tests, ohne zusätzliche Werkzeuge.

import { test, expect } from "@playwright/test";

test("product page renders correctly", async ({ page }) => {
  await page.goto("/products/wireless-headphones");

  await expect(page).toHaveScreenshot("product-page.png", {
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
    fullPage: true,
  });
});

test("checkout form is visually stable across states", async ({ page }) => {
  await page.goto("/checkout");

  await expect(page).toHaveScreenshot("checkout-empty.png");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page).toHaveScreenshot("checkout-validation-error.png");
});

Die größte Hürde ist die Pflege der Referenzbilder. Jede beabsichtigte visuelle Änderung verlangt eine Aktualisierung, und der Freigabeprozess kann zum Engpass werden, wenn ein Team häufig an der Oberfläche arbeitet. Die Lösung besteht darin, Aktualisierungen der Referenzen als Teil des Entwicklungsablaufs zu behandeln: Visuelle Unterschiede im selben Review-Zyklus wie den Code freigeben, über eine Plattform, die sich in den Pull-Request-Ablauf einfügt.

  • Beginnen Sie mit kritischen Seiten: Startseite, Kasse, Anmeldung, Produktdetails und jede Seite mit komplexem Layout.
  • Setzen Sie passende Schwellen, um Kantenglättung und Schriftunterschiede zwischen Betriebssystemen zu ignorieren.
  • Nutzen Sie Screenshots auf Komponentenebene (über Storybook) für gezielte Abdeckung statt ganzseitiger Aufnahmen, die auf jede Änderung reagieren.
  • Binden Sie die visuelle Prüfung in den Pull-Request-Ablauf ein. Verlangen Sie eine menschliche Freigabe für visuelle Unterschiede auf geänderten Seiten.
  • Führen Sie visuelle Tests in einer einheitlichen Umgebung (Docker) aus, damit Unterschiede zwischen Entwicklerrechnern entfallen.

React Server Components und Next.js testen

React Server Components haben die Testlandschaft für React-Anwendungen verändert. Serverkomponenten laufen ausschließlich auf dem Server und senden kein JavaScript an den Client. Sie können direkt auf Datenbanken, Dateisysteme und Backend-Dienste zugreifen, ohne diese Logik dem Browser preiszugeben. Das macht einen anderen Testansatz möglich als bei clientseitigen Komponenten.

Serverkomponenten sind im Kern asynchrone Funktionen, die JSX zurückgeben. Sie testen sie wie jede andere asynchrone Funktion: mit Props aufrufen, das Ergebnis abwarten und auf die gerenderte Ausgabe prüfen. Keine Browserumgebung, kein Nachbilden von fetch oder Datenbankaufrufen, wenn Sie gegen eine echte Testdatenbank arbeiten. Diese Einfachheit ist einer der unterschätzten Vorzüge von Serverkomponenten: Der Testaufbau ist deutlich schlanker als bei Clientkomponenten, die eine DOM-Umgebung brauchen.

import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";

describe("ProductList (Server Component)", () => {
  it("renders products from the database", async () => {
    // No mocking needed - the component calls the database directly
    const { props } = await ProductList({ category: "electronics" });

    expect(props.products.length).toBeGreaterThan(0);
    expect(props.products.every((p) => p.category === "electronics")).toBe(
      true
    );
  });

  it("shows empty state when no products match", async () => {
    const { props } = await ProductList({ category: "nonexistent" });

    expect(props.products).toHaveLength(0);
  });
});

Clientkomponenten, die useState, useEffect, Browser-APIs oder Ereignishandler nutzen, brauchen eine DOM-Testumgebung. Vitest bietet dafür happy-dom oder jsdom. Testing Library liefert Hilfsmittel, um die gerenderte Ausgabe abzufragen und Nutzerinteraktionen zu simulieren. Der Unterschied zum Testen von Serverkomponenten: Sie müssen die Komponente rendern, auf Effekte warten und den entstandenen DOM-Zustand prüfen.

Next.js-Anwendungen bringen Routing, Datenabruf und Server Actions mit, die weitere Überlegungen erfordern. Bei Seitenkomponenten mit getServerSideProps oder generateStaticParams testen Sie die Abruffunktionen unabhängig von der Komponente. Server Actions testen Sie als gewöhnliche asynchrone Funktionen – mehr sind sie nicht. Bei Layouts und Seiten des App Routers prüfen Sie die Datenschicht getrennt von der Darstellungsschicht, damit Ihre Tests nicht an die Render-Interna des Frameworks gekoppelt sind.

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";

describe("CheckoutForm", () => {
  it("shows validation errors for empty required fields", async () => {
    const user = userEvent.setup();

    render(<CheckoutForm />);

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(screen.getByText("Email is required")).toBeVisible();
    expect(screen.getByText("Card number is required")).toBeVisible();
  });

  it("submits the form with valid data", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

    render(<CheckoutForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
    await user.type(screen.getByLabelText("Expiry"), "12/28");
    await user.type(screen.getByLabelText("CVC"), "123");

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      cardNumber: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    });
  });
});

Die wichtigste Unterscheidung im neuen React-Modell ist, Tests für Server- und Clientkomponenten zu trennen. Serverkomponenten sind reine asynchrone Funktionen – testen Sie sie ohne DOM-Umgebung. Clientkomponenten brauchen simulierte Nutzerinteraktion – testen Sie sie mit Testing Library und userEvent. Beides zu vermischen macht Ihre Tests langsamer und komplizierter als nötig.

Mocking-Strategien und wann man auf Mocks verzichtet

Mocking gehört zu den umstrittensten Themen beim Testen. Attrappen ersetzen echte Abhängigkeiten durch kontrollierte Stellvertreter und erlauben es, Code isoliert zu prüfen, ohne Datenbanken aufzusetzen, externe APIs aufzurufen oder auf Zeitgeber zu warten. Der Preis: Sie prüfen Ihren Code gegen eine simulierte Fassung der Abhängigkeit, nicht gegen die echte. Bildet die Attrappe das reale Verhalten nicht korrekt ab, besteht der Test, während der echte Code scheitert.

Als Faustregel gilt: an der Systemgrenze nachbilden. Ersetzen Sie externe Dienste, die Sie nicht kontrollieren (fremde APIs, Zahlungsdienstleister, E-Mail-Versand), und Infrastruktur, die teuer oder nicht deterministisch ist (Datenbanken, Dateisysteme, Uhren). Bilden Sie keine internen Module, Hilfsfunktionen oder eigenen Domänenobjekte nach – die gehören in Integrationstests mit ihren echten Implementierungen.

Vitest bringt mit vi.mock ein System mit, das Mock-Deklarationen an den Dateianfang zieht und Modulimplementierungen vor dem Testlauf ersetzt. Das ist nützlich für Umgebungsvariablen, fremde SDKs und globale Objekte. Das Risiko: Nachbildung auf Modulebene ist unsichtbar und kann zu Tests führen, die isoliert bestehen, zusammen aber wegen gegenseitiger Beeinflussung scheitern.

import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";

const mockStripe = {
  charges: {
    create: vi.fn(),
  },
};

vi.mock("stripe", () => ({
  default: vi.fn(() => mockStripe),
}));

describe("PaymentService", () => {
  it("creates a charge successfully", async () => {
    mockStripe.charges.create.mockResolvedValue({
      id: "ch_001",
      status: "succeeded",
    });

    const service = new PaymentService();
    const result = await service.charge(5000, "tok_visa");

    expect(result.status).toBe("succeeded");
    expect(mockStripe.charges.create).toHaveBeenCalledWith({
      amount: 5000,
      currency: "usd",
      source: "tok_visa",
    });
  });

  it("throws on failed payment", async () => {
    mockStripe.charges.create.mockRejectedValue(
      new Error("card_declined")
    );

    const service = new PaymentService();

    await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
      "card_declined"
    );
  });
});

Verlässlicher als Attrappen sind Test-Doubles, die dieselbe Schnittstelle wie die echte Abhängigkeit umsetzen, aber vereinfacht im Arbeitsspeicher arbeiten. Statt das Datenbankmodul nachzubilden, schreiben Sie etwa eine Speicherfassung Ihres Repositorys, die Daten in einer Map hält. Dieses Double ist leichtgewichtig, aber treu und durchläuft dieselben Codepfade wie die echte Datenbank – ohne deren Einrichtungskosten.

Jede Attrappe reißt eine Lücke zwischen Ihren Tests und der Wirklichkeit. In dieser Lücke verstecken sich die Fehler. Bevor Sie zur Attrappe greifen, fragen Sie: Kann ich das mit der echten Abhängigkeit in einer kontrollierten Umgebung testen? Wenn ja, nehmen Sie das Echte. Wenn nein – zu langsam, zu teuer, nicht deterministisch –, dann bilden Sie nach, aber eng an der Systemgrenze.

  • An Systemgrenzen nachbilden: fremde APIs, SDKs, Zahlungsanbieter, externe Dienste, die lokal nicht laufen.
  • Nutzen Sie Speicher-Doubles für eigene Abstraktionen (Repositories, Caches, Warteschlangen), statt sie nachzubilden.
  • Bilden Sie nicht nach, was Ihnen nicht gehört – das Nachbilden von Bibliotheksinterna koppelt Tests an Implementierungsdetails.
  • Bevorzugen Sie Abhängigkeitsinjektion gegenüber Nachbildung auf Modulebene. Explizit übergebene Abhängigkeiten machen den Testaufbau durchschaubar.
  • Begrenzen Sie den Geltungsbereich einer Attrappe auf den Test, der sie braucht. Setzen Sie alle Attrappen zwischen Tests zurück.

Testen in CI/CD-Pipelines

Eine Testsuite ist nur dann wertvoll, wenn sie verlässlich läuft und schnell Rückmeldung gibt. Tests, die langsam sind, flattern oder nur auf Entwicklerrechnern laufen, sind eine Last. Die Einbindung in die CI/CD-Pipeline sorgt dafür, dass sie bei jedem Pull Request, jeder Zusammenführung in den Hauptzweig und – für kritische Pfade – vor jeder Auslieferung laufen.

Die Testpyramide überträgt sich natürlich auf die Pipeline-Stufen. Unit-Tests laufen zuerst, bei jedem Push, weil sie schnell sind und grundlegende Logikfehler finden. Danach folgen Integrationstests, wo möglich parallel, mit containerisierten Abhängigkeiten. E2E-Tests kommen zuletzt, nur bei Pull Requests und vor Auslieferungen, weil sie langsam und teuer sind. Visuelle Regressionstests laufen neben den E2E-Tests und vergleichen Screenshots gegen die Referenz des Hauptzweigs.

Die Laufzeit zählt. Eine Suite, die 45 Minuten braucht, verleitet dazu, lokal gar nicht mehr zu testen und kaputten Code zu pushen. Wirksame Strategien: nur die von der Änderung betroffenen Tests ausführen, Suiten auf parallele Läufer verteilen, node_modules und Playwright-Browser zwischen Läufen zwischenspeichern und langsame E2E-Tests nach Zeitplan statt bei jedem Commit ausführen.

# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx vitest run --project unit

  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx vitest run --project integration

  e2e:
    needs: [unit, integration]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Flatternde Tests sind die größte Gefahr für das Vertrauen in die CI. Ein Test, der ohne Zusammenhang mit Codeänderungen gelegentlich scheitert, untergräbt das Vertrauen in die gesamte Pipeline. Teams beginnen, Fehlschläge zu ignorieren, mit roter CI zu mergen, und irgendwann wird die Suite zu Rauschen. Investieren Sie in das Erkennen solcher Tests – markieren, isolieren und vorrangig beheben oder entfernen. Ein Test, dem man nicht trauen kann, ist schlimmer als kein Test, weil er die Gewohnheit schafft, Fehlschläge zu übersehen.

Auch die Ergebnisdarstellung wird unterschätzt. Eine Ausgabe, die nur „3 Tests fehlgeschlagen“ meldet, zwingt zum Durchscrollen der Protokolle auf der Suche nach der eigentlichen Fehlermeldung. Gute Berichte zeigen die gescheiterte Zusicherung, erwartete und tatsächliche Werte, Datei und Zeilennummer sowie Kontext wie Screenshots oder Aufrufspuren. Werkzeuge wie HTML-Reporter für Vitest und Playwright oder Annotationen in GitHub Actions machen Ergebnisse direkt im Pull-Request-Ablauf sichtbar.

Testpyramide gegen Testtrophäe

Die klassische Testpyramide – Unit-Tests als Basis, Integrationstests in der Mitte, E2E-Tests an der Spitze – prägt die Teststrategie seit zwei Jahrzehnten. Sie vermittelt eine einfache Idee: viele schnelle, isolierte Tests, weniger langsamere Integrationstests und sehr wenige langsame E2E-Tests. Das Modell passt gut zu Backend-Diensten, deren Einheit eine Funktion oder Klasse mit klaren Ein- und Ausgabegrenzen ist.

Als Gegenentwurf wurde die Testtrophäe vorgeschlagen, die moderne Frontend-Entwicklung besser abbildet. Sie formt die Pyramide zu einer Raute, in der Integrationstests die größte Schicht bilden, mit weniger Unit- und weniger E2E-Tests. Das Argument: Bei Frontend-Anwendungen entsteht der größte Wert daraus, zu prüfen, wie Komponenten zusammenspielen – genau das leisten Integrationstests – und nicht daraus, isolierte Funktionen oder vollständige Nutzerwege zu prüfen.

Das Trophäenmodell spiegelt eine praktische Wahrheit: Die wertvollsten Tests einer React-Anwendung sind die, die eine Komponente rendern, mit ihr interagieren und das Ergebnis prüfen. Solche Tests beanspruchen die Komponente, ihre Kinder, ihre Hooks, ihre Zustandsverwaltung und ihre API-Aufrufe gemeinsam. Sie sind schneller als E2E-Tests und realistischer als Unit-Tests. Eine Suite gut geschriebener Integrationstests gibt im Frontend mehr Sicherheit je Test als Unit- oder E2E-Tests.

In Wahrheit sind beide Modelle Vereinfachungen. Die richtige Strategie hängt von Ihrer Architektur ab. Ein Backend-Microservice mit viel Geschäftslogik profitiert von der Pyramide – tiefe Unit-Abdeckung der Domänenlogik, weniger Integrationstests für die API-Schicht und eine Handvoll Vertragstests für externe Dienste. Eine Frontend-Anwendung mit komplexen Interaktionen profitiert von der Trophäe – breite Integrationsabdeckung für Komponenten und Seiten, gezielte Unit-Tests für Hilfslogik und E2E-Tests für die kritischen Pfade.

Pyramide und Trophäe sind beide falsch, wenn man ihnen dogmatisch folgt. Die richtige Teststrategie ist die, die Ihre Fehler findet, für Ihr Team schnell genug läuft und Ihre Architektur überlebt. Das kann wie eine Pyramide aussehen, wie eine Trophäe oder wie eine Form, die noch niemand benannt hat. Hören Sie auf, über die Form zu streiten, und schauen Sie, was Ihre Tests tatsächlich finden.

Die wichtige Einsicht hinter beiden Modellen ist, dass die Ebenen unterschiedliche Kosten-Nutzen-Profile haben. Ein Unit-Test kostet Millisekunden im Schreiben und Laufen. Ein Integrationstest kostet Sekunden. Ein E2E-Test kostet Minuten. Verteilen Sie Ihr Testbudget im Verhältnis zum Nutzen, den jede Ebene für Ihre konkrete Anwendung stiftet. Ist Ihre Anwendung ein datenlastiges Dashboard mit komplexer Geschäftslogik, investieren Sie in Unit- und Integrationstests. Ist sie eine Inhaltsseite mit wenig Interaktion, sind E2E-Tests für kritische Abläufe und visuelle Regressionstests für das Layout wahrscheinlich die beste Investition.

Eine Testkultur aufbauen

Die beste Strategie scheitert, wenn das Team nicht an Tests glaubt. Testkultur entsteht nicht durch vorgeschriebene Abdeckungsgrenzen oder erzwungenes TDD. Sie entsteht, wenn das Schreiben von Tests der natürliche Weg des geringsten Widerstands ist. Entwickler schreiben Tests, wenn Tests leicht zu schreiben, schnell auszuführen und erkennbar nützlich sind. Sind sie spröde, langsam und voller falscher Alarme, finden Entwickler Wege, sie zu umgehen.

Machen Sie zuerst die Testerfahrung ausgezeichnet. Investieren Sie in Infrastruktur – schnelle CI-Läufer, verlässliche Testdatenbanken, Erkennung flatternder Tests. Schreiben Sie Hilfsmittel, die gängige Zusicherungen knapp halten. Dokumentieren Sie Testmuster in einem Teamleitfaden, damit jede und jeder weiß, wie ein neuer API-Endpunkt, eine React-Komponente oder eine Datenbankmigration getestet wird, ohne bei null anzufangen. Gute Testinfrastruktur einmal zu bauen kostet weit weniger als der tägliche Kampf mit schlechten Tests.

Code-Reviews sollten Tests einschließen. Prüfende sollten fragen: Deckt dieser Test wirklich das Verhalten ab, das er zu prüfen vorgibt? Könnte er auch bei falscher Implementierung bestehen? Prüft er das Richtige auf der richtigen Ebene? Ist er lesbar und wartbar? Behandeln Sie Tests als vollwertigen Code – mit denselben Standards, Stilrichtlinien und Qualitätserwartungen wie Produktionscode.

Abdeckungszahlen sind ein nützliches Diagnosewerkzeug, aber ein schlechtes Ziel. Wer 90 Prozent vorschreibt, bekommt 90 Prozent – keine besseren Tests. Entwickler schreiben dann triviale Tests auf Getter-Methoden und leere Konstruktoren, um die Vorgabe zu erfüllen, ohne das Sicherheitsnetz zu verbessern. Nutzen Sie Abdeckungsberichte, um ungetestete Pfade zu finden, nicht um willkürliche Schwellen durchzusetzen. 70 Prozent mit gut platzierten Integrationstests sind mehr wert als 95 Prozent mit oberflächlichen Unit-Tests.

  • Machen Sie das Schreiben von Tests leicht: Hilfsmittel, Fabriken und Helfer, die Standardcode in jeder Testdatei sparen.
  • Feiern Sie Testverbesserungen wie ausgelieferte Features. Ein Test, der eine ganze Fehlerklasse ausschließt, ist ein Feature.
  • Lassen Sie die Verantwortung für die Testinfrastruktur rotieren, damit Wissen im Team verteilt und nicht bei einer Person gebündelt ist.
  • Halten Sie regelmäßig eine Sichtung flatternder Tests ab. Verfolgen Sie ihre Zahl als Kennzahl der Teamgesundheit und beheben Sie vorrangig.
  • Schreiben Sie ein Testmanifest des Teams, das festhält, was auf welcher Ebene getestet wird, was nicht, und welche Kompromisse vereinbart sind.

Das eigentliche Ziel einer Testkultur ist keine bestimmte Abdeckungszahl und keine perfekte Pipeline. Es ist Vertrauen. Vertrauen, dass ein Refactoring die Produktion nicht zerlegt. Vertrauen, dass eine Auslieferung am Freitagnachmittag sicher ist. Vertrauen, dass ein gemeldeter Fehler nach der Behebung behoben bleibt. Eine Strategie, die dem Team dieses Vertrauen gibt, ist eine gute Strategie – ganz gleich, ob sie wie eine Pyramide, eine Trophäe oder etwas dazwischen aussieht.

Alles zusammengenommen

Eine wirksame Teststrategie zu bauen heißt nicht, sich für einen Ansatz zu entscheiden. Es heißt, die Kompromisse zu verstehen und für jede Situation das passende Werkzeug zu wählen. Unit-Tests finden Logikfehler in Millisekunden. Integrationstests finden Fehler im Zusammenspiel mit echten Abhängigkeiten. E2E-Tests finden Ausfälle, die Nutzer sehen, über den gesamten Stapel hinweg. Eigenschaftsbasierte Tests finden Randfälle, von deren Existenz Sie nichts wussten. Visuelle Regressionstests finden ungewollte optische Veränderungen.

Die Teams, die verlässlich ausliefern, sind nicht die mit den höchsten Abdeckungszahlen. Es sind die Teams, die durchdacht haben, was jede Ebene leistet, ihre Testinvestition am Risikoprofil ausgerichtet und eine Kultur geschaffen haben, in der Tests geschrieben werden, weil sie Wert stiften – nicht weil eine Richtlinie es verlangt. Beginnen Sie mit einer Bestandsaufnahme Ihrer Suite. Fragen Sie bei jedem Test: Rechtfertigt er seine Existenz? Lautet die Antwort nicht klar ja, entfernen oder ersetzen Sie ihn. Die beste Testsuite ist nicht die größte. Es ist die, die Ihnen je Laufzeitminute das meiste Vertrauen gibt.

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.