Zum Inhalt springen

19. Teststrategien I

Zu Zen-Modus wechseln

Nach diesem Kapitel kannst du:

  • die Testebenen Unit, Integration und End-to-End unterscheiden und ihre jeweilige Aussage benennen.
  • die Testpyramide mit Kosten, Geschwindigkeit und Aussagekraft begründen und die Fehlform “Eistüte” erkennen.
  • für einen Anwendungsteil die passende Testebene und ein begründetes Werkzeug wählen.
  • Abhängigkeiten mit Stubs, Mocks oder Fakes isolieren und entscheiden, was mit echten Objekten und was an den Rändern ersetzt getestet wird.
  • Testdaten mit einem Builder erzeugen und Flakiness durch Ausschalten von Zufall und Zeit vermeiden.

Stell dir ein Autowerk vor. Bevor ein neues Modell auf die Straße darf, wird geprüft, aber nicht überall gleich. Jede Bremsscheibe kommt einzeln auf den Prüfstand, das geht schnell und kostet wenig. Die fertige Bremsanlage wird als Baugruppe geprüft, ob Scheibe, Sattel und Leitung zusammenpassen. Und erst ganz am Ende fährt jemand das ganze Auto auf der Teststrecke. Tausende Bauteilprüfungen, einige Baugruppentests, wenige Probefahrten: Niemand würde die Bremsscheiben nur durch Probefahrten prüfen, denn wenn das Auto nicht bremst, weiß man noch lange nicht, welches Teil schuld ist.

flowchart LR
  accTitle: Im Autowerk wird auf drei Ebenen geprüft, so wie Software auf drei Testebenen
  B["Bauteil auf dem Prüfstand<br/>(tausendfach, schnell)"] --> G["Baugruppe im Zusammenbau<br/>(einige)"]
  G --> P["Probefahrt mit dem ganzen Auto<br/>(wenige, teuer)"]
  B -.->|im Code| U["Unit-Test"]
  G -.->|im Code| I["Integrationstest"]
  P -.->|im Code| E["End-to-End-Test"]

Einzelne Tests zu schreiben ist ein Handwerk. Eine Teststrategie ist eine Entscheidung: Was prüfen wir, auf welcher Ebene, mit welchem Werkzeug - und was bewusst nicht? Denn testen ist nicht umsonst. Jeder Test kostet beim Schreiben Zeit und bei jeder Änderung erneut. Das Ziel ist nicht möglichst viel Test, sondern möglichst viel Sicherheit pro investierter Minute. Dieses Kapitel legt die Grundlagen dafür; das nächste (Kapitel 20) macht daraus eine dokumentierte Strategie für das Jahresprojekt.

  • Testebene: wie viel vom System ein Test auf einmal prüft (Unit, Integration, End-to-End).
  • Testpyramide: Empfehlung für das Mengenverhältnis der Ebenen: viele Unit-Tests, einige Integrationstests, wenige End-to-End-Tests.
  • Testrunner: Werkzeug, das Tests findet, ausführt und das Ergebnis meldet (hier Vitest).
  • Test-Double: Sammelbegriff für ein Ersatzobjekt, das im Test eine echte Abhängigkeit vertritt (Stub, Mock, Fake).
  • Builder (Testdaten-Builder): Funktion, die gültige Testobjekte mit sinnvollen Vorgaben liefert und nur das Relevante überschreiben lässt.
  • Flaky: Ein Test ist flaky, wenn er ohne Codeänderung mal fehlschlägt und mal durchläuft.

Tests unterscheiden sich danach, wie viel vom System sie auf einmal prüfen.

EbenePrüftBeispielGeschwindigkeit
Uniteine Einheit isoliert (eine Funktion, eine Klasse)die Mengenprüfung im Servicesehr schnell (Millisekunden)
Integrationmehrere Teile im ZusammenspielService zusammen mit echter Datenbankmittel
End-to-End (E2E)das ganze System wie ein NutzerKlick durch die Oberfläche bis zur Datenbank und zurücklangsam (Sekunden)
flowchart LR
  accTitle: Jede Testebene schließt mehr vom System ein als die darunter
  subgraph E2E["End-to-End: das ganze System"]
    UI["Oberfläche"]
    subgraph INT["Integration: Teile im Zusammenspiel"]
      subgraph UNIT["Unit: eine Einheit"]
        S["Service"]
      end
      R["Repository"] --> DB[("Datenbank")]
    end
    UI --> S
    S --> R
  end

