Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

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.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/testing-good-practices/-/tree/main/bdd-acceptance-tests-with-adapters

Behaviour-Driven Acceptance Tests mit Use-Case-Interfaces und Adaptern

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:

  1. den Code Ihres Systems, und
  2. die Adapter, die die Interfaces der Tests mit dieser Domäne verbinden.

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.

Wie Behaviour-Driven Acceptance Tests funktionieren

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:

  • eine Ausgangssituation (Given),
  • eine Aktion (When), und
  • das erwartete Ergebnis (Then).

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.

Der Weg eines Szenarios

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!

Was ein guter Adapter ist

Ein guter Adapter ist eine dünne Übersetzungsschicht mit drei Eigenschaften, und nicht mehr:

  1. Er implementiert genau ein Use Case Interface
  2. Er delegiert an genau einen Application Service eines Aggregats
  3. Er konvertiert zwischen den Basistypen des Use Case Interfaces und den Domänentypen des Service

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:

  1. Keine Prüfung, ob der Reisende existiert
  2. keine Entscheidung darüber, was ein gültiger Preis ist
  3. keine Regel über irgendetwas

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.

Die Übersetzungen, aufgelistet

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.

Das Anti-Pattern: Geschäftslogik im Adapter

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.

1. Geschäftslogik hat die Domäne verlassen

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.

2. Eine aggregatsübergreifende Abhängigkeit wurde versteckt

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.

3. … und eine zyklische Abhängigkeit hätte versteckt sein können

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.

Ausführen

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.