Behaviour-Driven Acceptance Tests beschreiben in fachlicher Sprache (Cucumber-Feature-Dateien mit Given/When/Then), was ein System leisten muss, und dienen zugleich als lebende Dokumentation. Damit der kundeneigene Testtext ausführbar wird, verbinden dünne Adapter die Use-Case-Interfaces der Tests mit der eigenen Domäne und übersetzen nur zwischen Basis- und Domänentypen. Diese Seite zeigt, wie ein guter Adapter aussieht und warum Geschäftslogik im Adapter ein gefährliches Anti-Pattern ist: Die Tests bleiben grün, obwohl die Funktionalität nie in der Anwendung ankommt.
Hier bekommen Sie die Acceptance Tests fertig geliefert. Am Anfang steht ein Kunde, der in
einfacher fachlicher Sprache die Szenarien beschreibt, die das System erfüllen muss - die
Cucumber-.feature-Dateien. Um diese Szenarien herum bekommen Sie die Step Definitions, die jede
Zeile in einen Java-Aufruf übersetzen, und die Use-Case-Interfaces, die benennen, was jeder Akteur tun
kann. Sie schreiben zwei Dinge:
An dieser Stelle gehen wir besonders auf die Rolle und die Form dieser Adapter ein, denn genau dort untergräbt eine verlockende Abkürzung die gesamte Architektur. Die Code-Ausschnitte stammen aus dem Beispielprojekt, das diese Seite begleitet, einem kleinen Bahn-Loyalty-System mit drei winzigen Aggregates und ohne Datenbank (jeder Service hält seinen Zustand im Speicher - das ist normalerweise ein Anti-Pattern! Wir machen das hier nur, um das Beispiel klein zu halten. ).
traveller kennt die Zuordnung von einem Benutzernamen zu einer technischen TravellerId.ticket verbucht Ticketkäufe und kann aufsummieren, was ein Reisender ausgegeben hat.loyalty entscheidet, ob ein Reisender als Vielfahrer qualifiziert ist (mindestens 500 € für
Tickets in den sechs abgeschlossenen Monaten vor einem Stichmonat).Es ist dieselbe Domäne wie im Mockito-Beispiel; wenn Sie diese
Seite zuerst gelesen haben, sind TicketService und die Vielfahrer-Regel bereits vertraut.
Ein Behaviour-Driven Acceptance Test beginnt als Gespräch mit dem Kunden, nicht als Code. Der Kunde beschreibt, was das System tun soll, als konkretes, fachlich lesbares Szenario:
Given),When), undThen).Weil es sich wie normale Sprache liest, kann der Kunde bestätigen, dass es wirklich das trifft,
was er gemeint hat, und es dient noch lange als lebende Dokumentation. Erst danach machen
Entwickler es ausführbar, indem sie jede Given-/When-/Then-Zeile an ein kleines Stück
Glue Code binden. Hier ein Szenario aus diesem Projekt, genau so, wie es in Loyalty.feature steht:
Scenario: A traveller who spent at least 500 euro in the window qualifies
Given a traveller "johndoe" is registered
And the clerk sells "johndoe" a ticket for 300.00 euro on "10.12.2025"
And the clerk sells "johndoe" a ticket for 250.00 euro on "05.11.2025"
When the loyalty desk checks whether "johndoe" is a frequent traveller as of "01.2026"
Then "johndoe" should be recognised as a frequent traveller
Beachten Sie, dass hier nichts von Java, Spring, einer Datenbank oder einem Klassennamen die Rede ist. Genau das ist der Punkt: Das Szenario gehört dem Kunden und ist in der Sprache der Domäne formuliert, sodass es selbst dann noch Sinn ergäbe, wenn das gesamte System dahinter von Grund auf neu geschrieben würde. Die Aufgabe von allem Folgenden ist es, diesen kundeneigenen Text als Test laufen zu lassen, ohne je technische Details wieder nach oben durchsickern zu lassen.
Eine einzelne Then-Zeile durchläuft mehrere Schichten, bevor sie Ihre Domäne erreicht. Diesen Weg
zu verstehen ist der Schlüssel, um den Adapter richtig zu platzieren:
Loyalty.feature das lesbare Szenario (Kunde / Lehrperson)
↓
LoyaltyProgramSteps Step Definitions, der "Glue Code" (Kunde / Lehrperson)
↓
LoyaltyUseCase das Use Case Interface, nur Basistypen (Kunde / Lehrperson)
↓
LoyaltyAdapter der Adapter zum eigentlichen Code (Entwickler:in / Student:in)
↓
LoyaltyService der Application Service (Entwickler:in / Student:in)
↓
FrequentTravellerRule Ihre Domänenlogik (Entwickler:in / Student:in)
Die Step Definitions injizieren die Use Case Interfaces, nie die Adapter oder Services direkt:
@Autowired
private LoyaltyUseCase loyaltyUseCase;
Spring injiziert den mit @Service annotierten Adapter hinter diesem Use Case Interfaces.
Der Glue Code spricht nur die Basistypen des Vertrags (String, double, UUID). Von
Ihrer TravellerId oder Ihrem Price weiß er nichts - weil er vom Kunden kommt und Ihren
Code gar nicht kennen soll!
Ein guter Adapter ist eine dünne Übersetzungsschicht mit drei Eigenschaften, und nicht mehr:
Das ist die gesamte Aufgabe. Hier der Ticketverkaufs-Adapter:
@Service
public class TicketSalesAdapter implements TicketSalesUseCase {
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern( "dd.MM.yyyy" );
private final TicketService ticketService;
public TicketSalesAdapter( TicketService ticketService ) {
this.ticketService = ticketService;
}
@Override
public void sellTicket( String username, double priceInEuro, String purchaseDate ) {
ticketService.purchaseTicket( username, Price.ofEuros( priceInEuro ),
LocalDate.parse( purchaseDate, GERMAN_DATE ) );
}
@Override
public double totalSpentBy( String username ) {
return ticketService.totalSpentBy( username ).amountInEuro().doubleValue();
}
}
Achten Sie darauf, was nicht da ist:
Der double wird zum Price, der Datums-String zum LocalDate, der zurückgegebene Price wieder
zum double, und der Aufruf geht direkt an den Service. Der Adapter wirkt damit wie ein
Anti-Corruption Layer: Er hält die Basistypen des Testvertrags aus Ihrer Domäne heraus, und
Ihre Domänentypen aus dem Test heraus.
Der Loyalty-Adapter hat dieselbe Form, obwohl sein Use Case nach mehr Arbeit klingt:
@Service
public class LoyaltyAdapter implements LoyaltyUseCase {
private static final DateTimeFormatter GERMAN_MONTH = DateTimeFormatter.ofPattern( "MM.yyyy" );
private final LoyaltyService loyaltyService;
public LoyaltyAdapter( LoyaltyService loyaltyService ) {
this.loyaltyService = loyaltyService;
}
@Override
public boolean isFrequentTraveller( String username, String referenceMonth ) {
return loyaltyService.isFrequentTraveller(
username, YearMonth.parse( referenceMonth, GERMAN_MONTH ) );
}
}
“Ist das ein Vielfahrer?” zu beantworten braucht die Tickets des Reisenden und eine
Qualifikationsregel. Der Adapter tut beides nicht: Er parst den Monat und delegiert. Die
Orchestrierung liegt im LoyaltyService, der die Tickets über den Service des Ticket-Aggregats holt
und die Regel anwendet. Das ist der legitime Ort für aggregatsübergreifende Koordination.
Alles, was ein Adapter in diesem Beispiel tut, ist eine dieser mechanischen Konvertierungen:
| Im Use Case Interfaces (Basistyp) | In der Domäne (Domänentyp) | Erledigt durch |
|---|---|---|
UUID |
TravellerId |
TravellerId.value() / wrap |
double Euro |
Price |
Price.ofEuros(...) |
String "15.01.2026" |
LocalDate |
LocalDate.parse(...) |
String "01.2026" |
YearMonth |
YearMonth.parse(...) |
Wenn eine Methode in Ihrem Adapter etwas anderes tut als übersetzen-und-delegieren, ist das das Signal innezuhalten und die Logik in einen Service zu verschieben.
Hier derselbe Loyalty-Use-Case, falsch umgesetzt. Das Beispiel liefert diese Klasse als
adapters/antipattern/LoyaltyOrchestratingAdapter mit (bewusst kein @Service, sie läuft also nie):
public class LoyaltyOrchestratingAdapter implements LoyaltyUseCase {
private final TicketService ticketService;
private final TravellerService travellerService;
private final FrequentTravellerRule frequentTravellerRule = new FrequentTravellerRule();
// ... Konstruktor ...
@Override
public boolean isFrequentTraveller( String username, String referenceMonth ) {
// ... ruft ein 2. Aggregate ...
travellerService.findByUsername( username );
// ... ruft ein 3. Aggregate ...
List<Ticket> tickets = ticketService.findTicketsOf( username );
YearMonth month = YearMonth.parse( referenceMonth, ... );
// ... und führt eine Regel aus
return frequentTravellerRule.qualifiesAsFrequentTraveller( tickets, month );
}
}
Das Beunruhigende ist: Diese Variante besteht den Acceptance Test. Gerade die grünen Tests machen die Abkürzung gefährlich, deshalb lohnt sich Genauigkeit darüber, was schiefgegangen ist. Zwei Dinge sind es, und beide reichen über bloße Ordnung hinaus.
Das Holen der Tickets und das Anwenden der Regel geschieht jetzt im Test-Adapter,
nicht in einem Loyalty-Service. Der LoyaltyService bleibt leer. Solange der einzige
Aufrufer der Acceptance Test ist, fällt das niemandem auf. Aber in einem späteren
Milestone setzen Sie einen REST-Controller vor das System, und dieser Controller ruft den
LoyaltyService auf, in dem das Vielfahrer-Verhalten schlicht nicht ist. Die Funktionalität, die Sie
“implementiert” haben, lebt in Testcode und war nie Teil der Anwendung.
Ein Bild der beiden Kontrollflüsse macht die Lücke greifbar. Der Acceptance Test erreicht die Regel, weil er über den Adapter läuft; der REST-Controller geht direkt zum leeren Service und berührt sie nie.
flowchart TB
subgraph test["Pfad des Acceptance-Tests (bleibt grün)"]
ST["Cucumber-Step"] --> AD["LoyaltyOrchestratingAdapter<br/>‹enthält die Regel›"]
AD --> OTH["TicketService /<br/>TravellerService"]
end
subgraph prod["Pfad im echten Betrieb (REST)"]
RC["REST-Controller"] --> LS["LoyaltyService<br/>(leer - Regel läuft nie)"]
end
classDef bad fill:#fdd,stroke:#c00,color:#900;
classDef empty fill:#eee,stroke:#999,color:#666;
class AD bad;
class LS empty;
Die beiden Pfade treffen sich nie. Der einzige Ort, an dem die Regel existiert, ist eine Box, die der echte Betrieb nie betritt, und genau deshalb ist das Ergebnis dort falsch.
Der gute Adapter hängt von einem Service ab; dieser hier hängt von drei Aggregaten gleichzeitig ab und “näht sie selbst zusammen”. Wo immer der natürliche Ort für diese Orchestrierung ein Service wäre, verschiebt das Erledigen im Adapter die Abhängigkeit in eine Schicht, die die Zyklen- und Grenzprüfungen nie inspizieren.
Die Lösung besteht nie darin, den schlechten Adapter “aufzuhübschen”. Sie besteht darin, die Orchestrierung und die Regel in einen Service zu verschieben und den Adapter delegieren zu lassen, womit die zweite Variante wieder zur ersten wird.
In unsere Bahn-Domäne gibt es keinen Zykel (damit das Beispiel nicht zu groß wird), aber nehmen wir mal an, der TravellerService müsste wissen, ob ein Reisender ein Vielfahrer ist, um ihn zu laden. Dann gäbe es eine zyklische Abhängigkeit zwischen den Aggregaten:
TicketService --> LoyaltyService --> TravellerService --> TicketService
Ein Test auf Zyklen würde die finden, wenn sie in einem Service stattfände. Aber die Adapter sind Test-Code und werden selbst nicht mitgetestet, sodass die Zyklenprüfung sie nicht sieht. Wenn man dann die Orchestrierung in den Adapter auflösen muss (siehe 1.), dann hat man die zyklische Abhängigkeit plötzlich in den Services.
Benötigt Java 21 und Maven 3.9+.
# vom Repository-Wurzelverzeichnis: nur dieses Modul bauen und testen
mvn install -pl bdd-acceptance-tests-with-adapters
# oder aus dem Modulverzeichnis heraus
mvn test
Der Lauf führt den JUnit-Unit-Test, die ArchUnit-Adapterregeln und die fünf Cucumber-Szenarien aus, die Acceptance Tests und Ihre eigenen Tests nebeneinander in einer Suite.