Der Spickzettel zum Testhandwerk aus dem Seminar Java Testing mit JUnit, Mockito und Testcontainers — Artefaktnamen, Annotationen und Konfigurations schlüssel zum Nachschlagen, wenn man weiß, dass es geht, aber nicht mehr genau wie. Für Attrappen und Container gibt es ein eigenes Blatt: Mockito & Testcontainers. Maßgeblich und immer aktuell ist das JUnit 5/6 User Guide; hier steht die Auswahl, die im Alltag tatsächlich gebraucht wird.
Testebenen und was sie kosten
Zwei Blicke auf dieselbe Entscheidung: Was findet eine Ebene — und was verlangt sie dafür.
| Ebene | Findet | Findet nicht | Laufzeit | Pflege |
|---|---|---|---|---|
| Unit | Rechenfehler, Grenzfälle | falsche Verdrahtung | Millisekunden | mittel |
| Komponente | Vertragsbrüche zwischen Klassen | echtes SQL | Zehntelsekunden | gering |
| Integration | Schema, Mapping, Transaktionen | Zusammenspiel der Dienste | Sekunden | mittel |
| End-to-End | den kaputten Gesamtweg | die Ursache | Minuten | hoch |
Ein Fehler, der auf zwei Ebenen geprüft wird, kostet doppelt und meldet sich doppelt. Und Pflegeaufwand schlägt Laufzeit: Ein Test, den niemand mehr repariert, wird abgeschaltet.
Quer dazu läuft eine zweite Trennung, die im Alltag mehr trägt als jede Pyramide: fachliche Testfälle kommen aus der Domäne und überleben jedes Refactoring, technische kommen aus der Umsetzung und sterben mit ihr. Wer beide vermischt, kann bei rotem Test nicht sagen, ob die Regel oder der Code falsch war.
Modul: Eine tragfähige Teststrategie entwickeln
Artefakte und Build-Einbindung
| Artefakt | Gruppe | Rolle |
|---|---|---|
| junit-bom | org.junit | hält alle Versionen konsistent |
| junit-jupiter | org.junit.jupiter | API und Engine als Bündel |
| junit-jupiter-params | org.junit.jupiter | parametrisierte Tests |
| junit-platform-launcher | org.junit.platform | Start aus Build und IDE |
| junit-vintage-engine | org.junit.vintage | führt JUnit-4-Tests aus |
In JUnit 6 trägt nur die BOM die Gruppe org.junit, die Komponenten
behalten ihre eigenen Gruppen. Baseline ist Java 17.
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Fehlt der Launcher, meldet Gradle lapidar, es gebe keine Tests.
Konfiguration statt Code
| Parameter | Wirkung |
|---|---|
| junit.jupiter.execution.parallel.enabled | schaltet parallele Ausführung frei |
| junit.jupiter.testinstance.lifecycle.default | eine Instanz je Test oder je Klasse |
| junit.jupiter.displayname.generator.default | Regel für die angezeigten Namen |
| junit.jupiter.extensions.autodetection.enabled | Extensions über ServiceLoader laden |
Die Parameter stehen in junit-platform.properties im Testklassenpfad —
liegt die Datei im Hauptquellbaum, wirkt sie nicht.
Modul: JUnit 6 verstehen und einrichten
Von JUnit 4 nach Jupiter
| Thema | JUnit 4 | Jupiter |
|---|---|---|
| Vor jedem Test | @Before | @BeforeEach |
| Erwartete Exception | @Test(expected = …) | assertThrows |
| Erweiterung | Runner und Rule | Extension |
| Ignorieren | @Ignore | @Disabled |
| Parametrisierung | Runner mit Parametern | @ParameterizedTest |
Die Umbenennungen erledigt ein Rezept der Migrationswerkzeuge, die Extensions bleiben Handarbeit. Vintage ist eine Brücke, kein Ufer: Rules und Runner lassen sich nicht mit Extensions mischen, und solange Vintage läuft, gibt es zwei Programmiermodelle im selben Projekt.
Modul: JUnit 6 verstehen und einrichten
Assertions, die etwas aussagen
| Prüfung | Werkzeug |
|---|---|
| Ein Wert | assertEquals, erwarteter Wert zuerst |
| Mehrere Werte eines Objekts | assertAll mit einem Namen für die Gruppe |
| Erwartete Ausnahme | assertThrows, danach die Meldung prüfen |
| Laufzeitgrenze | assertTimeout, nicht ein eigener Zeitstempel |
assertAll("Bestellung",
() -> assertEquals(OFFEN, bestellung.status()),
() -> assertEquals(3, bestellung.zeilen().size()));
var fehler = assertThrows(LieferfensterFehler.class,
() -> bestellung.liefereAm(SONNTAG));
assertEquals("Sonntag wird nicht geliefert", fehler.getMessage());
assertThrows gibt die Ausnahme zurück — erst damit lässt sich die Meldung
prüfen. Und die Reihenfolge zählt: erwarteter Wert links, tatsächlicher rechts,
sonst steht die Meldung im Bericht auf dem Kopf.
Der Unterschied wird sichtbar, wenn ein Test nach einem Jahr das erste Mal bricht:
| Schreibweise | Meldung im Fehlerfall |
|---|---|
| assertTrue mit Vergleich | erwartet true, war false |
| assertEquals | erwartet 42.90, war 39.90 |
| assertThat mit AssertJ | erwartet 42.90, war 39.90, Abweichung 3.00 |
assertThat(bestellung.zeilen())
.hasSize(3)
.extracting(Zeile::artikel)
.containsExactly("Apfelkiste", "Honig", "Möhren");
assertThat(bestellung.gesamt()).isEqualByComparingTo(euro("42.90"));
Bei Beträgen isEqualByComparingTo statt isEqualTo — sonst vergleicht der
Test die Skalierung statt den Wert.
Testnamen und Testschnitt
| Nach Methode geschnitten | Nach Verhalten geschnitten |
|---|---|
| rechneTest1, rechneTest2 | staffelpreisAbZehnKisten |
| bricht bei jeder Umbenennung | überlebt das Refactoring |
| prüft, was der Code tut | prüft, was gelten soll |
| wächst mit der Klasse | wächst mit der Fachlichkeit |
@DisplayName darf Umlaute, Leerzeichen und ganze Sätze enthalten — der Name
ist die halbe Fehlermeldung, und der Bericht wird dadurch dem Fachbereich
zeigbar.
Modul: Verständliche Unit-Tests schreiben
Parametrisierte Tests und Testdaten
| Quelle | Passt für |
|---|---|
| @ValueSource | eine einzelne Reihe einfacher Werte |
| @CsvSource | mehrere Spalten, direkt im Test lesbar |
| @CsvFileSource | größere Datensätze aus einer Datei |
| @MethodSource | Argumente, die berechnet oder Objekte sind |
| @FieldSource | vorbereitete Listen in einem statischen Feld |
| @EnumSource | alle oder ausgewählte Konstanten einer Enum |
@ParameterizedTest(name = "{0} Kisten je {1} Euro")
@CsvSource({ "1, 18.00", "9, 18.00", "10, 17.10", "50, 16.20" })
void staffelpreisJeKiste(int menge, BigDecimal preis) { … }
Der name-Platzhalter macht aus vier Läufen vier lesbare Zeilen im Bericht —
ohne ihn steht dort nur der Index. Objekte gehören in @MethodSource, nicht in
@CsvSource mit einem Converter, der die Hälfte errät; für lange Signaturen
gibt es ArgumentConverter, ArgumentsAggregator und ArgumentsAccessor.
| Bauart | Fälle stehen fest | Passt für |
|---|---|---|
| @ParameterizedTest | beim Schreiben | bekannte Datenreihen |
| @ParameterizedClass | beim Schreiben | dieselbe Klasse je Konstellation |
| @TestFactory | zur Laufzeit | Fälle aus Datei oder Datenbank |
Dynamische Tests sind mächtig und unübersichtlich — die letzte Wahl, nicht die erste.
Für Grenzfälle gilt: Jede Regel ergibt zwei Fälle — den letzten ungültigen und den ersten gültigen Wert. Die Mitte einer Äquivalenzklasse zu testen kostet Zeit und findet nichts.
| Regel | Letzter ungültiger Wert | Erster gültiger Wert |
|---|---|---|
| Mindestbestellwert | 24.99 Euro | 25.00 Euro |
| Staffelpreis | 9 Kisten | 10 Kisten |
| Vorlauf Lieferung | heute | morgen |
| Gutscheinlaufzeit | Vortag des Starts | Starttag |
Dazu gehören null und leer — sie gelten oft als Sonderfall und tauchen dann in
keinem Test auf.
@Nested gruppiert nach Vorbedingung, nicht nach Codestruktur: Der
Klassenname wird zur Bedingung, der Methodenname zur Erwartung.
Modul: Testdaten und unterschiedliche Szenarien
Lifecycle und eingebaute Erweiterungen
| Annotation | Wirkung |
|---|---|
| @TempDir | legt ein temporäres Verzeichnis an und räumt es weg |
| @AutoClose | schließt eine Ressource im Feld nach dem Test |
| @DefaultLocale | setzt die Standardsprache für die Testdauer |
| @DefaultTimeZone | setzt die Standardzeitzone für die Testdauer |
| @SetSystemProperty | setzt eine Systemeigenschaft und stellt sie zurück |
| @ClearSystemProperty | entfernt eine Systemeigenschaft für die Testdauer |
Locale, Zeitzone und Systemeigenschaften kamen mit JUnit 6.1 dazu — vorher brauchte es eine Fremdbibliothek. Ohne sie entscheidet der Rechner des Entwicklers über das Ergebnis.
@Test
@DefaultLocale("de-DE")
@DefaultTimeZone("Europe/Berlin")
void rechnungMitDeutschemDatum(@TempDir Path ordner) { … }
Bedingte Ausführung über @EnabledOnOs und @EnabledOnJre ist ehrlicher als
ein auskommentierter Test: Ein übersprungener Test steht im Bericht, ein
auskommentierter verschwindet. Eigene Bedingungen entstehen als
ExecutionCondition, nicht als if im Testcode.
Eine eigene Extension implementiert die passende Schnittstelle — für einen
Testparameter etwa ParameterResolver mit supportsParameter und
resolveParameter, eingebunden über @ExtendWith(FesteUhr.class). Zustand
gehört dabei in den ExtensionContext.Store, nie in ein Feld: Ein Feld wird
bei paralleler Ausführung geteilt. Was im Store liegt und AutoCloseable ist,
räumt JUnit selbst wieder ab.
Parallele Ausführung
| Parameter | Werte |
|---|---|
| junit.jupiter.execution.parallel.enabled | true, false |
| junit.jupiter.execution.parallel.mode.default | concurrent, same_thread |
| junit.jupiter.execution.parallel.config.strategy | dynamic, fixed, custom |
| junit.jupiter.execution.parallel.config.executor-service | fork_join_pool, worker_thread_pool |
Der Worker-Thread-Pool kam mit JUnit 6.1 dazu und gilt noch als experimentell.
Am Test steuern statt global: @Execution(ExecutionMode.CONCURRENT),
@ResourceLock(Resources.SYSTEM_PROPERTIES) für geteilte Ressourcen und
@Isolated als grobe Kelle — die Klasse läuft allein, alles andere wartet.
Modul: Lifecycle, Extensions und parallele Ausführung
Typische Fallen
- Jeden neuen Fehler auf der Ebene testen, auf der er zufällig auffiel.
- Die Pyramide als Quote verordnen, ohne das Risiko der Anwendung anzusehen.
- Langsame Tests aus der Pipeline nehmen, statt sie zu beschleunigen.
- Jede Abhängigkeit mit eigener Version führen — dann steht die BOM zur Zierde.
- Vintage dauerhaft drinlassen, obwohl nur noch drei JUnit-4-Tests existieren.
- Tags vergeben, ohne dass ein Build danach filtert.
- assertTrue auf einen Vergleich setzen — die Meldung sagt dann nur „war false”.
- Erwarteten und tatsächlichen Wert vertauschen und die Meldung umdrehen.
- assertThrows um drei Zeilen legen, wenn die Ausnahme aus der ersten kam.
- AssertJ und Jupiter-Assertions mischen in derselben Klasse.
- Beträge als double prüfen und an der Rundung scheitern.
- @BeforeAll nicht statisch deklarieren, ohne den Lifecycle umzustellen.
- PER_CLASS wählen, um Zustand zu sparen — und Tests beeinflussen sich.
- Zustand im Feld einer Extension halten; das funktioniert nur seriell.
- Parallelität einschalten und die Flaky-Analyse teurer bezahlen als die gesparten zehn Prozent Laufzeit.
Zum Seminar Java Testing mit JUnit, Mockito und Testcontainers