1. Die Rolle von spy neben mock()
Wenn mock() ein „Schauspieler nach Drehbuch" ist, der nur das tut, was wir ihm vorgegeben haben, dann ist spy ein „echter Mitarbeiter", dem wir eine Überwachungskamera aufgestellt haben (und dem wir manchmal einen Spickzettel in die Tasche stecken).
Manchmal möchte man in einem Test ein Objekt echt lassen, weil sein Verhalten einfach, nützlich und schwer von Hand nachzuschreiben ist. Gleichzeitig möchte man aber entweder sicherstellen, dass bestimmte Methoden wirklich aufgerufen wurden, oder punktuell ein kleines Stück Verhalten ersetzen, damit der Test deterministisch wird.
Auf dem Papier klingt das wunderbar: ein echtes Objekt nehmen, es beobachten und bei Bedarf leicht eingreifen. In der Praxis beginnen hier die häufigsten Fallstricke. spy steht fast immer an der Grenze zwischen „ich brauche es schnell" und „ich brauche es zuverlässig". Er ist weder verboten noch „beschämend", erfordert aber Disziplin, weil er standardmäßig echten Code ausführt. Und echter Code hat die dreiste Angewohnheit, ins Dateisystem zu gehen, Zufallswerte zu erzeugen, von der Uhrzeit abhängig zu sein, Ausnahmen zu werfen — kurz: all das zu tun, wovon Unit-Tests normalerweise wegwollen.
Wir müssen zwei Dinge gleichzeitig im Blick behalten. Erstens: wo spy ein normales kleines Werkzeug ist, das den Test verständlicher macht. Zweitens: wo spy ein rotes Fähnchen ist, das signalisiert, dass das Objekt zu groß und schlecht von seinen Abhängigkeiten getrennt ist.
2. spy in Mockito
Wenn du einen spy erstellst, bekommst du keinen „leeren Attrappen-Stub" wie bei mock(). Du nimmst eine echte Instanz und bittest Mockito, sie zu umhüllen. Von außen ist es ein Mockito-Objekt: es lässt sich verify()-en und stub()-en, aber innen bleibt es echt. Methoden werden standardmäßig wirklich ausgeführt, der Zustand ändert sich wirklich, und Ausnahmen fliegen dir wirklich ins Gesicht, wenn du etwas falsch gemacht hast.
Ein kurzer Vergleich hilft, das im Kopf zu behalten:
| Eigenschaft | mock() | spy() |
|---|---|---|
| Echtes Objekt innen | nein | ja |
| Methoden werden standardmäßig ausgeführt | nein (Standardwerte werden zurückgegeben) | ja |
| Kann verify()-t werden | ja | ja |
| Kann gestubbt werden | ja | ja, aber mit Nuancen |
| Risiko zufälliger I/O/Zufallswerte | niedrig | hoch |
| Gut für große Abhängigkeiten | ja (wenn richtig eingesetzt) | meistens nein |
Das Wichtigste: spy ist kein „weiterer Mock-Typ". Es ist ein Werkzeug, das echtes Verhalten beibehält. Daher sollte die erste Frage nicht „wie mache ich einen Spy" sein, sondern „brauche ich wirklich das echte Verhalten dieses Objekts in diesem Test?".
Ein sehr einfaches Beispiel, wo spy sinnvoll erscheint: wir möchten, dass sich die Liste wirklich ändert, und gleichzeitig prüfen, dass das Hinzufügen tatsächlich stattgefunden hat.
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class EventListSpyTest {
@Test
void add_shouldActuallyModifyList() {
// Spy umhüllt ein echtes ArrayList: Methoden werden wirklich ausgeführt
List<String> events = spy(new ArrayList<>());
// Echter Aufruf: die Liste wird tatsächlich geändert
events.add("PUBLISHED");
// Wir können aber trotzdem Interaktionen prüfen, wie bei einem Mock
verify(events).add("PUBLISHED");
// Und den echten Zustand prüfen, weil innen eine echte Liste ist
assertThat(events).containsExactly("PUBLISHED");
}
}
Hier ist spy logisch: ArrayList ist ein kleines, deterministisches und verständliches Objekt. Wir befürchten nicht, dass es plötzlich ins Netz geht oder vom Zeitzonensystem abhängt. Wir wollen einfach „eine Kamera aufstellen" und sicherstellen, dass der wichtige Aufruf stattfand.
Wenn du dagegen ein Objekt bespitzelst, das innen etwas Ernstes tut, sagst du dem Test buchstäblich: „lebe gefährlich". Manchmal ist das gerechtfertigt, aber meistens nicht.
3. spy in ContentHub
In unserem durchgehenden Projekt ContentHub gibt es verschiedene Typen von Komponenten. Es gibt „saubere" Dinge mit lokaler Logik — zum Beispiel Slug-Normalisierung oder einfache Regelprüfungen. Es gibt Ports und Adapter: Benachrichtigungen senden, Dateien verarbeiten, externer Moderations-Client. Und es gibt Orchestrierungsservices, die alles zusammenführen.
In der Praxis taucht spy am häufigsten in zwei Situationen auf: wenn du ein kleines Objekt hast, dessen Verhalten du echt lassen möchtest, und wenn innerhalb des Objekts ein kleiner nicht-deterministischer Abschnitt liegt — ein zufälliges Suffix, ID-Generierung, aktuelle Uhrzeit —, den du festnageln möchtest.
Stellen wir uns einen sehr vereinfachten SlugService vor. In ContentHub soll der Slug benutzerfreundlich und lesbar sein, wobei die Eindeutigkeit oft durch Hinzufügen eines kleinen Suffixes erreicht wird. Und genau das Suffix ist das „Testproblem", weil es zufällig ist.
import java.util.UUID;
class SlugService {
String slugify(String title) {
// Deterministischer Teil: Titel-Normalisierung
String base = title.toLowerCase().replace(" ", "-");
// Nicht-deterministischer Teil: zufälliges Suffix
return base + "-" + randomSuffix();
}
String randomSuffix() {
// Echte UUID macht Tests instabil, wenn das Verhalten nicht fixiert wird
return UUID.randomUUID().toString().substring(0, 8);
}
}
Wenn wir das Verhalten von slugify() testen (oder einfach demonstrieren) möchten, wollen wir nicht, dass der Test von einer zufälligen UUID abhängt. Hier kann spy technisch helfen: wir lassen das echte slugify() stehen und ersetzen nur randomSuffix().
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class SlugServiceSpyTest {
@Test
void slugify_canBeMadeDeterministicWithSpy() {
// Spy behält die echte Implementierung von slugify(...)
SlugService slugService = spy(new SlugService());
// Für Spy ist Stubbing sicherer über doReturn(...).when(...)
// damit die echte Methode während der Einrichtung nicht aufgerufen wird
doReturn("deadbeef").when(slugService).randomSuffix();
// Wir prüfen das deterministische Ergebnis
assertThat(slugService.slugify("Hello World"))
.isEqualTo("hello-world-deadbeef");
}
}
Dieses Beispiel ist nützlich als Demonstration der Partial-Mock-Idee. Es zeigt aber auch etwas Wichtiges: wenn man im Projekt regelmäßig so Zufallswerte stabilisieren muss, bedeutet das, dass der Nicht-Determinismus ins Objekt versteckt wurde. Oft ist es gesünder, die Suffix-Generierung in eine separate Abhängigkeit auszulagern und SlugService vollständig deterministisch zu lassen. Dann wird auch der Test einfacher und spy wird keine Gewohnheit.
Genauso verdächtig sieht spy auf großen Services aus. Wenn du in ContentHub zum Beispiel auf ArticleWorkflowService spionierst und anfängst, die Hälfte seiner internen Methoden zu ersetzen, sagt der Test buchstäblich: „ich verstehe die Verantwortungsgrenzen dieser Klasse nicht". Und das ist ein guter Moment, sich nicht über den „grünen Test" zu freuen, sondern innezuhalten und zu fragen: „warum muss ich überhaupt ins Innere gehen?".
4. Partial Mock
Partial Mock ist kein eigenes magisches Gebilde. In Mockito bedeutet das fast immer: du hast spy(realObject) gemacht und danach eine oder mehrere Methoden gestubbt. Als Ergebnis bekommst du ein Objekt, das sich „fast wie ein echtes" verhält, aber an einer Stelle sagst du: „nein, Freundchen, hier verhältst du dich so, wie es dem Test passt".
Das ist ein bisschen wie im Alltag: du prüfst, ob eine Kaffeemaschine im Büro funktioniert, möchtest aber nicht, dass sie das Wasser wirklich erhitzt — weil das lange dauert und deinen Testaufbau beschädigen könnte. Du sagst: „alles andere mach ehrlich, aber die Erhitzung gilt als sofortig". Klingt lustig — und genauso sieht ein Partial Mock aus, wenn man ihn gedankenlos einsetzt.
Nehmen wir eine kleine Klasse, wo eine Methode auf eine andere angewiesen ist.
class ArticleDraft {
String title() {
// Echte Methode, die wir im Test NICHT ersetzen
return "Java Mockito";
}
String slug() {
// Methode, die im Test ersetzt wird (Partial Mock)
return title().toLowerCase().replace(" ", "-");
}
}
Und jetzt der Partial Mock im Test: title() bleibt echt, slug() ersetzen wir.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class ArticleDraftSpyTest {
@Test
void slug_canBeOverridden_inPartialMock() {
// Echtes Objekt nehmen und in Spy einwickeln
ArticleDraft draft = spy(new ArticleDraft());
// Nur eine Methode ersetzen — das ist der Partial Mock
doReturn("ready-slug").when(draft).slug();
// slug kommt aus dem Stub
assertThat(draft.slug()).isEqualTo("ready-slug");
// title bleibt echt, da wir es nicht ersetzt haben
assertThat(draft.title()).isEqualTo("Java Mockito");
}
}
Technisch gesehen ist das alles korrekt. Inhaltlich stellt sich aber eine große Frage: warum ersetzen wir eigentlich slug() auf einem Objekt, das selbst für slug() zuständig ist? Sehr oft bedeutet ein Partial Mock auf „unserem" Objekt, dass wir zu viel auf einmal testen oder das Verhalten nicht auf normalem Weg stabilisieren können — zum Beispiel über Abhängigkeiten.
Daher ist ein Partial Mock zwar erlaubt, sollte aber eine seltene und verständliche Ausnahme bleiben, kein Standardweg zum „Tests grün machen".
5. Die Stubbing-Falle bei spy
Bei mock() gewöhnst du dich an das Modell: „bis ich die Methode in Act aufrufe, passiert nichts". Bei spy() gilt das nicht ganz. Die bekannteste und schmerzhafteste Falle ist, wenn du when(spy.someMethod()).thenReturn(...) schreibst. Mockito ruft die Methode manchmal wirklich auf, um den Aufruf „abzufangen" — das bedeutet, echter Code wird direkt während der Einrichtung ausgeführt.
Bei kleinen Objekten kann das einfach überraschend sein. Bei großen — schon eine Katastrophe: du gehst versehentlich ins Dateisystem, machst eine Netzwerkanfrage oder bekommst eine Ausnahme, bevor der Test überhaupt etwas Inhaltliches geprüft hat.
Ein klassisches Beispiel mit einer Liste:
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.mockito.Mockito.*;
class SpyStubbingPitfallTest {
@Test
void when_onSpy_mayCallRealMethodDuringArrange() {
List<String> list = spy(new ArrayList<>());
// SCHLECHT: echtes list.get(0) wird direkt hier aufgerufen und schlägt fehl:
// when(list.get(0)).thenReturn("first"); // IndexOutOfBoundsException
}
}
Der richtige Weg für spy ist die Familie doReturn(...).when(spy).method() (sowie doThrow, doAnswer, doNothing). Diese Formen erfordern kein „Methode jetzt aufrufen", daher sind sie sicherer und berechenbarer.
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class SpyStubbingCorrectTest {
@Test
void doReturn_onSpy_isSafe() {
// Spy auf einer echten Liste
List<String> list = spy(new ArrayList<>());
// Sicheres Stubbing für Spy: die Methode wird während Arrange nicht aufgerufen
doReturn("first").when(list).get(0);
// Hier wird der Wert aus dem Stub zurückgegeben, nicht echtes list.get(0)
assertThat(list.get(0)).isEqualTo("first");
}
}
Beachte, wie sehr das das Denken verändert: in der Welt von spy ist die Einrichtung nicht nur „Szenario beschreiben", sondern auch Schutz vor versehentlicher Codeausführung während der Einrichtung. Daher gilt in echten Projekten oft eine unausgesprochene Regel: wenn du Stubbing für einen Spy schreibst, verwende fast immer doReturn/doThrow, auch wenn when(...).thenReturn(...) „anscheinend funktioniert hat" einmal auf deinem Laptop.
6. spy als Architektursignal
Es gibt eine gute Ingenieurgewohnheit: nicht nur Werkzeuge zu benutzen, sondern auch die „Symptome" zu lesen, die sie zeigen. spy ist eines der symptomreichsten Werkzeuge beim Testen. Er taucht oft nicht auf, weil man „Spies liebt", sondern weil das Objekt schwer anders zu testen ist.
Die folgende Tabelle hilft, schnell zu verstehen, was spy in einem Test bedeuten kann. Das ist kein Gesetz, sondern ein diagnostischer Spickzettel.
| Was du im Test siehst | Was das oft bedeutet | Warum das ein Risiko ist |
|---|---|---|
| spy() auf einem großen Service | der Service tut zu viel | der Test beginnt, in die Interna einzudringen |
| doReturn() auf 3–4 Methoden desselben Objekts | die Klasse mischt verschiedene Verantwortlichkeiten | der Test wird zum „Aufruf-Szenario" statt zur Verhaltensüberprüfung |
| spy() wird benötigt, um Zufälligkeit/Uhrzeit festzunageln | Nicht-Determinismus ist versteckt | Tests werden fragil und implizit |
| spy() ruft echte Nebeneffekte auf | Abhängigkeit ist nicht isoliert | der Test beginnt, von der Umgebung abhängig zu sein |
| verify(spy).privateHelper() (oder Ähnliches) | du testest die Implementierung, nicht das Verhalten | jedes Refactoring bricht Tests ohne inhaltliche Änderung |
Die Idee ist ganz einfach: spy ist in Ordnung, wenn er ein kleines, sauberes und berechenbares Objekt umhüllt. Wenn spy aber zur „Krücke" für eine große Komponente wird, bedeutet das oft, dass das Objekt nicht besonders gut für das Testen entworfen wurde. Nicht weil jemand ein schlechter Entwickler ist, sondern weil es in der normalen Entwicklung so passiert: erst schreibt man die Funktion, dann fängt man an zu testen — und plötzlich stellt sich heraus, dass die Verantwortlichkeiten besser aufgeteilt wären.
Wichtig ist, nicht ins Extrem „spy ist böse" zu verfallen. Das ist ungefähr so, als würde man sagen: „Klebeband ist böse". Klebeband rettet manchmal, wenn der Griff am Koffer abgefallen ist. Aber wenn der ganze Koffer auf Klebeband hält, ist das Problem nicht das Klebeband — das Problem ist der Koffer.
7. Wahl zwischen spy und mock()
Wenn die Versuchung aufkommt, spy einzusetzen, hilft ein kleiner Entscheidungsbaum im Kopf. Er hilft, spy nicht zur automatischen Gewohnheit zu machen, aber ihn auch nicht dort zu verteufeln, wo er den Test wirklich einfacher macht.
flowchart TD
A["Wird die echte Logik dieses Objekts im Test benötigt?"] -->|Nein| B["Verwende mock/stub/fake"]
A -->|Ja| C["Ist das Objekt klein und deterministisch?"]
C -->|Ja| D["Spy ist erlaubt (vorsichtig)"]
C -->|Nein| E["Das ist ein Signal: versuche, die Abhängigkeit auszulagern oder das Objekt zu vereinfachen"]
D --> F["Muss eine Methode ersetzt werden?"]
F -->|Ja| G["Verwende doReturn/doThrow für Spy"]
F -->|Nein| H["Verwende Spy nur für verify, nicht als Spektakel"]
Auf Deutsch gesagt ergibt sich folgendes. Wenn du die echte Logik nicht brauchst — nimm keinen spy, nimm mock oder fake. Wenn echte Logik gebraucht wird, aber das Objekt groß ist und Nebeneffekte hat, wird spy gefährlich. Wenn das Objekt klein und sauber ist, kann spy durchaus die lesbarste Lösung sein.
Wenn du eine Methode im spy ersetzen musst, ist es besser, über doReturn/doThrow zu gehen, sonst riskierst du, echten Code schon in Arrange auszuführen. Und schließlich: wenn du spy verwendest, um interne Aufrufe zu prüfen, beginnt der Test nicht das Verhalten, sondern die Implementierung zu schützen — und das macht den Test-Suite fast immer fragil.
8. Typische Fehler beim Arbeiten mit spy und Partial Mocks
spy ist sehr tückisch, weil er „aussieht" wie ein normales Mockito-Objekt, sich aber „verhält" wie ein echtes. Daher sind Fehler damit oft unverständlich: du machst dasselbe wie mit mock(), aber der Test schlägt entweder unerwartet fehl oder besteht, beweist aber seltsame Dinge.
Fehler Nr. 1: spy für einen „sicheren Mock" halten und die echte Codeausführung vergessen.
Das ist die häufigste Geschichte. Du umhüllst ein Objekt mit spy und erwartest, dass es „leer" und gehorsam ist, dann bekommst du plötzlich eine echte Ausnahme, echten Dateizugriff oder einen Hänger. Der Grund ist einfach: bei spy werden echte Methoden standardmäßig weiterhin ausgeführt. Wenn das Objekt irgendetwas Nicht-Deterministisches tut, wird der Test zur Lotterie.
Fehler Nr. 2: when(spy.method()).thenReturn(...) verwenden und versehentlich die echte Methode während der Einrichtung aufrufen.
Bei mock() ist das fast immer sicher, bei spy ist es eine der unangenehmen Fallen. Die Methode kann in Arrange aufgerufen werden, und du wirst lange überlegen, warum der Test „vor Act" fehlschlägt. Für spy ist es fast immer einfacher und sicherer, zu doReturn(...).when(spy).method() oder doThrow(...) zu wechseln, damit die Einrichtung keinen echten Code startet.
Fehler Nr. 3: auf einem großen Service spionieren und die Hälfte seiner Methoden ersetzen.
Wenn der Test zu einem „teilweise echten, teilweise gemockten" Monster wird, verlierst du den Sinn. Solche Tests sind schwer zu lesen, schwer zu warten und erklären schlecht, was sie eigentlich schützen. Das bedeutet normalerweise, dass der Service zu viel Verantwortung hat: er enthält Regeln, berührt Infrastruktur, sammelt Zustand und sendet Nebeneffekte. In dieser Situation ist spy keine Lösung, sondern der Rauch eines Feuers.
Fehler Nr. 4: über spy interne Helper-Aufrufe statt Verhalten prüfen.
verify(spy).someInternalMethod() sieht sehr „präzise" aus, prüft aber oft etwas, das nicht stabil sein muss. Heute rufst du die Helper-Methode auf, morgen integrierst du die Logik direkt — das Verhalten ist dasselbe, aber der Test schlägt fehl. Das ist Overspecification, nur im Gewand von spy. Wenn dem Test das Ergebnis oder der externe Effekt wichtig ist — prüfe diese, nicht den Weg dorthin.
Fehler Nr. 5: Partial Mock auf dem zu testenden Objekt verwenden und sich über den grünen Test freuen.
Wenn du eine Methode auf einem Objekt ersetzt, das eigentlich die zu prüfende Logik enthält, riskierst du, einen Test zu bekommen, der die Mockito-Einrichtung beweist und nicht die Geschäftsregel. Partial Mock ist als Ausnahme zulässig — zum Beispiel wenn du einen kleinen Nicht-Determinismus-Abschnitt stabilisierst —, aber wenn du die „Hauptlogik" der Klasse ersetzt, hört der Test auf, ein Test zu sein.
GO TO FULL VERSION