1. Wahl: ohne Kontext oder mit
Wenn man anfängt, Tests in einem Spring-Projekt zu schreiben, tappt man leicht in die Falle: „Da wir Spring haben, lasst uns doch alles über Spring testen – das ist seriöser“. Das ist etwa so, als würde man mit einem Abschleppwagen Brot holen: Theoretisch kommt man an, aber die Freude ist gering, die Kosten sind seltsam und das Parken ist schmerzhaft. Der Spring-Kontext hat seinen Preis: Startzeit, Diagnosekomplexität, jede Menge Infrastruktur und riesige Stacktraces, die einen besser vom Wesentlichen ablenken als jede Telefonbenachrichtigung.
Zu diesem Zeitpunkt kann ContextFlow bereits mehr als eine gewöhnliche Sammlung von POJOs mit ein paar new: Es enthält Profile, Eigenschaften, Ereignisse, AOP und sogar einen kleinen Legacy-XML-Rest. Das bedeutet, dass wir nun zwei verschiedene Überprüfungen trennen müssen: Was ist einfach die Logik der Klasse und was existiert nur, weil der Container die Anwendung genau so zusammengestellt hat.
Die Hauptidee der heutigen Vorlesung ist nicht „Unit-Tests vs. Kontexttests“ als Glaubenskrieg, sondern eine einfache technische Regel: Bevor Sie einen Test schreiben, formulieren Sie eine Hauptfrage, die er beantwortet. Wenn die Frage die Logik der Klasse betrifft, brauchen Sie den Container nicht. Wenn die Frage das Zusammenbauen der Anwendung durch den Container betrifft (Wiring, Profile, Eigenschaften, Ereignisse, Legacy-XML), verliert der Test ohne Kontext seinen Sinn.
Um das ganz greifbar zu machen, fassen wir den Unterschied in einer kleinen Tabelle zusammen – nicht als „Spickzettel für die Prüfung“, sondern als Landkarte:
| Was Sie überprüfen möchten | Normalerweise geeignet | Warum |
|---|---|---|
| Algorithmus / Regel / Berechnung (reine Logik) | Unit-Test ohne Spring | Sie erstellen das Objekt über new, geben den Abhängigkeiten einfache Stubs, und ein Testfehler zeigt direkt auf den Regelcode |
| Dass die Klasse den richtigen „Nachbarn“ aufruft (Delegation) | Unit-Test ohne Spring + Stub | Der Container fügt keinen Mehrwert hinzu: Sie überprüfen die Interaktion von Objekten, nicht den Graphaufbau |
| Dass der Kontext überhaupt startet und aufgebaut wird | Kontexttest | Dies ist eine Überprüfung der Konfiguration und des Wiritngs – ohne Kontext erfahren Sie nicht, dass die Anwendung tatsächlich nicht startet |
| Dass im Profil test die richtigen Beans eingesetzt werden | Kontexttest | Profile sind Containermechanik; von Hand „so tun, als ob man testet“ – ist nicht dasselbe |
| Dass @EventListener tatsächlich auslöst | Kontexttest | Listener werden vom Container registriert; ohne Kontext testen Sie keine Spring-Ereignisse, sondern normale Methodenaufrufe |
Wenn Sie eine ganz kurze „Entscheidungsbaum“ wollen, sieht er so aus:
flowchart TD
A["Ich schreibe einen Test"] --> B["Meine Frage betrifft die Logik der Klasse?"]
B -->|Ja| C["Unit-Test (new + stub)"]
B -->|Nein| D["Meine Frage betrifft den Build/das Verhalten von Spring?"]
D -->|Ja| E["Kontexttest (ApplicationContext)"]
D -->|Nein| F["Die Frage ist höchstwahrscheinlich vage formuliert – Ziel des Tests präzisieren"]
In ContextFlow ist das besonders wichtig, weil wir bereits eine Menge „leckere“ Container-Features gesammelt haben: profiles, properties, conversion, events, AOP, legacy XML. All das kann man leicht mit einer Konfiguration kaputt machen – und es ist schwer, manuell zu diagnostizieren, wenn es keine Tests gibt.
2. Unit-Test ohne Spring
Ein Unit-Test ist der Moment, in dem man Spring höflich bitten kann, im Hintergrund zu bleiben. Sie nehmen die Klasse, erstellen sie mit einem einfachen new, setzen die Abhängigkeiten von Hand ein und überprüfen das Verhalten. Für Anfänger ist das sogar psychologisch nützlich: Sie verstecken sich nicht hinter dem Container, sondern sehen, welche Objekte tatsächlich an der Arbeit beteiligt sind. Und ja, das passt perfekt zur Spring-Philosophie über POJOs: „Ihre Dienste sind normale Klassen, Spring hilft nur, sie zusammenzusetzen.“
Um nicht abstrakt zu bleiben, nehmen wir ein winziges Fragment aus unserer Domäne: einen Dienst, der Benachrichtigungen über den Port NotificationSender sendet. Wir müssen keinen Kontext hochziehen, um zu überprüfen, ob der Dienst das Senden tatsächlich delegiert.
Zuerst das Interface (Port), das in unserem Projekt bereits in domain.ports existiert:
public interface NotificationSender {
// Vertrag des Ports: Dem Dienst ist es egal, WIE die Nachricht gesendet wird – Hauptsache sie wird gesendet
void send(String message);
}
Danach – ein Stub (einfache Test-Ersatzimplementierung), der sich die letzte Nachricht merkt:
public class StubNotificationSender implements NotificationSender {
// Im Stub halten wir die Tatsache der Interaktion fest (was genau gesendet wurde),
// ohne zu versuchen, „wirklich“ in SMS/E-Mail/Queue zu senden.
String lastMessage;
@Override
public void send(String message) {
// Merken uns das Argument – der Test wird später prüfen, ob der Dienst den richtigen Wert übergeben hat
this.lastMessage = message;
}
}
Jetzt der Dienst, den wir testen möchten:
public class NotificationDispatchService {
// Abhängigkeit über Interface (Port) – das macht den Unit-Test einfach
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
this.sender = sender;
}
public void dispatch(String message) {
// Die gesamte Logik hier: Delegation – wir prüfen, ob der Aufruf tatsächlich an den sender geht
sender.send(message);
}
}
Und der eigentliche Test mit JUnit Jupiter (ohne Spring, ohne Kontext, ohne Magie und ohne „wo sind die Annotationen?“):
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NotificationDispatchServiceTest {
@Test void dispatchDelegatesToSender() {
// Arrange: Abhängigkeit ersetzen
var sender = new StubNotificationSender();
// Arrange: getesteter Dienst – von Hand erstellt, ohne Container
var service = new NotificationDispatchService(sender);
// Act: Aktion ausführen
service.dispatch("order-created");
// Assert: prüfen, dass die Nachricht die Abhängigkeit erreicht hat
assertEquals("order-created", sender.lastMessage);
}
}
Bei diesem Test ist wichtig, dass er genau eine Frage beantwortet: „Sendet der Dienst die Nachricht tatsächlich über die Abhängigkeit?“. Wenn er fehlschlägt, liegt die Ursache entweder in dieser Klasse oder im Stub – also an einer Stelle, die Sie vor Augen haben. Kein „Kontext wurde nicht hochgefahren, weil PropertySource nicht gefunden wurde“, wenn Sie eigentlich nur eine Methode testen wollten.
Um zu zeigen, dass Unit-Tests nicht nur für „Delegation“ nützlich sind, nehmen wir ein Beispiel etwas näher an der Geschäftslogik: Rabatt. Diesen kann und sollte man ohne Spring testen, denn Rabatt ist eine Regel, keine Container-Funktion.
import java.math.BigDecimal;
public interface DiscountPolicy {
// Rabattrichtlinie: Eingabe Betrag, Ausgabe Betrag nach Anwendung der Regel
BigDecimal apply(BigDecimal original);
}
import java.math.BigDecimal;
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public BigDecimal apply(BigDecimal original) {
// „Nullrabatt“ – gibt den Betrag unverändert zurück
return original;
}
}
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NoDiscountPolicyTest {
@Test void keepsOriginalAmount() {
// Wichtig: BigDecimal besser aus einem String erstellen, um Überraschungen mit binärer Genauigkeit zu vermeiden
var policy = new NoDiscountPolicy();
assertEquals(new BigDecimal("100.00"), policy.apply(new BigDecimal("100.00")));
}
}
Ja, das ist ein einfaches Beispiel. Es zeigt aber das Prinzip: Logik muss nicht „innerhalb von Spring leben“, um Teil einer Spring-Anwendung zu sein. Im Gegenteil: Je mehr Logik Sie ohne Container überprüfen können, desto ruhiger leben Sie.
3. Stub-Objekte
Wenn Menschen zum ersten Mal von „Ersatz“ in Tests hören, denken sie oft sofort an Mocking-Frameworks und beginnen von Mockito als Superkraft zu träumen: „Jetzt mocke ich hier alles und die Welt wird vorhersagbar“. Manchmal – ja. Aber auf unserem Niveau (und im Rahmen des Kurses) reicht uns meist ein Stub: eine kleine Klasse, die das Interface implementiert und sich maximal einfach verhält. Das ist wie ein Stuntman: Er muss nicht genial spielen, seine Aufgabe ist es, die Szene sicher zu überstehen.
Der Stub hilft dem Unit-Test, klein und ehrlich zu bleiben. Statt eines „echten“ SMS-Versenders oder Dateischreibers verwenden wir ein Objekt, das sich nur merkt, dass etwas zu ihm kam. So testen wir das Verhalten der getesteten Klasse, nicht die Nebeneffekte der Infrastruktur.
Zum Beispiel ist in ContextFlow das Audit ein separater Port AuditWriter. In Unit-Tests wollen wir normalerweise nicht in die Konsole oder in eine Datei schreiben (laut, langsam, manchmal fehlerhaft auf CI). Wir wollen verstehen: „Wurde das Audit überhaupt aufgerufen?“. Ein Stub dafür könnte so aussehen:
import java.util.ArrayList;
import java.util.List;
public class StubAuditWriter implements AuditWriter {
// Speichern alle Nachrichten, damit der Test prüfen kann:
// 1) dass das Audit aufgerufen wurde
// 2) wie oft es aufgerufen wurde
// 3) welche Nachrichten genau dort gelandet sind
final List<String> messages = new ArrayList<>();
@Override
public void write(String message) {
// „Erfassen des Aufrufs“ ohne I/O, ohne Dateien, ohne Konsole
messages.add(message);
}
}
Hier gibt es eine wichtige Nuance: Der Stub sollte primitiv sein, sonst wird er zu einer „zweiten Implementierung des Systems“, die Sie anstelle der Hauptimplementierung testen. Wenn Sie feststellen, dass Ihr Stub Verzweigungen, Konfiguration, komplexe Logik und die Hälfte des Domänenmodells enthält – dann haben Sie ein kleines Framework gebaut, herzlichen Glückwunsch… aber die Tests werden dadurch in der Regel nicht glücklicher.
In unseren Vorlesungen und im Projekt ContextFlow haben wir vorab eine wichtige Sache getan: Die Interaktion mit der Außenwelt in Ports (domain.ports) ausgelagert. Das bedeutet, dass es für Unit-Tests sehr einfach ist, Stubs zu schreiben: Interface implementieren – und fertig.
4. Kontexttest in Spring
Ein Kontexttest ist der Fall, wenn Sie nicht „eine Klasse im Vakuum“ testen, sondern die Arbeit von Spring als Builder Ihrer Anwendung. Und hier ist es sehr wichtig, sich nicht selbst zu belügen: Wenn Sie alle Abhängigkeiten über new erstellen und manuell „als ob Sie die Anwendung zusammenbauen“, dann testen Sie… Ihren manuellen Zusammenbau, nicht Spring. Das ist manchmal nützlich (z. B. für Unit-Tests), aber es beantwortet nicht die Frage „Wird der Container es genauso zusammenbauen?“.
Kontexttests werden dort benötigt, wo Ihr Verhalten nur dank des Containers entsteht. In ContextFlow gibt es viele solcher Stellen: aktive Profile, Laden von Properties, Conversion, Listener über @EventListener, Legacy-XML-Bridge, Post-Processors, Proxy/AOP. All das ist nicht „einfach Java-Code“. Es ist Containermechanik, und wenn Sie Vertrauen darin haben wollen, dann heben Sie den Kontext hoch.
Den Kontext von Hand hochzufahren ist einmal nützlich: So sieht man besser, was der Container genau macht. Aber das ist bewusst eine Zwischenlösung. Sobald es mehr solcher Tests gibt, möchte man Bootstrap und Schließen des Kontexts an Spring Test abgeben, statt sie in jeder Methode von Hand zu erledigen.
In dieser Vorlesung verwenden wir noch nicht die Spring-Test-Integration (die kommt in der nächsten Vorlesung), sondern zeigen nur die minimale Bedeutung: Der Kontext kann direkt im Test manuell hochgefahren werden, um den Unterschied zu sehen.
Konfiguration für den Test:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class TestConfig {
// Einfachster Bean, damit der Test prüfen kann: „Der Kontext wurde hochgefahren und kann Beans ausliefern“
@Bean
String appName() {
return "contextflow-test";
}
}
Und der Test, der genau die Containerregistrierung überprüft:
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ContextPresenceTest {
@Test void readsBeanFromContext() {
// Wichtig: Der Kontext ist eine Ressource, sie muss geschlossen werden.
// try-with-resources garantiert das Schließen auch bei Fehlschlagen des Asserts.
try (var ctx = new AnnotationConfigApplicationContext(TestConfig.class)) {
// Überprüfen, ob der Bean tatsächlich vom Container registriert und aus dem Kontext aufgelöst wird
assertEquals("contextflow-test", ctx.getBean(String.class));
}
}
}
Das ist kein „nützlicher Business-Test“ (und soll es auch nicht sein). Sein Sinn ist ein anderer: zu zeigen, dass die Frage des Tests den Container betrifft. Wenn @Bean verschwindet, wenn die Konfigurationsklasse nicht registriert wird, wenn der Kontext nicht startet – dann fällt der Test durch. Und genau das wollen wir abfangen, wenn wir über Kontexttests sprechen.
In ContextFlow beantworten Kontexttests normalerweise Fragen wie: „Verwenden wir im Profil test tatsächlich DeterministicOrderIdGenerator?“, „Ist MessageSource konfiguriert?“, „Ist das Legacy-XML-Fragment angebunden?“, „Sind die Event-Listener tatsächlich registriert?“. Diese Fragen lassen sich mit einem einzigen new nicht ehrlich überprüfen.
5. Grenze in ContextFlow
Lassen Sie uns das nun auf unser Projekt herunterbrechen, denn eine abstrakte Testphilosophie ohne Codebezug klingt zwar schön, schützt vor Bugs aber etwa so gut wie ein inspirierendes Zitat auf einer Tasse. In ContextFlow haben wir die Schichten bewusst getrennt: Domäne und Application-Services sollten relativ „POJO-kompatibel“ sein, und die Container-Magie lebt in config/support und der Infrastruktur. Das ist kein Zufall – das macht das Testen einfacher.
Unten sehen Sie eine praktische Karte, wo meistens die Grenze verläuft. Es ist kein Naturgesetz, sondern eine Orientierungshilfe, damit Sie nicht an jeder Klasse 30 Minuten hängen bleiben, um zwischen „Unit oder Context“ zu wählen.
| Teil von ContextFlow | Beispiel Mechanik/Klasse | Was sinnvoll zu prüfen ist | Testtyp |
|---|---|---|---|
| domain.model | Invarianten von OrderItem, Status | dass keine „unsinnigen“ Objekte erstellt werden, korrekte Übergänge | unit |
| application.service | OrderPricingService | Berechnungen, Regeln, Reaktionen auf Eingabedaten | unit (stub ports) |
| application.service | OrderPlacementService | Orchestrierung als Schrittfolge (teilweise) | unit oder Szenario mit Kontext (abhängig vom Ziel) |
| infrastructure.reporting | TextReportFormatter | String-Formatierung, Berichtsstruktur | unit |
| support.conversion | String -> NotificationChannel converter | Reaktion auf email, sms, ungültige Werte | unit (für converter) |
| config.profiles | @Profile-Zweige | dass in test/demo die richtigen Implementierungen ausgewählt werden | context |
| domain.events + listeners | OrderCreatedEvent | dass das Ereignis veröffentlicht wird und die Listener auslösen | context |
| config.legacybridge | @ImportResource | dass das XML tatsächlich angebunden ist und Beans liefert | context |
| support.aop | Aspekt und Proxy | dass die Services zu Proxy wurden und der Advice angewendet wurde | context (wenn überhaupt getestet) |
Beachten Sie eine interessante Überlegung: Derselbe Klasse kann manchmal auf unterschiedliche Weise getestet werden. Beispielsweise kann man OrderPlacementService als „Aktionssequenz“ mit einem Unit-Test und Stubs abdecken (schnell, lokal). Aber wenn Ihr Ziel ist, den Event-Flow und Listener zu überprüfen, dann machen Sie bereits einen kontextbezogenen Szenario-Test, weil Sie sonst keine Spring-Events prüfen, sondern „manuelle Methodenaufrufe“.
6. Ein Test – eine Frage
Das häufigste Problem eines Anfängers beim Testen eines Spring-Projekts ist ein Test, der versucht, „einfach alles“ zu überprüfen. Er hebt den Kontext hoch, erstellt eine Bestellung, prüft den Rabatt, prüft, ob der Listener ein Audit geschrieben hat, prüft, ob der Bericht in einer Datei gespeichert wurde … und dann fällt er durch, weil irgendwo ein Property-Schlüssel geändert wurde. Am Ende sitzen Sie da und denken: „Moment mal, habe ich den Rabatt oder das Lesen der Properties getestet?“.
Das Vermischen von Zielen verschlechtert fast immer die Diagnose. Ein Unit-Test ist gut, weil er aus einem Grund ausfällt, der mit der getesteten Klasse zusammenhängt. Ein Kontexttest ist gut, weil er aus einem Grund ausfällt, der mit dem Container und der Konfiguration zusammenhängt. Wenn man sie mischt, erhält man „fällt aus irgendeinem Grund aus“ – und das ist genau der Fall, in dem Tests anfangen zu nerven und man sie abschalten möchte (und dann versehentlich die Produktion abschaltet).
Eine praktische Regel, die wirklich funktioniert: Versuchen Sie im Geiste den Satz zu beenden: „Dieser Test dient dazu, sicherzustellen, dass…“. Wenn Sie ihn nicht mit einem Satz beenden können, hat der Test mehrere Ziele und sollte aufgeteilt werden. In ContextFlow würde das etwa so aussehen: Ein separater Unit-Test prüft, ob der Rabatt korrekt angewendet wird; ein separater Kontext-Smoke-Test prüft, ob DiscountPolicy überhaupt aufgelöst wird und nicht mit anderen Implementierungen kollidiert; ein separater Event-Flow-Test prüft, ob nach OrderCreatedEvent der Listener ausgelöst hat.
Ja, es werden mehr Tests. Dafür wird jeder einfacher, schneller und ehrlicher. Und, was schön ist, Sie werden nicht das Gefühl haben, dass „Spring mit Zaubersprüchen getestet wird“.
7. Typische Fehler bei der Wahl zwischen Unit- und Kontexttest
Fehler Nr. 1: Den Spring-Kontext „der Seriosität halber“ hochfahren, wo ein einfaches new ausreicht.
Normalerweise sieht das so aus: Sie wollen 5 Zeilen Logik überprüfen, starten aber den Kontext, ziehen die Konfiguration mit, und der Test fällt wegen Wiring durch. Das macht den Test nicht „integrativer“, sondern weniger verständlich. Wenn Ihre Frage nicht Spring betrifft, ist Spring im Test nur unnötiges Rauschen.
Fehler Nr. 2: Versuchen, die Containermechanik durch manuelles Zusammenbauen von Objekten zu prüfen.
Zum Beispiel „prüfen Sie Profile“, aber im Test erstellen Sie selbst ein new DeterministicOrderIdGenerator und freuen sich. Das prüft, dass Sie new schreiben können, aber nicht, dass @Profile("test") tatsächlich funktioniert. Containermechaniken werden mit Kontext getestet – sonst umgehen Sie einfach den Prüfgegenstand.
Fehler Nr. 3: In einem Test die Überprüfung der Geschäftslogik und die Überprüfung des Wiritngs vermischen.
Zuerst scheint es praktisch: „Ein Test pro Szenario“. In der Praxis bekommen Sie einen Test, der bei jeder Änderung kaputtgeht und die Fehlerursache unklar ist. Besser trennen: Unit-Tests fangen Logikfehler, Kontexttests fangen Fehler beim Zusammenbau der Anwendung.
Fehler Nr. 4: Keinen Stub verwenden, wo eine echte Abhängigkeit laute Seiteneffekte erzeugt.
In Unit-Tests sind echte Schreibvorgänge in Dateien, Konsole und alles, was „von der Umgebung abhängt“, besonders gefährlich. Selbst wenn der Test bei Ihnen läuft, kann er auf CI oder einer anderen Maschine flackern. Ein Stub gibt Kontrolle: Sie erfassen die Tatsache der Interaktion, verlassen sich nicht auf „na ja, die Datei wird wohl erstellt“.
Fehler Nr. 5: Den Stub „klüger“ machen als die getestete Klasse.
Wenn sich der Stub in ein komplexes Ding mit Logik und Zustand verwandelt, testen Sie unbemerkt den Stub. Die Tests bestehen, aber das reale System bricht zusammen, weil der Stub zu „perfekt“ war. Ein Stub sollte dumm und vorhersagbar sein – wie ein echtes Bügeleisen: eingeschaltet, es wird heiß, keine Überraschungen.
GO TO FULL VERSION