Jede Ebene beantwortet eine andere Frage. Der Unit-Test sagt “diese Regel stimmt”. Der Integrationstest sagt “diese Teile passen zusammen”. Der E2E-Test sagt “der Nutzer kann diese Aufgabe erledigen”. Keine Ebene ersetzt eine andere: Ein grüner Unit-Test garantiert nicht, dass die Teile zusammenpassen, ein grüner E2E-Test sagt nicht, welche Einheit kaputt ist.

Genauer erklärt: Nimm einen Service, dessen Unit-Tests mit einem Ersatz-Repository alle grün sind. Steht im echten Repository eine fehlerhafte Datenbankabfrage (etwa ein falscher Spaltenname im SQL), merkt das keiner dieser Tests, denn die echte Abfrage läuft in ihnen nie. Diesen Fehler fängt erst ein Integrationstest gegen eine echte Test-Datenbank. Umgekehrt meldet ein roter E2E-Test zwar, dass die Bestellung nicht klappt, aber nicht, ob die Berechnung, die Abfrage oder nur ein Button schuld ist. Im Autowerk ist es genauso: Die Probefahrt ersetzt den Prüfstand nicht, und der Prüfstand ersetzt die Probefahrt nicht.

Die Ebenen unterscheiden sich in Kosten und Aussagekraft, und daraus folgt eine Empfehlung für ihr Mengenverhältnis:

flowchart TD
  accTitle: Die Testpyramide mit wenigen E2E-Tests oben, einigen Integrationstests in der Mitte und vielen Unit-Tests unten
  E["E2E: wenige<br/>langsam, breit, aber teuer und wackelig"]
  I["Integration: einige<br/>mittlere Kosten, prüft Zusammenspiel"]
  U["Unit: viele<br/>schnell, günstig, präzise lokalisierbar"]
  E --- I --- U

Die Form ist eine Pyramide, keine Säule und kein auf dem Kopf stehendes Dreieck: viele schnelle Unit-Tests unten, einige Integrationstests in der Mitte, wenige E2E-Tests oben. Der Grund ist wirtschaftlich:

  • Unit-Tests sind billig, schnell und zeigen bei einem Fehler sofort die Stelle. Von ihnen kann man Hunderte haben.
  • E2E-Tests sind teuer, langsam und sagen bei einem Fehlschlag nur “irgendwo im ganzen Ablauf stimmt etwas nicht”. Sie sind wertvoll, aber jeder einzelne kostet.

Die typische Fehlform ist die Eistüte (viele E2E-Tests, kaum Unit-Tests): Die Suite läuft minutenlang, ist bei jedem Fehlschlag mühsam zu deuten und wird deshalb bald ignoriert. Wer testet, wo der Fehler entsteht - meist in der Fachlogik -, braucht oben nur noch wenige Tests, die zeigen, dass alles zusammen läuft. Der Mechanismus dahinter: Ein E2E-Test scheitert oft an einer Stelle, die weit von der eigentlichen Fehlerursache entfernt liegt - “Button nicht gefunden” kann eine defekte Berechnung, einen langsamen Server oder nur eine geänderte CSS-Klasse bedeuten. Ein Unit-Test direkt an der Fehlerquelle sagt stattdessen sofort, welche Funktion mit welcher Eingabe das falsche Ergebnis liefert.

flowchart LR
  accTitle: Pyramide und Eistüte im Vergleich der Mengenverhältnisse
  subgraph P["Pyramide: gesund"]
    P1["E2E: wenige"] --- P2["Integration: einige"] --- P3["Unit: viele"]
  end
  subgraph T["Eistüte: Fehlform"]
    T1["E2E: sehr viele"] --- T2["Integration: wenige"] --- T3["Unit: kaum"]
  end
Auflösung

Das ist die Eistüte: oben breit, unten schmal. Erster Schritt: die Fachlogik (Preise, Mengen, Bestand), an der die meisten Fehler entstehen, mit Unit-Tests absichern und die E2E-Tests auf die wenigen wichtigsten Nutzerpfade reduzieren. Dann läuft die Suite schneller, und ein roter Unit-Test zeigt die Fehlerstelle direkt.

