Mockito ist ein populäres Mocking-Framework für Java, das Integrationstests erheblich vereinfacht. Mit Mockito lassen sich Abhängigkeiten durch Mock-Objekte ersetzen, um einzelne Komponenten isoliert zu testen. Dies ermöglicht schnelle, gezielte Tests, die unabhängig von externen Systemen wie Datenbanken oder REST-APIs funktionieren.
- Beispielcode-Repository
- https://gitlab.com/archi-lab/public/testing-good-practices/-/tree/main/integration-testing-mockito
- Keywords
- Integrationstests, Mockito, Mocking, Mock, Test, Integration
Integration Testing mit Mockito
Wenn wir einen Spring-Service isoliert testen, wollen wir weder einen vollständigen
Application-Context hochfahren noch eine echte Datenbank ansprechen. Beides ist langsam,
nicht-deterministisch und prüft am Ende ohnehin etwas anderes als die Logik unseres
Service. Mockito erlaubt uns, die Abhängigkeiten eines Service durch Mock-Objekte zu
ersetzen, deren Verhalten wir vollständig kontrollieren. So testen wir das Verhalten des
Service rein in Java - schnell, deterministisch und ohne jede Infrastruktur.
Diese Seite ist ein Katalog der wichtigsten Pattern, die beim Mockito-basierten
Integrationstesten von Services immer wieder vorkommen. Die Code-Ausschnitte stammen aus dem
Beispielprojekt, das diese Seite begleitet - einem kleinen Bahn-Ticket-System mit zwei
Services und einer reinen Rechenregel:
TicketService.purchaseTicket(...) - legt einen Ticketkauf an. Hängt von einem
Repository (TicketRepository) ab.
LoyaltyService.isFrequentTraveller(...) - entscheidet, ob ein Reisender im
Vielfahrer-Programm ist.
FrequentTravellerRule - die reine Regel (“mindestens 500 € in den letzten sechs
Monaten”). Keine Abhängigkeiten, keine Seiteneffekte - und damit kein Mockito-Fall,
sondern ein normaler Unit-Test.
Unit-Test vs. Integrationstest
Die Abgrenzung zwischen beiden Testarten ist wichtig für das Verständnis, wofür man Mockito
einsetzt. Ein Unit-Test prüft eine einzelne Klasse ohne Kollaborateure - typischerweise ein
Aggregate Root, ein Domain Primitive oder eine reine Regelklasse wie FrequentTravellerRule.
Dort brauchen Sie kein Mockito, Sie instanziieren die Klasse einfach.
Ein Integrationstest hingegen prüft das Zusammenspiel eines Service mit seinen
Kollaborateuren - typischerweise dem Repository und benachbarten Services. Genau dieses
Zusammenspiel isolieren wir mit Mockito, indem wir die Kollaborateure durch Mocks ersetzen
und nur den einen (getesteten) Service echt laufen lassen.
Was wir mocken - und warum
Ein Application Service hängt typischerweise von Kollaborateuren ab, die mit der Außenwelt
reden. Genau die ersetzen wir im Test durch Mocks:
| Kollaborateur |
Warum wir ihn mocken |
Repository (TicketRepository) |
Ersetzt die JPA-/Datenbankschicht. Wir wollen keine echte DB starten und auch nicht testen, ob JPA funktioniert - sondern ob unser Service das Repository korrekt benutzt. |
benachbarter Service (TravellerService) |
Ersetzt ein anderes Aggregat. Dessen Logik hat eigene Tests; hier interessiert nur, dass und wie unser Service ihn aufruft. |
Zwei Dinge bleiben echt (kein Mock):
- der getestete Service selbst - wir bauen ihn mit seinen gemockten Abhängigkeiten
zusammen und prüfen sein Verhalten;
- reine Logik ohne Seiteneffekte wie
FrequentTravellerRule - die wird nicht gemockt,
sondern “as is” mitgenutzt und hat ihren eigenen Unit-Test.
Typische Patterns bei der Nutzung von Mockito
Pattern 1 - Mocks deklarieren
Es gibt zwei gängige Wege, Mocks zu erzeugen und in den Service zu injizieren. Beide sind
korrekt - wählen Sie einen und bleiben Sie im Projekt konsistent dabei. In diesem
Beispielprojekt habe ich mich dafür entschieden, die Mocks in der Setup-Methode manuell
zu erstellen.
class TicketServiceTest {
private TicketRepository ticketRepository;
private TravellerService travellerService;
private TicketService ticketService;
@BeforeEach
void setUp() {
ticketRepository = mock( TicketRepository.class );
travellerService = mock( TravellerService.class );
ticketService = new TicketService( ticketRepository, travellerService );
}
// … die Tests …
}
Der Service wird hier über seinen Konstruktor mit den Mocks zusammengebaut (da sieht man
dann auch, warum Constructor Injection besser ist als Field Injection). Der Vorteil: man
sieht die Abhängigkeiten explizit, und der Test funktioniert ganz ohne Mockito-Annotationen.
Alternativ gibt es den annotationsgetriebenen Stil, der etwas kompakter ist, aber auch etwas
“magischer”. So sieht dieselbe Testklasse mit Mockito-Annotationen aus:
@ExtendWith( MockitoExtension.class )
class TicketServiceTest {
@Mock
private TicketRepository ticketRepository;
@Mock
private TravellerService travellerService;
@InjectMocks
private TicketService ticketService;
// … die Tests …
}
Pattern 2 - Stubbing mit when().thenReturn()
Das Grundpattern: Wir sagen einem Mock, was er bei einem bestimmten Aufruf zurückgeben soll.
Damit stellen wir die Welt her, in der unser Service-Aufruf laufen soll (das „given“).
@Test
void isFrequentTraveller_loadsTicketsViaServiceAndAppliesRule() {
// given: der Ticket-Service liefert die Tickets des Reisenden
TravellerId travellerId = new TravellerId( UUID.randomUUID() );
when( ticketService.findTicketsOf( "johndoe" ) ).thenReturn( List.of(
new Ticket( travellerId, new BigDecimal( "500.00" ), LocalDate.of( 2025, 12, 10 ) ) ) );
// when
boolean frequent = loyaltyService.isFrequentTraveller( "johndoe", REFERENCE );
// then
assertThat( frequent ).isTrue();
}
Lesen Sie die drei Abschnitte als given / when / then: erst den Mock einrichten, dann genau
einen Aufruf des getesteten Service, dann die Überprüfungen.
Pattern 3 - thenAnswer(): das gespeicherte Objekt zurückgeben
save(...) eines Spring-Data-Repositories gibt die gespeicherte Entität zurück. Ein Mock
gibt standardmäßig null zurück - das bringt jeden Service zum Absturz, der das Ergebnis von
save weiterverwendet. Mit thenAnswer reichen wir das übergebene Argument einfach durch:
when( ticketRepository.save( any( Ticket.class ) ) )
.thenAnswer( inv -> inv.getArgument( 0 ) );
Gelesen wird das so: “Was auch immer in save(...) hineingegeben wird, gib es unverändert zurück.”
Das ist das übliche “Save-Echo”-Pattern und gehört in fast jeden Service-Test, der speichert und
das Ergebnis zurückgibt - so wie TicketService.purchaseTicket(...), das das gespeicherte
Ticket zurückliefert.
thenAnswer ist allgemeiner als thenReturn: Die Antwort wird bei jedem Aufruf aus den
tatsächlichen Argumenten berechnet, statt einen vorab festgelegten Wert zu liefern.
Pattern 4 - Argument-Matcher (any, eq, anyString)
Oft ist uns das konkrete Argument egal (“irgendein Ticket”), oder wir kennen es im Test gar
nicht (eine intern erzeugte ID). Dann verwenden wir Matcher statt konkreter Werte:
when( ticketRepository.save( any( Ticket.class ) ) ).thenAnswer( inv -> inv.getArgument( 0 ) );
when( travellerService.findByUsername( anyString() ) ).thenReturn( travellerId );
Hat eine Methode mehrere Parameter und soll nur eines exakt passen, der Rest egal sein, dann
mischt man - aber nach einer festen Regel (Beispiel mit einem gedachten abgeleiteten Query):
when( ticketRepository.findByTravellerIdAndPurchaseDateAfter( eq( travellerId ), any() ) )
.thenReturn( List.of() );
Es gilt: Sobald ein Argument ein Matcher ist, müssen alle Argumente Matcher sein.
Einen konkreten Wert, der exakt passen soll, schreibt man dann als eq(wert) - nicht als
nackten Wert. Ein Mix wie find( travellerId, any() ) (erstes Argument “nackt”, zweites
Argument Matcher) wirft zur Laufzeit eine InvalidUseOfMatchersException.
Die wichtigsten Matcher sind:
any() (irgendetwas, inkl. null)
any(Ticket.class) (irgendein Ticket, nicht null)
eq(x) (genau x)
anyString() (irgendein String, nicht null)
anyInt() (irgendein int, nicht null)
Pattern 5 - Interaktionen prüfen mit verify()
when richtet den Mock ein; verify prüft hinterher, dass (und wie oft) ein Mock
aufgerufen wurde. Das ist der Kern eines Integrationstests: nicht nur das Ergebnis zählt,
sondern auch, ob der Service korrekt mit seinen Nachbarn gesprochen hat.
// genau einmal gespeichert
verify( ticketRepository ).save( ticket );
// der Service wurde mit dem richtigen Username konsultiert
verify( travellerService ).findByUsername( "johndoe" );
// im Fehlerfall: sicherstellen, dass gar nicht gespeichert wurde
verify( ticketRepository, never() ).save( any() );
verify(mock) ohne Anzahl bedeutet dasselbe wie times(1), also genau ein Mal. Weitere nützliche Modi sind:
never() (kein Aufruf)
times(n) (macht die Aufrufzahl zum Teil des Vertrags - nützlich, wenn ein Pfad z. B. zweimal speichern muss und
ein anderer nur einmal)
atLeastOnce() (der Aufruf muss mindestens einmal erfolgen, aber es könnte auch öfter sein)
atLeast(n) (der Aufruf muss mindestens n-mal erfolgen, aber es könnte auch öfter sein)
atMost(n) (der Aufruf darf höchstens n-mal erfolgen, aber es könnte auch weniger sein)
Pattern 6 - verifyNoInteractions(): kein Schreibvorgang, wenn etwas schiefgeht
Eine der wichtigsten Prüfungen ist es, dass der Service nichts weiter tut (insbesondere nicht das
Repository zum Speichern aufruft), wenn ein Fehler auftritt - z. B. wenn der angegebene Username
nicht existiert.
@Test
void purchaseTicket_unknownUsername_throwsAndNeverTouchesRepository() {
when( travellerService.findByUsername( "unknown" ) )
.thenThrow( new TravellerNotFoundException( "unknown" ) );
assertThatThrownBy( () ->
ticketService.purchaseTicket( "unknown", new BigDecimal( "49.99" ), LocalDate.of( 2026, 1, 15 ) ) )
.isInstanceOf( TravellerNotFoundException.class );
verifyNoInteractions( ticketRepository );
}
verifyNoInteractions(mock) (für gar keinen Aufruf eines Mocks) bzw. never().save(...)
stellt sicher, dass der Service nicht teilweise ausgeführt wird - es kann kein
halb-angelegter Ticketkauf in der Datenbank landen, wenn der Reisende gar nicht existiert. Das
ist die Test-Entsprechung der Idee, dass ein Aggregat seine Invarianten konsistent hält.
Pattern 7 - Fehlerpfade des Kollaborateurs mit thenThrow()
Ein Kollaborateur kann fehlschlagen. Wir simulieren das, indem der Mock eine Exception wirft,
und prüfen, dass unser Service korrekt reagiert (durchreicht, übersetzt oder abbricht).
@Test
void purchaseTicket_unknownUsername_throwsAndNeverTouchesRepository() {
when( travellerService.findByUsername( "unknown" ) )
.thenThrow( new TravellerNotFoundException( "unknown" ) );
assertThatThrownBy( () ->
ticketService.purchaseTicket( "unknown", new BigDecimal( "49.99" ), LocalDate.of( 2026, 1, 15 ) ) )
.isInstanceOf( TravellerNotFoundException.class );
}
Wichtig ist, dass Sie auf den fachlichen Exception-Typ prüfen, nicht auf RuntimeException. Würden wir die
generische RuntimeException erwarten, käme auch eine versehentliche NullPointerException als
“korrekt abgelehnt” durch. Der Test wäre dann unehrlich darüber, warum der Aufruf gescheitert ist.
Das Festnageln auf den konkreten Typ (TravellerNotFoundException) hält den Ablehnungstest “ehrlich”.
Pattern 8 - ArgumentCaptor: was genau wurde gespeichert?
Manchmal reicht “save wurde aufgerufen” nicht - wir wollen wissen, womit. Der Service
erzeugt die Entität ja intern (purchaseTicket baut das Ticket selbst); im Test haben wir
keine direkte Referenz darauf. Der ArgumentCaptor fängt das tatsächlich übergebene Objekt
ab, damit wir es prüfen können:
@Test
void purchaseTicket_savesTicketWithPriceDateAndTraveller() {
LocalDate date = LocalDate.of( 2026, 1, 15 );
when( travellerService.findByUsername( "johndoe" ) ).thenReturn( travellerId );
when( ticketRepository.save( any( Ticket.class ) ) ).thenAnswer( inv -> inv.getArgument( 0 ) );
ticketService.purchaseTicket( "johndoe", new BigDecimal( "49.99" ), date );
ArgumentCaptor<Ticket> captor = ArgumentCaptor.forClass( Ticket.class );
verify( ticketRepository ).save( captor.capture() );
assertThat( captor.getValue().getTravellerId() ).isEqualTo( travellerId );
assertThat( captor.getValue().getPrice() ).isEqualByComparingTo( "49.99" );
assertThat( captor.getValue().getPurchaseDate() ).isEqualTo( date );
}
Als Faustregel gilt: ArgumentCaptor verwendet man, wenn das interessierende
Objekt im Service entsteht und Sie seinen Inhalt prüfen wollen. Geht es nur um die
Identität eines Objekts, das Sie bereits in der Hand haben, genügt verify( repo ).save( dasObjekt ).
Pattern 9 - Optional als Rückgabe: gefunden / nicht gefunden
Spring-Data-findBy…-Methoden geben oft Optional zurück (z. B.
TravellerRepository.findByUsername). Über den Inhalt dieses Optional steuern wir im Test
die beiden Welten “Entität existiert” und “existiert nicht” - und damit fast alle
Verzweigungen eines Services:
// gefunden → Service arbeitet weiter
when( travellerRepository.findByUsername( "johndoe" ) )
.thenReturn( Optional.of( new Traveller( "johndoe" ) ) );
// nicht gefunden → Service wirft Exception
when( travellerRepository.findByUsername( "ghost" ) )
.thenReturn( Optional.empty() );
assertThatThrownBy( () -> travellerLookup.findByUsername( "ghost" ) )
.isInstanceOf( TravellerNotFoundException.class );
Mit demselben Mock und nur unterschiedlichem Optional-Inhalt decken Sie systematisch
Happy Path und “nicht gefunden” ab. Das ist das typische Pattern hinter einem
findBy…(…).orElseThrow(…) im Service.
Was Sie nicht mocken sollten
Mocking ist ein Werkzeug für Kollaborateure mit Außenwirkung (Repositories, benachbarte Services).
Nicht alles gehört gemockt:
- Keine reine Logik mocken (
FrequentTravellerRule). Sie ist klein, deterministisch und
ohne Seiteneffekte - nutzen Sie sie echt (per @Spy oder einfach new …()) und testen Sie
sie separat. Ein gemocktes Regelobjekt würde genau die Rechenlogik verbergen, die Sie prüfen
wollen.
- Keine Aggregate/Entities mocken, deren Verhalten zum Testgegenstand gehört. Oben sind
Ticket und Traveller jeweils echt, nur ihr Repository ist gemockt. So testen Sie
die echte Logik der Entität mit.
- Keine Value Objects / IDs mocken (
TravellerId, TicketId). Die baut man einfach.
- Nichts mocken, was Ihnen nicht gehört (JDK-Typen, fremde Bibliotheksklassen).
Faustregel: Mocken Sie eine Grenze (Datenbank, Nachbar-Aggregate), nicht den Kern
dessen, was der Test eigentlich prüfen soll.