Important
🎯 TL;DR
Approval Testing hilft Ihnen, komplexe Ausgaben wie Reports, HTML oder
JSON praktikabel zu testen. Statt im Test einen riesigen
expected-String manuell zusammenzubauen und zu pflegen, frieren Sie
einmal einen geprüften Output als "approved" (in einer Textdatei) ein
und lassen künftige Testläufe automatisch dagegen vergleichen.
Änderungen sehen Sie als Diff im Repository und können bewusst
entscheiden, ob das Verhalten gewollt angepasst wurde oder ein Fehler
vorliegt - ideal auch als Sicherheitsnetz vor und nach Refactorings.
Property-Based Testing (PTB) ergänzt klassische Beispieltests, indem es allgemeine Eigenschaften ("Für alle Eingaben aus diesem Bereich muss X gelten") über viele automatisch generierte Eingaben prüft. Die Äquivalenzklassenanalyse dient dabei als zentrales Denkwerkzeug: Sie strukturieren damit den Eingaberaum und formulieren daraus Properties für jqwik.
Während die Idee von Approval Testing sehr überschaubar ist, können wir nur einen allerersten Blick in das Thema Property-Based Testing werfen.
- Bisher: klassische Unit-Tests mit JUnit (
@Test,assertEquals, ...) - Problem 1: Komplexe/umfangreiche Outputs sind schwer "per Hand" zu vergleichen
- Problem 2: Einzelne Beispieltests decken nur wenige Eingaben ab
- Idee: Zwei Ergänzungen
- Approval Testing
- Property-Based Testing (PBT)
In klassischen Tests formulieren wir für ein paar ausgewählte Beispiele "Eingabe X -> erwartete Ausgabe Y". Das ist gut für Dokumentation und einfache Funktionen, stößt aber an Grenzen, wenn Outputs lang/komplex sind oder wenn wir viele verschiedene Eingaben testen wollen. Approval Testing hilft beim Umgang mit komplexen Ausgaben, Property-Based Testing beim automatisierten Durchprobieren vieler Eingaben anhand allgemeiner Eigenschaften.
@Test
void report_looks_as_expected() {
// given
List<Order> orders = List.of(...); // !!!!
String expected = "..."; // ???!
// when
String report = OrderReportGenerator.generateReport(orders);
// then
assertEquals(expected, report);
}- Grundidee:
- Einmal gültigen Output als "Goldstandard" abspeichern
(
approved) - Zukünftig generierten Output automatisch dagegen vergleichen
- Einmal gültigen Output als "Goldstandard" abspeichern
(
- Gut geeignet für:
- Reports, Tabellen, formatierten Text
- HTML, JSON, Logs, Konsolen-Output
- Typischer Einsatz:
- Refactoring ohne Verhaltensänderung absichern
Beim Approval Testing wird nicht im Test ein erwarteter String manuell zusammengebaut. Stattdessen prüft man einmalig einen generierten Output, "segnet ihn ab" und speichert ihn als "approved" in einem String oder einer Datei. Künftige Testläufe erzeugen einen "received"-Output und vergleichen ihn mit der "approved"-Version. Unterschiede fallen als Diffs auf. Besonders beim Refactoring ist das hilfreich: Wenn sich der Output nicht ändert, war das Refactoring verhaltensgleich.
Produktionscode: Textreport aus Bestellungen erzeugen:
public record Order(String id, String customer, List<String> items) {}
public class OrderReportGenerator {
public static String generateReport(List<Order> orders) {
var sb = new StringBuilder();
sb.append("=== ORDER REPORT ===\n");
orders.forEach( o -> {
sb.append("Order: ").append(o.id()).append("\n");
sb.append("Customer: ").append(o.customer()).append("\n");
sb.append("Items:\n");
o.items().forEach(item -> sb.append(" - ").append(item).append("\n"));
sb.append("\n");
});
sb.append("Total orders: ").append(orders.size()).append("\n");
return sb.toString();
}
}Approval-Test:
- Test laufen lassen und Vergleich mit leerem String -> rot
- Ausgabe von JUnit prüfen und als Erwartung übernehmen -> grün
Erster Lauf mit leerem "expected"-String:
class OrderReportGeneratorTest {
@Test
void report_looks_as_expected() {
// given
List<Order> orders = List.of(
new Order("A-001", "Alice", List.of("Keyboard", "Mouse")),
new Order("B-002", "Bob", List.of("Laptop")),
new Order("C-003", "Charlie", List.of("HDMI Cable", "USB Hub")));
// 1. Test absichtlich fehlschlagen lassen, 2. Ausgabe übernehmen
String expected = "..."; // ???!
// when
String report = OrderReportGenerator.generateReport(orders);
// then
assertEquals(expected, report);
}
}Nach dem ersten Lauf Ergänzen des "expected"-Strings zu:
class OrderReportGeneratorTest {
@Test
void report_looks_as_expected() {
// given
List<Order> orders = List.of(
new Order("A-001", "Alice", List.of("Keyboard", "Mouse")),
new Order("B-002", "Bob", List.of("Laptop")),
new Order("C-003", "Charlie", List.of("HDMI Cable", "USB Hub")));
String expected = """
=== ORDER REPORT ===
Order: A-001
Customer: Alice
Items:
- Keyboard
- Mouse
Order: B-002
Customer: Bob
Items:
- Laptop
Order: C-003
Customer: Charlie
Items:
- HDMI Cable
- USB Hub
Total orders: 3
""";
// when
String report = OrderReportGenerator.generateReport(orders);
// then
assertEquals(expected, report);
}
}Java-Bibliothek ApprovalTests.Java zur Unterstützung von Approval Tests
- vergleicht gesamten Report-String mit
*.approved-Datei - kein manuelles Zusammensetzen eines langen "expected"-Strings nötig
Einbindung in Gradle:
dependencies {
testImplementation 'com.approvaltests:approvaltests:30.1.1'
}Assert im Test über den Aufruf Approvals.verify(actual);:
- Legt die Ausgabe
actualin einer Datei im Build-Ordner an:<TESTCLASS>.<TESTMETHOD>.received.txt - Inhalt einmalig manuell prüfen
- Wenn ok: Datei verschieben in Testordner (parallel zur Test-Klasse):
<TESTCLASS>.<TESTMETHOD>.approved.txt - Diese "*.approved.txt"-Datei mit in Git einchecken
- Folgende Testläufe vergleichen Output mit der "*.approved.txt"-Datei
class OrderReportGeneratorTest {
@Test
void report_looks_as_expected() {
// given
List<Order> orders = List.of(
new Order("A-001", "Alice", List.of("Keyboard", "Mouse")),
new Order("B-002", "Bob", List.of("Laptop")),
new Order("C-003", "Charlie", List.of("HDMI Cable", "USB Hub")));
// when
String report = OrderReportGenerator.generateReport(orders);
// then
// ApprovalTests.Java: vergleicht gegen <TESTCLASS>.<TESTMETHOD>.approved.txt
Approvals.verify(report);
}
}Der Approval-Test ruft die normale Produktionsmethode auf, erzeugt einen
kompletten Report und übergibt diesen an Approvals.verify(report).
Beim ersten Lauf wird eine "received"-Datei erzeugt, das Sie manuell
überprüfen und (falls ok) als "approved"-Datei übernehmen. Die
Namenskonvention ist <TESTCLASS>.<TESTMETHOD>.approved.txt und muss
strikt eingehalten werden. Die "approved"-Datei muss "neben" der
Test-Klasse liegen, also im selben Ordner. Spätere Testläufe vergleichen
dann den aktuellen Output mit der "approved"-Version; bei Änderungen
zeigt ein Diff genau, was sich im Verhalten geändert hat. So müssen Sie
keinen langen expected-String im Test pflegen.
Tip
Beachten Sie die Ausgabe in der Konsole bei der Ausführung des Tests -
dort steht, wo Sie die zu prüfende Ausgabedatei
<TESTCLASS>.<TESTMETHOD>.received.txt finden!
- Approved-Dateien werden im Repository versioniert
- Diffs der Approved-Files zeigen Verhaltensänderungen im Code-Review
- Workflow bei legitimer Änderung:
- Test schlägt fehl -> Diff prüfen -> neue Version als "approved" übernehmen
Da die "approved"-Ausgabedateien im Versionskontrollsystem liegen, können Sie bei jedem Pull-Request sehen, wie genau sich der Output geändert hat. Das ist besonders wertvoll, wenn formatiertes Text- oder HTML-Output betroffen ist. Wenn sich eine Änderung als gewollt herausstellt, aktualisieren Sie einfach die Approved-Datei und dokumentieren damit die neue, korrekte Ausgabe.
- Klassischer Test:
- "Für Eingabe X erwarte ich Ergebnis Y"
- Property-Based Test:
- "Für alle Eingaben mit Eigenschaft E muss Eigenschaft P gelten"
- Ein Framework (z.B. jqwik) generiert viele Eingaben automatisch
- Fokus: Allgemeine Eigenschaften / Invarianten statt einzelner Beispiele
Beim Property-Based Testing überlegen Sie nicht mehr nur konkrete Beispielwerte, sondern formulieren allgemeine Gesetze. Zum Beispiel: "Sortieren liefert immer eine aufsteigend geordnete Liste mit denselben Elementen" oder "Die Steuer steigt nie, wenn das Einkommen sinkt". jqwik erzeugt dann viele unterschiedliche Testdaten, inklusive Grenzfällen, und versucht aktiv, Gegenbeispiele zu finden. So werden mehr Teile des Eingaberaums automatisch abgedeckt.
- Äquivalenzklassenanalyse:
- Eingaberaum in Bereiche mit ähnlichem Verhalten aufteilen
- Je Klasse einige repräsentative Testfälle + Grenzwerte wählen
- Property-Based Testing (PBT):
- Nutzt dieselben Bereiche/Eigenschaften
- Aber: Framework erzeugt viele Werte innerhalb der Bereiche
- Kein "doppelt genäht", sondern:
- Äquivalenzklassen = Denkwerkzeug
- PBT = automatisierte Stichproben über diese Klassen
Sie kennen bereits die Idee, den Eingaberaum in Äquivalenzklassen aufzuteilen und pro Klasse typische sowie Grenzfälle zu testen. Property-Based Testing (PBT) baut genau darauf auf: Sie formulieren Eigenschaften, die für ganze Klassen (z.B. "Einkommen zwischen 10k und 20k") gelten müssen, und überlassen dem Framework die Auswahl vieler konkreter Werte in diesen Bereichen. Das Denken in Bereichen und Grenzwerten bleibt, wird aber automatisiert "ausgerollt".
- Funktion:
calculateTax(int income)- Einkommen in ganzen Euro (
income >= 0) - Rückgabe: Steuerbetrag (ganze Euro, abgerundet)
- Einkommen in ganzen Euro (
- Steuerregeln (vereinfacht):
- < 10 000: 0 %
- 10 000 - 20 000: 10 % auf den Teil über 10 000
- 20 000 - 50 000: zusätzlich 20 % auf den Teil über 20 000
- > 50 000: zusätzlich 30 % auf den Teil über 50 000
- Negative Einkünfte:
IllegalArgumentException
public class TaxCalculator {
public static int calculateTax(int income) {
if (income < 0) throw new IllegalArgumentException("Income must be >= 0");
double tax = 0.0;
if (income <= 10_000) tax = 0.0;
else if (income <= 20_000) tax = 0.10 * (income - 10_000);
else if (income <= 50_000) tax = 0.10 * 10_000 + 0.20 * (income - 20_000);
else tax = 0.10 * 10_000 + 0.20 * 30_000 + 0.30 * (income - 50_000);
return (int) Math.floor(tax);
}
}- Äquivalenzklassen:
- E1:
income < 0(ungültig) - E2:
0 <= income < 10000 - E3:
10000 <= income <= 20000 - E4:
20000 < income <= 50000 - E5:
income > 50000
- E1:
- Wichtige Grenzwerte:
- Rund um 0:
-1,0,1 - Rund um 10000:
9999,10000,10001 - Rund um 20000:
19999,20000,20001 - Rund um 50000:
49999,50000,50001
- Rund um 0:
Jede Äquivalenzklasse beschreibt Bereiche, in denen die Steuerfunktion dasselbe Rechenmuster nutzt. Um diese Klassen herum identifizieren wir Grenzwerte, an denen das Verhalten wechselt (z.B. 9999 vs. 10000). Das ist die klassische Vorbereitung für systematische Tests - und gleichzeitig eine gute Basis für Properties.
- Für jede Äquivalenzklasse einige repräsentative Werte testen
- Zusätzlich Grenzfälle explizit prüfen
- Negative Eingaben: Exception erwarten
// E1: Ungültige Eingabe
@Test
void negative_income_throws_exception() {
assertThrows(IllegalArgumentException.class, () -> TaxCalculator.calculateTax(-1));
}
// E2: 0 <= income < 10_000
@Test
void income_below_10k_is_tax_free() {
assertEquals(0, TaxCalculator.calculateTax(0));
assertEquals(0, TaxCalculator.calculateTax(5_000));
assertEquals(0, TaxCalculator.calculateTax(9_999));
}
// ...Diese klassischen Tests setzen direkt die Äquivalenzklassenanalyse um: Für jede Klasse und jeden Grenzbereich wählen wir einige typische Einkünfte und erwarten einen exakt berechneten Steuerbetrag. Diese Tests dokumentieren das Verhalten in verständlichen Beispielen.
Ziel-Eigenschaft: Für alle gültigen Einkommen gilt: Steuerbetrag
Nutzung von jqwik:
-
Einbindung in Gradle:
dependencies { testImplementation 'net.jqwik:jqwik:1.10.1' } -
Testfall: Annotation
@Propertystatt@Test -
Generieren von Werten: Annotation
@ForAllan Parametern generiert viele ganzzahlige Einkommen
class TaxCalculatorProperties {
@Property
void tax_is_never_negative(@ForAll @IntRange(min = 0, max = 1_000_000) int income) {
int tax = TaxCalculator.calculateTax(income);
assertTrue(tax >= 0);
}
}Hier formulieren wir eine sehr allgemeine Eigenschaft unserer
Steuerberechnung: Bei keinem gültigen Einkommen darf eine negative
Steuer herauskommen. Die Java-Bibliothek jqwik generiert viele Einkommen
im Bereich 0 bis 1000000 und wendet die Funktion darauf an. Mit der
einfachen JUnit-Assertion assertTrue(tax >= 0) prüfen wir die
Eigenschaft wie gewohnt.
Der Test beschreibt nicht konkrete Zahlen, sondern ein generelles Gesetz.
- Intuition: Wenn Einkommen steigt, darf Steuer nicht sinken
- Formale Property: Für alle
a,bmita <= bgilt:tax(a) <= tax(b)
@Property
void tax_is_monotone_non_decreasing(
@ForAll @IntRange(min = 0, max = 1_000_000) int a,
@ForAll @IntRange(min = 0, max = 1_000_000) int b) {
int lower = Math.min(a, b);
int higher = Math.max(a, b);
int taxLower = TaxCalculator.calculateTax(lower);
int taxHigher = TaxCalculator.calculateTax(higher);
assertTrue(taxLower <= taxHigher);
}Diese Property fasst eine wesentliche Anforderung an ein Steuersystem
zusammen: Wer mehr verdient, soll nicht weniger Steuer zahlen. jqwik
erzeugt viele Zahlenpaare, die wir im Test sortieren (lower, higher)
und für die wir jeweils die entsprechenden Steuerbeträge vergleichen.
Ein Verstoß gegen diese Monotonie würde auf inkonsistente Steuerregeln oder Implementierungsfehler hinweisen - und jqwik würde automatisch ein Gegenbeispiel liefern.
- Idee: Innerhalb einer Steuerklasse (z.B. 10000 - 20000) gilt ein linearer Zusammenhang
- Property-Beispiel: Innerhalb 10000 - 20000 steigt die Steuer mit 10 % der Einkommensdifferenz
@Property
void within_bracket_10k_to_20k_tax_grows_with_roughly_10_percent(
@ForAll @IntRange(min = 10_000, max = 19_000) int base,
@ForAll @IntRange(min = 0, max = 1_000) int delta) {
int income1 = base; int income2 = base + delta;
if (income2 > 20_000) income2 = 20_000;
int tax1 = TaxCalculator.calculateTax(income1);
int tax2 = TaxCalculator.calculateTax(income2);
int diffIncome = income2 - income1;
int diffTax = tax2 - tax1;
int expected = (int) Math.floor(0.10 * diffIncome);
// Wegen Rundung erlauben wir +/- 1 Euro Toleranz
assertTrue(diffTax >= expected - 1 && diffTax <= expected + 1);
}In dieser Property verbinden wir explizit die Äquivalenzklasse
"Einkommen zwischen 10000 und 20000" mit einer Eigenschaft (Property):
In diesem Bereich gilt eine konstante Steuerquote von 10 %. Die Eingaben
werden von jqwik auf diesen Bereich beschränkt, und wir prüfen, ob die
Steuerdifferenz 10 % der Einkommensdifferenz entspricht. Weil in der
Implementierung mit double gearbeitet und mit Math.floor gerundet
wird, kann es zu Abweichungen um 1 Euro kommen, was durch eine kleine
Toleranz berücksichtigt wird.
- Klassische Unit-Tests:
- Dokumentieren Verhalten an Beispielen
- Nutzen Äquivalenzklassen & Grenzwerte manuell
- Approval Testing:
- Erleichtert Tests komplexer Outputs ("Goldstandard")
- Passt gut zu Refactoring & Code-Review
- Property-Based Testing:
- Formuliert allgemeine Eigenschaften über alle Eingaben
- Nutzt Äquivalenzklassen als Denkgrundlage, generiert aber viele Fälle automatisch
Für die Praxis bedeutet das: Sie starten bei der gedanklichen Strukturierung mit Äquivalenzklassenanalyse, schreiben daraus ein paar klassische JUnit-Tests zur Dokumentation und Verständlichkeit und ergänzen diese dann durch Properties, die allgemeine Invarianten absichern. Wenn zusätzlich komplexe Text- oder Datenstrukturen zu testen sind, kann Approval Testing helfen, Outputs als referenzierte Goldstandards zu verwalten. Alle drei Techniken ergänzen sich und helfen Ihnen, robusteren und besser geprüften Code zu schreiben.
Das Thema Property-Based Testing geht sehr tief, wir schaffen hier nur einen ersten Einblick in die Grundideen. Frameworks wie jqwik suchen beispielsweise bei der Ausführung nach "kleinen Gegenbeispiele" (sogenanntes Shrinking), d.h. es wir haben hier nicht einfach nur eine Spielart von "Random Testing".
Tip
Sie finden den Beispielcode in einem vorkonfigurierten Gradle-Projekt im Ordner ptb.
Tip
📖 Zum Nachlesen
Approval Testing:
- Schönes Video: https://youtu.be/YAXGU2J7XjM (ab 17:12 Approval testing ft. Emily Bache)
- Bibliothek ApprovalTests.Java
- ApprovalTests Getting Started
Property based Testing:
- Schönes Video (letzter Teil): https://youtu.be/MWsk1h8pv2Q (ab 37:33 FizzBuzz)
- Blog von Johannes Link (Maintainer von jqwik): Property-based Testing in Java: Jqwik - a JUnit 5 Test Engine
- Doku vom jqwik-Projekt
Note
✅ Lernziele
- k2: Ich kann das Grundprinzip von Approval Testing an einem selbst gewählten Beispiel erklären (Goldstandard/"approved"-Output, Vergleich mit "received"-Output, Rolle von Diff-Ansichten).
- k2: Ich kann den Unterschied zwischen klassischen Beispieltests und Property-Based Testing in eigenen Worten erläutern (Beispiele vs. allgemeine Eigenschaften über viele Eingaben).
- k2: Ich kann erklären, wie Äquivalenzklassenanalyse die Auswahl von Testfällen strukturiert und warum sie auch für Property-Based Testing wichtig ist.
- k2: Ich kann typische Einsatzszenarien für Approval Testing und Property-Based Testing nennen und begründen, warum diese Techniken dort sinnvoll sind.
- k3: Ich kann für eine gegebene Funktion sinnvolle Äquivalenzklassen formulieren und daraus konkrete JUnit-Beispieltests ableiten.
- k3: Ich kann für eine Funktion mit komplexem String-Output (z.B. Report oder formatierte Liste) einen einfachen Approval-Test schreiben und den Approved-Output im Projekt verwalten.
- k3: Ich kann für eine gegebene Funktion mindestens eine geeignete
Property formulieren und diese mit jqwik als
@Property-Test umsetzen. - k3: Ich kann in einem bestehenden Test-Set begründet entscheiden, ob Beispieltests, Approval Tests oder Property-Based Tests (oder eine Kombination) sinnvoll sind, und dies kurz begründen.
Unless otherwise noted, this work is licensed under CC BY-SA 4.0.
Last modified: a8d89ce 2026-06-09 junit4: add new screencast