Die Wahl der Werkzeuge wird begründet, nicht dem Zufall überlassen. Grobe Zuordnung für ein TypeScript/Next.js-Projekt:

ZweckArt von WerkzeugWas es prüft
Unit- und IntegrationstestsTestrunner (z. B. Vitest)Funktionen, Klassen, Services; auch gegen eine echte Test-Datenbank
API-TestsTestrunner plus HTTP-AufrufeEndpunkte mit Statuscodes und Antwortformaten
KomponententestsTestrunner plus Rendering-Bibliothekeine UI-Komponente isoliert
End-to-EndBrowser-Automatisierung (z. B. Playwright)ganze Nutzerpfade im echten Browser
flowchart LR
  accTitle: Jede Testebene bekommt ein passendes Werkzeug, und alle laufen in der Pipeline
  U["Unit und Integration"] --> V["Testrunner: Vitest"]
  A["API und Komponenten"] --> V2["Testrunner plus HTTP bzw. Rendering"]
  E["End-to-End"] --> PW["Browser-Automatisierung: Playwright"]
  V --> CI["Pipeline: läuft bei jedem Push"]
  V2 --> CI
  PW --> CI

Das Auswahlkriterium ist immer dasselbe: Passt das Werkzeug zur Ebene, und ist es in der Pipeline (Kapitel 20) automatisch ausführbar? Ein Werkzeug, das nur auf einem Rechner von Hand läuft, sichert nichts ab.

Wo begegnet dir das? In deinem Jahresprojekt: Läuft der Test nur, wenn eine bestimmte Person ihn auf ihrem Laptop startet, ist er für das Team wertlos, sobald diese Person krank ist. Ein Werkzeug, das sich mit einem Befehl wie npx vitest run in der Pipeline starten lässt, ist die Voraussetzung dafür, dass Tests einen Merge blockieren können.

Ein Unit-Test soll eine Einheit prüfen. Sobald diese Einheit von einer anderen abhängt - der Service vom Repository, das Repository von der Datenbank -, muss die Abhängigkeit im Test ersetzt werden, sonst prüft man beide zugleich und der Test wird langsam und unzuverlässig. Im Autowerk ist das der Crashtest-Dummy: Statt eines echten Menschen sitzt ein Ersatz im Auto, der sich für die Prüfung genauso verhält, wie es nötig ist, und manche Dummys zeichnen zusätzlich mit Sensoren auf, was mit ihnen passiert.

Der Sammelbegriff für solche Ersatzobjekte ist Test-Double. Die wichtigsten Arten:

ArtVerhalten
Stubliefert feste, vorbereitete Antworten
Mockwie ein Stub, merkt sich aber zusätzlich, wie er aufgerufen wurde (für Prüfungen)
Fakeeine echte, aber vereinfachte Implementierung (etwa ein In-Memory-Repository)

Der Grund, warum das so leicht fällt, ist die Architektur mit Interfaces: Weil der Service ein Repository-Interface verlangt und nicht eine konkrete Datenbank, lässt sich im Test jede Ersatzimplementierung übergeben. Das ist das Dependency-Inversion-Prinzip aus Kapitel 14 und die Dependency Injection aus Kapitel 15, diesmal aus Sicht des Tests.

classDiagram
  accTitle: Der Service kennt nur das Repository-Interface, im Test wird ein Stub, Mock oder Fake eingesetzt
  class ClipRepository {
    <<interface>>
    +findById(id) Clip
    +listAll() Clip[]
  }
  class LibraryService {
    +totalDuration() number
  }
  class StubRepository
  class MockRepository["Mock: vi.fn()"]
  class FakeRepository["Fake: In-Memory"]
  class DatabaseRepository["echte Datenbank"]
  LibraryService --> ClipRepository : nutzt
  ClipRepository <|.. StubRepository
  ClipRepository <|.. MockRepository
  ClipRepository <|.. FakeRepository
  ClipRepository <|.. DatabaseRepository
