Der Spickzettel zu Attrappen und echter Infrastruktur aus dem Seminar Java Testing mit JUnit, Mockito und Testcontainers. Das Testhandwerk selbst — Assertions, parametrisierte Tests, Lifecycle — steht auf dem zweiten Blatt: JUnit 6. Maßgeblich sind die Mockito-Dokumentation und die Testcontainers-Dokumentation; hier steht die Auswahl, die im Alltag tatsächlich gebraucht wird.
Test Doubles unterscheiden
| Double | Liefert | Passt für |
|---|---|---|
| Dummy | nichts | Pflichtparameter ohne Bedeutung |
| Stub | feste Antworten | ein Lesezugriff im Test |
| Fake | echtes Verhalten | wiederkehrende Nutzung über viele Tests |
| Spy | echtes Verhalten plus Protokoll | Altcode ohne saubere Schnittstelle |
| Mock | Antworten plus Prüfung | Aufruf ist selbst das erwartete Ergebnis |
Wer nicht sagen kann, welche der fünf Zeilen gemeint ist, braucht die Attrappe wahrscheinlich nicht. Und ein Stub, der verifiziert wird, ist still zum Mock geworden — der Test prüft dann plötzlich Interna.
Modul: Test Doubles und Mockito-Grundlagen
Mockito-Annotationen
| Annotation | Bedeutung |
|---|---|
| @Mock | eine Attrappe, alle Methoden liefern Standardwerte |
| @Spy | ein echtes Objekt, dessen Aufrufe protokolliert werden |
| @Captor | ein vorbereiteter ArgumentCaptor für einen Typ |
| @InjectMocks | der Prüfling, in den die Mocks eingesetzt werden |
@ExtendWith(MockitoExtension.class)
class BestellServiceTest {
@Mock ZahlwerkClient zahlwerk;
@Mock LagerBestand lager;
@InjectMocks BestellService service;
}
Ohne die Extension bleiben alle vier null — dann fehlt entweder @ExtendWith
oder das Artefakt mockito-junit-jupiter. Ein nicht gestubbter Aufruf liefert
null, 0 oder eine leere Sammlung; der Test läuft weiter und scheitert drei
Zeilen später an einer NullPointerException.
Stubbing
when(zahlwerk.belaste(any(), eq(euro("42.90"))))
.thenReturn(Zahlung.erfolgreich("KG-4711"));
when(zahlwerk.status("KG-4711")).thenReturn(OFFEN).thenReturn(GEBUCHT);
doThrow(new LagerLeer()).when(lager).reserviere(HONIG);
Zwei thenReturn hintereinander beantworten den ersten und den zweiten Aufruf
verschieden. Argument Matcher sind alles oder nichts: Sobald ein Argument
ein Matcher ist, müssen es alle sein — ein fester Wert wird dann mit eq
umschlossen. Je genauer der Matcher, desto genauer sagt der rote Test, was sich
geändert hat.
In BDD-Schreibweise heißt dasselbe given(…).willReturn(…) und
then(mock).should().… — gleiche Wirkung, andere Lesart. Im Team eine
Schreibweise wählen und dabei bleiben.
| Fall | Mittel |
|---|---|
| Antwort hängt vom Argument ab | thenAnswer mit getArgument |
| Generischer Rückgabetyp | thenAnswer statt thenReturn mit Cast |
| Fertiges asynchrones Ergebnis | completedFuture im thenReturn |
| Nur einzelne Methoden ersetzen | Spy plus doReturn |
Bei einem Spy immer doReturn statt when — sonst läuft die echte Methode
schon beim Stubben. Ein Answer, der länger als drei Zeilen wird, ist meist ein
Fake, der noch keine Klasse hat. Asynchrones braucht keine Wartezeit im Test:
ein abgeschlossenes CompletableFuture zurückgeben, Callbacks im Answer direkt
aufrufen.
Module: Test Doubles und Mockito-Grundlagen · Fortgeschrittenes Stubbing
Verifikation
| Modus | Bedeutung |
|---|---|
| ohne Angabe | genau ein Aufruf |
| times(n) | genau n Aufrufe |
| never() | kein Aufruf |
| atLeast(n), atMost(n) | mindestens, höchstens n Aufrufe |
| only() | dieser Aufruf und kein anderer auf dem Mock |
verify(zahlwerk).belaste(KUNDE, euro("42.90"));
verify(zahlwerk, never()).storniere(any());
var reihe = inOrder(lager, zahlwerk);
reihe.verify(lager).reserviere(APFELKISTE);
reihe.verify(zahlwerk).belaste(any(), any());
times(1) ist nur die ausgeschriebene Form des Standardfalls. Die Reihenfolge
nur prüfen, wenn sie eine Regel ist — hier ist sie eine: Reserviert wird vor
der Belastung, sonst zahlt der Kunde für eine Kiste, die es nicht gibt.
Für Argumente, die erst im Prüfling entstehen:
| Situation | Mittel |
|---|---|
| Argument ist ein fester, bekannter Wert | direkt im verify angeben |
| Argument entsteht erst im Prüfling | ArgumentCaptor oder assertArg |
| Mehrere Aufrufe, alle Argumente prüfen | ArgumentCaptor mit getAllValues |
| Bedingung wiederholt sich in vielen Tests | eigener Matcher mit argThat |
verify(zahlwerk).belaste(assertArg(a ->
assertThat(a.referenz()).startsWith("KG-")));
assertArg (seit Mockito 5.3) prüft im Matcher und spart das Einfangen; bei
mehreren Aufrufen bleibt der Captor besser, denn getValue liefert nur den
letzten, getAllValues alle in Aufrufreihenfolge.
Maß halten: Verifiziert wird, was der Nutzer merken würde, wenn es ausbliebe.
| Aufruf | Verifizieren? |
|---|---|
| Belastung bei Zahlwerk ausgelöst | ja, das ist der Nebeneffekt |
| Preis beim Rechner abgefragt | nein, das Ergebnis zeigt es |
| Log-Ausgabe geschrieben | nein, das ist kein fachliches Verhalten |
| Reservierung vor Belastung | ja, wenn die Reihenfolge eine Regel ist |
Modul: Interaktionen und Zusammenarbeit prüfen
Statisches, Finales und Strictness
try (MockedStatic<Uhr> mock = mockStatic(Uhr.class)) {
mock.when(Uhr::jetzt).thenReturn(MITTAG);
assertThat(lieferfenster.offen()).isTrue();
}
Der Mock gilt nur im Block und nur im aktuellen Thread. Seit Java 21 dürfen
Bibliotheken keinen Java-Agenten mehr an die eigene JVM hängen: Der Inline Mock
Maker braucht Mockito als -javaagent auf der Kommandozeile, sonst warnt die
JVM bei jedem Testlauf. Auf Java 25 gehört der Eintrag zur Grundeinrichtung des
Builds — und in die Pipeline, nicht nur auf den Entwicklerrechner.
Wichtiger als das Wie ist meist das Warum:
| Wird gemockt | Meist die bessere Antwort |
|---|---|
| statische Uhr oder Zufallsquelle | als Abhängigkeit hineinreichen |
| Konstruktoraufruf im Prüfling | Fabrik oder Konstruktorparameter |
| finale Klasse aus der Fremdbibliothek | eigene Schnittstelle davor legen |
| Singleton oder Enum mit Verhalten | Verhalten in ein Objekt herausziehen |
Alle vier gehen mit Mockito. Alle vier sind zugleich ein Befund über die Produktionsarchitektur.
Strenge Stubs sind die Voreinstellung und sollten es bleiben: Ein ungenutzter
Stub heißt meist, dass der Test etwas anderes prüft als gedacht; ein
Argument-Mismatch, dass der Prüfling anders aufruft als angenommen. LENIENT
für eine ganze Klasse ist ein Schuldschein, lenient() für einen einzelnen Stub
eine Entscheidung.
Modul: Fortgeschrittenes Stubbing und schwierige Abhängigkeiten
Wenn das Mocking wehtut
| Was im Test auffällt | Was in der Anwendung fehlt |
|---|---|
| Setup länger als der Test | ein Schnitt, der Verantwortung trennt |
| Mock liefert Mock liefert Mock | eine Schnittstelle statt einer Kette |
| Reihenfolge muss geprüft werden | ein Ergebnis, das die Regel sichtbar macht |
| Jeder Umbau färbt zwanzig Tests rot | Tests am Verhalten statt an der Struktur |
Der Test ist das erste Werkzeug, das die Architektur benutzt — und das erste, das sich beschwert. Die drei häufigsten Unberechenbaren kommen über den Konstruktor herein, dann braucht kein Test dafür einen statischen Mock:
| Quelle | In Produktion | Im Test |
|---|---|---|
| Zeit | Systemuhr als Clock | feste Uhr im Konstruktor |
| Zufall | Zufallsquelle als Port | feste Folge von Werten |
| Identität | erzeugte UUID als Port | mitzählender Generator |
Ein Test Data Builder hält im Test nur das Abweichende sichtbar —
eineBestellung().mit(APFELKISTE, 10).fuerKunde(STAMMKUNDE).build(). Alles, was
dort nicht steht, ist für die geprüfte Regel egal; genau das ist die Aussage.
Modul: Gute Testarchitektur statt Mocking-Überlastung
Testcontainers 2: Umstieg und Einrichtung
Was der Realismus kostet — und was er dafür findet:
| Unit-Test | Container-Test | |
|---|---|---|
| Start | keiner | Sekunden je Image |
| Laufzeit je Test | Millisekunden | Zehntel bis Sekunden |
| Voraussetzung | JVM | Container-Laufzeit |
| Findet | Rechenfehler | Schema, Dialekt, Mapping |
Die Startzeit fällt einmal je Testklasse an, nicht je Test — das entscheidet über die Gesamtlaufzeit. Container-Tests ergänzen Unit-Tests, sie ersetzen sie nicht.
| Thema | Testcontainers 1.x | Testcontainers 2.x |
|---|---|---|
| Artefakt | postgresql | testcontainers-postgresql |
| Paket | org.testcontainers.containers | org.testcontainers.postgresql |
| JUnit 4 | als Rule unterstützt | entfallen |
| Jupiter-Modul | junit-jupiter | testcontainers-junit-jupiter |
Die Gruppe bleibt org.testcontainers — geändert haben sich Artefaktname und
Paket. Über die BOM (testcontainers-bom, aktuell 2.0.5) kommen
testcontainers-junit-jupiter und das Technologiemodul ohne eigene Version
dazu.
@Container
static GenericContainer<?> cache =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1))
.withStartupTimeout(Duration.ofSeconds(90));
var port = cache.getMappedPort(6379);
Der Container-Port wird nie fest zugeordnet — getMappedPort liefert den
zufällig vergebenen Hostport. Das Feld muss statisch sein, sonst startet
jeder Test die Infrastruktur erneut, und die Klasse braucht @Testcontainers.
| Strategie | Passt für |
|---|---|
| Wait.forListeningPort | Voreinstellung, Dienst öffnet einen Port |
| Wait.forLogMessage | Dienst meldet seine Bereitschaft im Log |
| Wait.forHttp | Dienst hat einen Health-Endpunkt |
| Wait.forHealthcheck | Image bringt einen eigenen Healthcheck mit |
| Wait.forSuccessfulCommand | Bereitschaft ist nur im Container prüfbar |
Ohne Angabe wartet Testcontainers bis zu 60 Sekunden auf den ersten
veröffentlichten Port. Startet ein Container nicht, steht die Ursache im
Container-Log — getLogs nachträglich, ein Log-Consumer live; der Test allein
meldet nur eine abgelaufene Wartezeit.
Modul: Testcontainers 2 einrichten und verwenden
Lebenszyklus der Container
| Lebenszyklus | Startet | Passt für |
|---|---|---|
| Feld je Testmethode | vor jedem Test | maximale Isolation |
| Statisches Feld | je Testklasse | der Regelfall |
| Singleton | einmal je Testlauf | viele Klassen, eine Datenbank |
| Wiederverwendung | überlebt den Lauf | lokale Arbeit, nicht CI |
Wiederverwendbare Container gelten weiterhin als experimentell und werden für CI ausdrücklich nicht empfohlen — ist die Wiederverwendung lokal aktiv und in der Pipeline nicht, verhalten sich die Tests unterschiedlich.
Datenbank- und Spring-Boot-Tests
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class BestellungIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> db =
new PostgreSQLContainer<>("postgres:17-alpine");
}
Ohne @ServiceConnection müsste jede Eigenschaft von Hand über
@DynamicPropertySource gesetzt werden. Das Image bekommt ein festes Tag —
mit latest wechselt die Datenbank still die Hauptversion.
| Annotation | Lädt |
|---|---|
| @DataJpaTest | nur die Persistenzschicht, mit Rollback |
| @WebMvcTest | nur die Weboberfläche, ohne Datenbank |
| @SpringBootTest | die ganze Anwendung, ohne Server |
| @SpringBootTest mit RANDOM_PORT | die ganze Anwendung samt echtem Server |
Je größer der Ausschnitt, desto langsamer der Test und desto unschärfer die
Fehlermeldung. Beim Test über echtes HTTP läuft der Aufruf in einem anderen
Thread als der Test — kein Rollback, geschriebene Daten bleiben liegen. Für
einzelne Beans im Kontext gibt es @MockitoBean und @MockitoSpyBean.
| Weg | Vorteil | Preis |
|---|---|---|
| Rollback je Test | schnell, kein Aufräumcode | prüft kein echtes Commit |
| Tabellen leeren | prüft echtes Verhalten | Reihenfolge der Constraints |
| Container je Klasse | vollständig isoliert | Startzeit je Testklasse |
| Quelle des Bestands | Eignung |
|---|---|
| Migration allein | leeres Schema, Test legt alles selbst an |
| Testdaten-Skript nach der Migration | gemeinsamer Grundbestand für viele Tests |
| Test Data Builder im Test | fachlich lesbar, je Test genau das Nötige |
| Datenbank-Abzug aus Produktion | ungeeignet, personenbezogen und instabil |
Migrationen gehören in den Test: Flyway oder Liquibase laufen gegen den frischen Container, damit eine nicht abwärtskompatible Migration im Pull Request auffällt statt beim Ausrollen. Der beste Bestand ist der kleinste, aus dem sich die geprüfte Regel noch ableiten lässt.
Modul: Datenbank- und Spring-Boot-Integrationstests
Verbund, Pipeline und Coverage
static Network netz = Network.newNetwork();
@Container
static GenericContainer<?> zahlwerk =
new GenericContainer<>("wiremock/wiremock:3.9.1")
.withNetwork(netz)
.withNetworkAliases("zahlwerk")
.withExposedPorts(8080);
Innerhalb des Netzwerks lautet die Adresse zahlwerk:8080 — von außen gilt der
gemappte Port; über localhost finden sich die Container nicht.
| Einzelne Container | ComposeContainer | |
|---|---|---|
| Definition | im Java-Code | in einer Compose-Datei |
| Wartebedingung | je Container steuerbar | über die Datei |
| Wiederverwendung | im Test | auch für die lokale Entwicklung |
| Passt für | zwei bis vier Dienste | eine vorhandene Compose-Umgebung |
| JaCoCo-Zähler | Bedeutung |
|---|---|
| Instruktionen | ausgeführte Bytecode-Anweisungen |
| Zeilen | Quellzeilen mit mindestens einer Ausführung |
| Branches | genommene Zweige von if und switch |
| Komplexität | abgedeckte Pfade gegenüber allen Pfaden |
Branch-Abdeckung sagt mehr als Zeilenabdeckung — eine Zeile mit if wird von
einem Testfall halb geprüft. Und Coverage beantwortet ohnehin nur die halbe
Frage:
| Coverage | Mutation Testing |
|---|---|
| Wurde die Zeile ausgeführt | Fällt der Test bei falschem Code auf |
| Schnell, in jedem Build | Langsam, nachts oder je Modul |
| Zeigt Lücken | Zeigt wirkungslose Tests |
| Lässt sich leicht schönen | Lässt sich kaum täuschen |
Flaky Tests sind eine Aussage, kein Rauschen. Ein sporadisch roter Test hat eine Ursache: Zeit, Reihenfolge, Nebenläufigkeit oder Netz. Wer ihn wiederholt, bis er grün ist, hat die Ursache nach Produktion verschoben.
Ein KI-Assistent findet Grenzfälle, die beim Schreiben übersehen wurden — er kennt aber die fachliche Regel nicht und leitet die Erwartung aus dem Code ab. Ein generierter Test, der das aktuelle Verhalten festschreibt, zementiert auch den Fehler. Übernommen wird ein Vorschlag erst, wenn jemand seine fachliche Aussage benennen kann.
Modul: Komplexe Testumgebungen, Pipelines und KI-Unterstützung
Typische Fallen
- Jede Abhängigkeit reflexhaft mocken, auch die Klasse mit reiner Rechenlogik.
- Die ersetzte Abhängigkeit nirgends echt testen — und niemand merkt es.
- @InjectMocks bei mehreren passenden Konstruktoren still den falschen wählen lassen.
- Festen Wert und Matcher mischen — die Meldung nennt eine ganz andere Ursache.
- Stubs ins gemeinsame Setup legen, wo die meisten Tests sie nicht brauchen.
anyüberall setzen und den Test grün lassen, obwohl sich Argumente änderten.whenauf einem Spy anwenden und dabei die echte Methode ausführen.- verifyNoMoreInteractions in jedem Test — jede Änderung wird rot.
- Den Captor vor dem Aufruf auswerten, wo er noch leer ist.
- MockedStatic nicht schließen und alle folgenden Tests des Threads färben.
- Deep Stubs benutzen: kurze Kette, unsichtbare Abhängigkeit.
- Den alten 1.x-Paketnamen im Import stehen lassen — die Klasse wird nicht gefunden.
- Feste Ports im Test verwenden statt
getMappedPort. - Image ohne Tag angeben, dann ändert sich der Testlauf ohne Commit.
- @SpringBootTest auf jede Testklasse setzen und zehn Minuten Laufzeit kaufen.
- Je Testklasse andere Properties setzen und die Kontextwiederverwendung verhindern.
- Eine Coverage-Quote erzwingen — es entstehen Tests ohne Assertion.
Zum Seminar Java Testing mit JUnit, Mockito und Testcontainers