interface ClipRepository {
findById(id: number): Clip | undefined;
listAll(): Clip[];
}
class LibraryService {
constructor(private repo: ClipRepository) {}
totalDuration(): number {
return this.repo.listAll().reduce((sum, clip) => sum + clip.durationSeconds, 0);
}
}
// the stub returns fixed data, nothing more
class StubRepository implements ClipRepository {
constructor(private clips: Clip[]) {}
findById(id: number) { return this.clips.find((clip) => clip.id === id); }
listAll() { return this.clips; }
}
import { describe, expect, it } from "vitest";
describe("LibraryService", () => {
it("sums the durations", () => {
const service = new LibraryService(
new StubRepository([aClip({ durationSeconds: 30 }), aClip({ id: 2, durationSeconds: 90 })]),
);
expect(service.totalDuration()).toBe(120); // no database involved
});
});

Ein Mock nimmt dir das Schreiben der Ersatzklasse ab und erlaubt zusätzlich, den Aufruf zu prüfen:

import { expect, it, vi } from "vitest";
it("asks the repository exactly once", () => {
const repo: ClipRepository = {
findById: vi.fn().mockReturnValue(aClip({ id: 7, title: "Intro" })),
listAll: vi.fn().mockReturnValue([]),
};
const service = new LibraryService(repo);
expect(service.titleOf(7)).toBe("Intro");
expect(repo.findById).toHaveBeenCalledWith(7); // verify the interaction
expect(repo.findById).toHaveBeenCalledTimes(1);
});

Beispielprojekt: 19_teststrategien_1_examples/ex01_test_doubles

flowchart LR
  accTitle: Die eigene Fachlogik läuft im Test echt, nur die Ränder werden durch Test-Doubles ersetzt
  T["Unit-Test"] --> F["eigene Fachlogik<br/>(echte Objekte)"]
  F --> D["Datenbank"]
  F --> N["Netzwerk"]
  F --> U["Uhr"]
  F --> Z["Zufall"]
  D -.-> X["Ränder: durch Stub, Mock oder Fake ersetzt"]
  N -.-> X
  U -.-> X
  Z -.-> X

Genauer erklärt: Angenommen, totalDuration() wird umgebaut und holt die Clips künftig einzeln über findById statt gesammelt über listAll. Das Ergebnis bleibt gleich, kein Nutzer merkt etwas. Ein Stub-Test, der nur prüft, dass 120 herauskommt, bleibt grün, solange der Stub beide Methoden beantwortet. Ein Mock-Test, der toHaveBeenCalledTimes(1) auf listAll verlangt, wird dagegen rot, obwohl kein Fehler vorliegt. Solche Fehlalarme sind teuer: Das Team gewöhnt sich daran, rote Tests “einfach anzupassen”, und übersieht dann den echten Fehler. Den Aufruf zu prüfen lohnt sich nur dort, wo der Aufruf selbst das gewünschte Verhalten ist, etwa “genau eine E-Mail wird verschickt”.

Auflösung

(a) Stub: Er liefert die drei vorbereiteten Clips, geprüft wird das Ergebnis. (b) Mock: Hier ist der Aufruf selbst das Verhalten, das geprüft werden soll. (c) Fake: ein vereinfachtes, aber funktionierendes In-Memory-Repository, das Speichern und Lesen wirklich umsetzt.

Tests brauchen Eingabedaten, und die Art, sie zu erzeugen, entscheidet über die Lesbarkeit der ganzen Suite.

Das Problem bei kopierten Testobjekten: Jeder Test schleppt alle Felder mit, auch die, die für ihn irrelevant sind, und beim kleinsten Modellwechsel müssen Dutzende Stellen angefasst werden. Die Lösung ist ein Builder (oder eine Fabrikfunktion) mit sinnvollen Vorgaben, der nur das Relevante überschreiben lässt:

function aClip(overrides: Partial<Clip> = {}): Clip {
return { id: 1, title: "untitled", durationSeconds: 60, ...overrides };
}
aClip(); // a valid default clip
aClip({ durationSeconds: 30 }); // same, but with the one field that matters here

Beispielprojekt: 19_teststrategien_1_examples/ex02_test_data_builder

flowchart LR
  accTitle: Der Builder verbindet gültige Vorgaben mit den wenigen Feldern, die ein Test überschreibt
  D["Vorgaben: id 1, title untitled, durationSeconds 60"] --> B["aClip(overrides)"]
  O["Test überschreibt nur: durationSeconds 30"] --> B
  B --> C["gültiger Clip für genau diesen Test"]

So steht in jedem Test nur, was für ihn wesentlich ist - aClip({ durationSeconds: 30 }) sagt “hier geht es um die Dauer” und verschweigt den Rest. Das ist zugleich Dokumentation: Der Test zeigt, welche Eigenschaft er tatsächlich prüft. Kommt im Modell ein neues Pflichtfeld dazu, änderst du nur die Vorgaben im Builder und nicht jeden einzelnen Test.

Genauer erklärt: Ein Test prüft, dass eine Bestellung “für heute” angelegt wird, und vergleicht dazu das Datum der Bestellung mit new Date(). Tagsüber ist er grün. Läuft die Pipeline aber knapp vor Mitternacht, entsteht die Bestellung am einen Tag und der Vergleich am nächsten, und der Test wird rot, ohne dass sich am Code etwas geändert hat. Reichst du stattdessen eine Uhr als Abhängigkeit herein und setzt im Test einen festen Zeitpunkt ein, liefert derselbe Test bei jedem Lauf dasselbe Ergebnis. Auch der Builder hält sich daran: Seine Vorgaben sind feste Werte, keine Zufallszahlen.

Auflösung

Bekommen zwei Objekte im selben Test zufällig dieselbe id, findet findById womöglich das falsche Objekt, und der Test schlägt nur manchmal fehl. Der Test ist damit flaky. Besser sind feste Vorgaben im Builder; wer eine andere id braucht, überschreibt sie ausdrücklich, etwa mit aClip({ id: 2 }).

  • Martin Fowler, TestPyramid - Ursprung des Pyramiden-Bilds und der “Eistüte” als Gegenbeispiel; kurz genug, um ihn vor der Übung ganz zu lesen.
  • Ham Vocke, The Practical Test Pyramid - zeigt an einem durchgehenden Projekt, welche Tests auf welche Ebene gehören; lohnt sich als Vorlage für die Pyramide eures Projekts in Aufgabe 13.
  • Martin Fowler, Mocks Aren’t Stubs - die feine, aber wichtige Unterscheidung zwischen Stub, Mock und Fake im Detail, mit den Folgen für das Testdesign.
  • Vitest-Dokumentation - das in diesem Kapitel verwendete Testwerkzeug, inklusive vi.fn() und weiterer Mocking-Funktionen; dort findest du die Befehle für deine eigene Suite.
  • Kapitel „Testen mit pytest” aus der 3. Klasse - die Anschlussreferenz für die dort bereits behandelte unterste Ebene, falls die Grundlagen von Unit-Tests wackeln.

Du kannst dir diesen Text mit der Vorlesefunktion deines Browsers oder Betriebssystems kostenlos anhören, etwa mit “Laut vorlesen” in Microsoft Edge. Das eignet sich gut zum Wiederholen vor der Schularbeit.

Lernergebnisse: Was du nach diesem Kapitel können solltest

Abschnitt betitelt „Lernergebnisse: Was du nach diesem Kapitel können solltest“
  • Erklären: die Testebenen Unit, Integration und End-to-End unterscheiden und ihre jeweilige Aussage benennen.
  • Erklären: die Testpyramide mit Kosten, Geschwindigkeit und Aussagekraft begründen und die Fehlform “Eistüte” erkennen.
  • Beurteilen: für einen Anwendungsteil die passende Testebene und ein begründetes Werkzeug wählen.
  • Anwenden: Abhängigkeiten mit Stubs, Mocks oder Fakes isolieren und entscheiden, was mit echten Objekten und was an den Rändern ersetzt getestet wird.
  • Anwenden: Testdaten mit einem Builder erzeugen und Flakiness durch Ausschalten von Zufall und Zeit vermeiden.
  • Aufgabe 13 - Jahresprojekt Sprint 4 - Testebenen und Testpyramide: Kata: sechs Tests den Ebenen zuordnen und einen Service mit einem Stub testen, dazu das Planning von Sprint 4 (Wesentlich_überwiegend); Ist-Pyramide des Projekts, Unit-Tests der Fachlogik mit Testdaten-Builder in der Pipeline (Wesentlich_vollständig). PlusPlus: Stub gegen Mock und ein Integrationstest; MandalorianPlus: Uhr und Zufall als Abhängigkeit, Soll-Pyramide.

GAM

CC-BY-SA

https://qiz.at/rules