Ein Bestellprozess kann jeden Unit-Test bestehen und trotzdem Pakete verschicken, die niemand bezahlt hat. Der Grund ist keine Nachlässigkeit, sondern die Stelle, an der die Logik wohnt: in einem Prozessmodell, in einem Job-Aufbau, in einer Konfiguration. Dieser Artikel zeigt an einem Onlineshop für T-Shirts, wie du dieses Testlevel aufziehst, mit Flowable 8 für den Bestellprozess, mit Spring Batch 6 für den nächtlichen Versandlauf, und wie du die Qualitätsszenarien aus arc42 Kapitel 10 in ausführbare Tests überführst.
Inhalt
- Die Bestellung, die niemand ausgeliefert hat
- Warum alle Unit-Tests grün blieben
- Die Auswahlregel für dieses Testlevel
- Zwei Sorten Zusagen
- Voraussetzungen
- Der Prozess vom Warenkorb zum Paket
- Schritt 1: den Prozess in einer echten Engine starten
- Schritt 2: der Weg, der gut geht
- Schritt 3: die Zahlung schlägt fehl
- Schritt 4: das Shirt ist nicht auf Lager
- Schritt 5: die Zustellung scheitert
- Der Wendepunkt: grüner Test, falscher Prozess
- Die vollständige Testklasse
- Die Abfragen, die du im Test brauchst
- Das zweite Artefakt: der nächtliche Versandlauf
- Qualitätsszenarien aus arc42 als ausführbarer Test
- Brauchst du Cucumber dafür?
- Wie viele Tests gehören auf dieses Level?
- Grenzen: was dieses Testlevel nicht leistet
- FAQ
- Fazit
- Quellen
Die Bestellung, die niemand ausgeliefert hat
Der Shop verkauft bedruckte T-Shirts. Eine Bestellung durchläuft immer dieselben Stationen: Zahlung einziehen, Bestand reservieren, Versandetikett erzeugen, Paket an den Zusteller übergeben, auf die Zustellbestätigung warten. Modelliert ist das als BPMN-Prozess, ausgeführt von einer Prozess-Engine, die Fachlogik steckt in Spring-Beans, die an den Service Tasks hängen.
An einem Dienstag fällt auf, dass eine Bestellung mit abgelehnter Kreditkarte trotzdem ein Etikett bekommen hat. Das Paket ist raus, der Betrag nie eingezogen. Es ist ein Einzelfall, aber er lässt sich reproduzieren: Wenn der Zahlungsdienst die Ablehnung als fachliches Ergebnis meldet statt als Ausnahme, nimmt der Prozess den falschen Ausgang am Gateway.
Der Fehler steht in einer Bedingung im Prozessmodell. Er steht in keiner Java-Klasse.
Warum alle Unit-Tests grün blieben
Die Testabdeckung des Projekts ist gut. Der Zahlungsdienst hat Tests für angenommene und abgelehnte Karten. Der Bestandsdienst hat Tests für vorhandene und fehlende Artikel. Der Etikettendienst hat Tests für gültige und ungültige Adressen. Jeder dieser Tests prüft genau das, wofür seine Klasse zuständig ist, und jeder ist berechtigt.
Keiner von ihnen führt das Prozessmodell aus. Die Reihenfolge der Stationen, die Bedingungen an den Gateways, das Verhalten beim Zeitgeber: all das ist in einer XML-Datei beschrieben, die zur Laufzeit von der Engine interpretiert wird. Für den Java-Compiler ist diese Datei eine Ressource wie ein Bild.
Dieselbe Lücke entsteht überall dort, wo ein Artefakt neben dem Code die Entscheidungen trägt. Ein Batch-Job, dessen Verhalten im Zusammenspiel von Reader, Processor, Writer und Chunk-Größe liegt. Eine Datenbank-Migration, deren Wirkung erst am echten Schema sichtbar wird. Eine Regel-Tabelle, ein Helm-Chart, eine Routing-Konfiguration. Der Code drumherum ist getestet, das Artefakt hat nie jemand ausgeführt.
Die Auswahlregel für dieses Testlevel
Welche Aussage über das Produkt wird erst wahr, wenn alle Teile zusammen laufen? An dieser Frage entscheidet sich, was auf dieses Level gehört.
Dass eine abgelehnte Karte zur Ablehnung führt, ist eine Aussage über den Zahlungsdienst. Sie gehört in einen Unit-Test, dort ist sie in Millisekunden geprüft.
Dass eine abgelehnte Karte niemals zu einem Versandetikett führt, ist eine Aussage über den Verbund. Sie wird erst wahr, wenn Prozessmodell, Gateway-Bedingung und Zahlungsdienst zusammenspielen. Keine einzelne Klasse kann sie einlösen, und kein Mock kann sie beweisen, denn der Mock ist genau die Annahme, die hier zur Debatte steht.
Alles, was die erste Form hat, bleibt unten in der Pyramide, wo es schnell und billig ist. Nur die zweite Form rechtfertigt einen Test, der eine Engine hochfährt.
Zwei Sorten Zusagen
Wendest du die Regel konsequent an, bleiben zwei Gruppen übrig, und sie verhalten sich unterschiedlich.
Die fachlichen Zusagen entstehen erst im Verbund mehrerer Komponenten. Ausdrücklich nicht alle Akzeptanzkriterien, sondern nur diejenigen, die über Komponentengrenzen laufen: kein Etikett ohne Zahlung, keine Retoure ohne Erstattung, keine Reservierung, die nach einem Abbruch stehen bleibt. Diese Schicht ist teuer, weil jeder Test eine Laufzeitumgebung braucht, und sie darf deshalb klein bleiben.
Die Qualitätszusagen betreffen nicht das Was, sondern das Wie gut. Antwortzeit, Durchsatz, Verhalten beim Neustart, Wiederanlauf nach einem Ausfall. Liegt bei euch eine arc42-Dokumentation, dann steht dieser Teil dort schon fertig. Kapitel 10 heißt Qualitätsanforderungen und gliedert sich in den Qualitätsbaum und die Qualitätsszenarien. Ein Qualitätsszenario hält fest, wie sich das System bei einem bestimmten Ereignis verhalten soll, und zwar konkret genug, dass sich hinterher entscheiden lässt, ob es geklappt hat. Genau das braucht ein Test auch: einen Auslöser, ein erwartetes Verhalten und eine Zahl, gegen die geprüft wird.
Diese zweite Gruppe bleibt in den meisten Projekten ein Dokument. Ein Qualitätsziel, das niemand nachmisst, ist eine Absichtserklärung.
Voraussetzungen
Der Artikel nutzt den Stand von August 2026.
- Java 25. Spring Batch 6 setzt mindestens Java 21 voraus, Flowable kommt mit weniger aus.
- JUnit 5. Flowable 8 hat die Unterstützung für JUnit 3 und 4 entfernt, abgekündigt war sie seit 7.2. Spring Batch 6 unterstützt JUnit 4 ebenfalls nicht mehr.
- Flowable 8.0.0, erschienen am 27. Februar 2026, mit offizieller Unterstützung für Spring Boot 4 und Spring 7.
- Spring Boot 4.1, erschienen am 10. Juni 2026.
- Spring Batch 6.0.4 für den zweiten Teil.
- Docker, allerdings nicht für die Engine. Die Prozess-Engine läuft eingebettet im Test. Docker brauchst du für die Datenbank aus Testcontainers, denn eine In-Memory-Datenbank im Test sagt nichts über das echte Schema aus.
Der Kern ist eine einzige Abhängigkeit, die alle Engines und die Spring-Anbindung mitbringt.
<dependency>
<groupId>org.flowable</groupId>
<artifactId>flowable-spring-boot-starter</artifactId>
</dependency>Prozessdefinitionen werden vom Starter automatisch deployt, sobald sie unter classpath*:/processes/ liegen und auf .bpmn20.xml oder .bpmn enden. Im Test brauchst du dafür also keine eigene Deployment-Annotation.
Eine Einstellung gehört in die Testkonfiguration, sonst wird der Test unzuverlässig:
flowable.async-executor-activate=falseDamit arbeitet der asynchrone Executor keine Zeitgeber im Hintergrund ab. Das klingt nach einer Einschränkung, ist aber die Voraussetzung dafür, dass du die Zeit im Test selbst in der Hand hast. Wie das aussieht, steht weiter unten beim Wendepunkt.
Der Prozess vom Warenkorb zum Paket
Der Prozess heißt shirt-order und sieht so aus. Die Kästen sind Service Tasks, die Rauten sind Gateways, der gestrichelte Kasten wartet auf ein Ereignis von außen. Unter jeder Beschriftung steht die Element-ID aus dem BPMN-XML, und genau diese IDs tauchen gleich in den Tests wieder auf.
Interessant an diesem Modell ist nicht der obere Weg, sondern dass die Erstattung an zwei Stellen hängt und dass es drei verschiedene Endzustände gibt. Genau daran entscheidet sich, ob der Prozess korrekt ist.
Die Service Tasks rufen Spring-Beans über Ausdrücke auf und schreiben das Ergebnis in eine Prozessvariable. Für die Zahlung sieht das im Modell so aus:
<serviceTask id="charge-payment" name="Zahlung einziehen"
flowable:expression="${paymentService.charge(orderId, amount)}"
flowable:resultVariable="paymentAccepted"/>Das Gateway dahinter entscheidet an ${paymentAccepted}. Genau diese Zeile war im Fehlerfall falsch, und genau sie prüft kein Unit-Test.
Schritt 1: den Prozess in einer echten Engine starten
Der Test lädt den vollen Spring-Kontext, ersetzt die fachlichen Beans durch Mocks und spricht die Engine über ihre Services an. Beachte @MockitoBean: Das frühere @MockBean ist mit Spring Boot 4 entfallen, die Annotation liegt jetzt unter org.springframework.test.context.bean.override.mockito.
@SpringBootTest
class ShirtOrderProcessTest {
@Autowired
private RuntimeService runtimeService;
@Autowired
private HistoryService historyService;
@MockitoBean
private PaymentService paymentService;
@MockitoBean
private StockService stockService;
private ProcessInstance startOrder() {
return runtimeService.startProcessInstanceByKey("shirt-order",
Map.of("orderId", "A-1001", "sku", "shirt-navy-l", "amount", 2990L));
}
}Die Engine führt dabei das echte Modell aus, mitsamt Gateways, Zeitgebern und Fehlerbehandlung. Ersetzt wird nur die Arbeit hinter einer Station, nicht der Ablauf selbst. Das ist der entscheidende Unterschied zu einem Unit-Test mit Mocks: Hier ist das Prüfobjekt das Modell, nicht die Klasse.
Zwei kleine Helfer nehmen dir in allen folgenden Tests die Wiederholung ab.
private boolean passed(String processInstanceId, String activityId) {
return historyService.createHistoricActivityInstanceQuery()
.processInstanceId(processInstanceId)
.activityId(activityId)
.finished()
.count() > 0;
}
private boolean isFinished(String processInstanceId) {
return historyService.createHistoricProcessInstanceQuery()
.processInstanceId(processInstanceId)
.finished()
.count() == 1;
}Schritt 2: der Weg, der gut geht
Der erste Test hält fest, dass eine bezahlte Bestellung mit einem vorrätigen Shirt beim Kunden ankommt. Auf die Zustellbestätigung wartet der Prozess an einem Receive Task, der von außen ausgelöst wird.
@Test
void shouldShipOrderWhenPaymentAcceptedAndShirtInStock() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(true);
ProcessInstance instance = startOrder();
Execution waiting = runtimeService.createExecutionQuery()
.processInstanceId(instance.getId())
.activityId("await-delivery")
.singleResult();
runtimeService.trigger(waiting.getId(), Map.of("delivered", true));
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "create-label"));
assertTrue(passed(instance.getId(), "hand-over-parcel"));
}Dieser Test ist der unwichtigste im ganzen Artikel. Er beweist, dass der Prozess grundsätzlich läuft, und er wäre auch beim kaputten Modell aus dem ersten Kapitel grün geblieben.
Schritt 3: die Zahlung schlägt fehl
Der Fehler aus der Szene lässt sich als Test formulieren. Wichtig sind die letzten drei Zeilen.
@Test
void shouldNotCreateLabelWhenPaymentDeclined() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "payment-declined"));
assertFalse(passed(instance.getId(), "reserve-stock"));
assertFalse(passed(instance.getId(), "create-label"));
assertFalse(passed(instance.getId(), "hand-over-parcel"));
}Die Gegenprobe ist die Assertion, die in der Praxis am häufigsten fehlt. Ein Test, der nur prüft, dass der Prozess irgendwie beendet wurde, ist bei einem falschen Gateway genauso grün wie bei einem richtigen. Erst der Nachweis, dass der andere Zweig gar nicht betreten wurde, macht aus dem Test eine Zusage.
Merk dir die Form: für jeden Zweig gehört ein bestandener Weg in die Prüfung und die Gegenprobe für alle Wege, die verboten sind.
Schritt 4: das Shirt ist nicht auf Lager
Der zweite Fehlerweg ist interessanter, weil hier Geld zurückfließen muss. Die Zusage lautet nicht nur, dass die Bestellung storniert wird, sondern dass die Erstattung tatsächlich stattfindet, und zwar genau einmal.
@Test
void shouldRefundPaymentWhenShirtOutOfStock() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "refund-payment"));
assertFalse(passed(instance.getId(), "create-label"));
verify(paymentService, times(1)).refund("A-1001");
}Das times(1) ist an dieser Stelle mehr als Kosmetik. Eine doppelte Erstattung ist ein echter Schaden, und ein Modell, das den Erstattungs-Task versehentlich in einer Schleife hängen hat, fällt sonst nicht auf.
Schritt 5: die Zustellung scheitert
Der letzte Zweig verbindet beide Fehlerwege, denn er nutzt denselben Erstattungs-Task wie Schritt 4.
@Test
void shouldBookReturnAndRefundWhenDeliveryFails() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(true);
ProcessInstance instance = startOrder();
Execution waiting = runtimeService.createExecutionQuery()
.processInstanceId(instance.getId())
.activityId("await-delivery")
.singleResult();
runtimeService.trigger(waiting.getId(), Map.of("delivered", false));
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
assertTrue(passed(instance.getId(), "refund-payment"));
verify(paymentService, times(1)).refund("A-1001");
}Der Wendepunkt: grüner Test, falscher Prozess
An dieser Stelle sieht die Testklasse vollständig aus. Vier Wege, vier grüne Tests. Und trotzdem lässt sich das Modell so verändern, dass alle vier grün bleiben und der Prozess falsch ist.
Nimm an, jemand ergänzt einen Zeitgeber: Wenn die Zustellbestätigung nach vierzehn Tagen ausbleibt, soll der Prozess automatisch die Retoure einbuchen. Kein Test oben ändert daran sein Ergebnis, denn alle lösen den Receive Task sofort aus. Der neue Weg existiert im Modell und wird von keiner Zusage berührt.
Weil der asynchrone Executor im Test abgeschaltet ist, liegt der Zeitgeber als Job in der Datenbank und wartet. Du holst ihn dir und führst ihn aus, ohne vierzehn Tage zu warten.
@Test
void shouldBookReturnWhenDeliveryIsNotConfirmedWithinFourteenDays() {
when(paymentService.charge("A-1001", 2990L)).thenReturn(true);
when(stockService.reserve("shirt-navy-l")).thenReturn(true);
ProcessInstance instance = startOrder();
Job timer = managementService.createTimerJobQuery()
.processInstanceId(instance.getId())
.singleResult();
assertNotNull(timer, "Es gibt keinen Zeitgeber an der Zustellung");
managementService.moveTimerToExecutableJob(timer.getId());
managementService.executeJob(timer.getId());
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
verify(paymentService, times(1)).refund("A-1001");
}Die Lehre daraus ist, dass die Testliste aus dem Modell folgen muss. Mehr Tests helfen nichts, solange niemand die Ausgänge zählt. Jeder Ausgang, jeder Zeitgeber, jeder Fehler-Zweig ist eine Zusage. Fehlt für einen davon ein Test, existiert die Zusage nur als Zeichnung.
Die vollständige Testklasse
Zusammengesetzt und mit den Importen sieht das so aus.
package com.example.shop.order;
import org.flowable.engine.HistoryService;
import org.flowable.engine.ManagementService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.runtime.Execution;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.job.api.Job;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringBootTest
class ShirtOrderProcessTest {
private static final String ORDER_ID = "A-1001";
private static final String SKU = "shirt-navy-l";
private static final long AMOUNT = 2990L;
@Autowired
private RuntimeService runtimeService;
@Autowired
private HistoryService historyService;
@Autowired
private ManagementService managementService;
@MockitoBean
private PaymentService paymentService;
@MockitoBean
private StockService stockService;
@Test
void shouldShipOrderWhenPaymentAcceptedAndShirtInStock() {
acceptPaymentAndStock();
ProcessInstance instance = startOrder();
confirmDelivery(instance, true);
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "create-label"));
assertTrue(passed(instance.getId(), "hand-over-parcel"));
assertFalse(passed(instance.getId(), "book-return"));
assertFalse(passed(instance.getId(), "refund-payment"));
}
@Test
void shouldNotCreateLabelWhenPaymentDeclined() {
when(paymentService.charge(ORDER_ID, AMOUNT)).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "payment-declined"));
assertFalse(passed(instance.getId(), "reserve-stock"));
assertFalse(passed(instance.getId(), "create-label"));
assertFalse(passed(instance.getId(), "hand-over-parcel"));
}
@Test
void shouldRefundPaymentWhenShirtOutOfStock() {
when(paymentService.charge(ORDER_ID, AMOUNT)).thenReturn(true);
when(stockService.reserve(SKU)).thenReturn(false);
ProcessInstance instance = startOrder();
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "refund-payment"));
assertFalse(passed(instance.getId(), "create-label"));
verify(paymentService, times(1)).refund(ORDER_ID);
}
@Test
void shouldBookReturnAndRefundWhenDeliveryFails() {
acceptPaymentAndStock();
ProcessInstance instance = startOrder();
confirmDelivery(instance, false);
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
assertTrue(passed(instance.getId(), "refund-payment"));
verify(paymentService, times(1)).refund(ORDER_ID);
}
@Test
void shouldBookReturnWhenDeliveryIsNotConfirmedWithinFourteenDays() {
acceptPaymentAndStock();
ProcessInstance instance = startOrder();
Job timer = managementService.createTimerJobQuery()
.processInstanceId(instance.getId())
.singleResult();
assertNotNull(timer, "Es gibt keinen Zeitgeber an der Zustellung");
managementService.moveTimerToExecutableJob(timer.getId());
managementService.executeJob(timer.getId());
assertTrue(isFinished(instance.getId()));
assertTrue(passed(instance.getId(), "book-return"));
verify(paymentService, times(1)).refund(ORDER_ID);
}
private void acceptPaymentAndStock() {
when(paymentService.charge(ORDER_ID, AMOUNT)).thenReturn(true);
when(stockService.reserve(SKU)).thenReturn(true);
}
private ProcessInstance startOrder() {
return runtimeService.startProcessInstanceByKey("shirt-order",
Map.of("orderId", ORDER_ID, "sku", SKU, "amount", AMOUNT));
}
private void confirmDelivery(ProcessInstance instance, boolean delivered) {
Execution waiting = runtimeService.createExecutionQuery()
.processInstanceId(instance.getId())
.activityId("await-delivery")
.singleResult();
runtimeService.trigger(waiting.getId(), Map.of("delivered", delivered));
}
private boolean passed(String processInstanceId, String activityId) {
return historyService.createHistoricActivityInstanceQuery()
.processInstanceId(processInstanceId)
.activityId(activityId)
.finished()
.count() > 0;
}
private boolean isFinished(String processInstanceId) {
return historyService.createHistoricProcessInstanceQuery()
.processInstanceId(processInstanceId)
.finished()
.count() == 1;
}
}Fünf Tests, fünf Zusagen, und die Namen sind die Zusagen.
Die Abfragen, die du im Test brauchst
Flowable bringt keine eigene Assertion-Bibliothek mit. Das ist kein Mangel, sondern bedeutet nur, dass du mit den normalen Abfragen der Engine arbeitest und die Aussage selbst formulierst. Der Vorrat ist überschaubar.
| Abfrage | Beantwortet |
|---|---|
historyService.createHistoricProcessInstanceQuery().finished() |
Ist die Instanz beendet |
historyService.createHistoricActivityInstanceQuery().activityId(id).finished() |
Wurde dieses Element durchlaufen |
historyService.createHistoricVariableInstanceQuery().variableName(name) |
Welchen Wert hatte eine Variable |
runtimeService.createProcessInstanceQuery().processInstanceId(id) |
Läuft die Instanz noch |
runtimeService.createExecutionQuery().activityId(id) |
Wo genau wartet die Instanz gerade |
taskService.createTaskQuery().processInstanceId(id) |
Welche User Tasks liegen offen |
managementService.createTimerJobQuery().processInstanceId(id) |
Welcher Zeitgeber wartet |
managementService.createDeadLetterJobQuery().processInstanceId(id) |
Was ist endgültig gescheitert |
Die letzte Zeile ist die, an die kaum jemand denkt. Ein Job landet nach erschöpften Wiederholungen im Dead-Letter-Bereich, und die Instanz steht still, ohne dass irgendetwas rot wird. In jeden Test, der einen erfolgreichen Weg beschreibt, gehört deshalb die Prüfung, dass dort nichts liegt.
assertEquals(0, managementService.createDeadLetterJobQuery()
.processInstanceId(instance.getId())
.count());Das zweite Artefakt: der nächtliche Versandlauf
Das Prozessmodell ist nicht das einzige Artefakt in diesem Shop. Nachts läuft ein Batch-Job, der alle übergabebereiten Pakete beim Zusteller anmeldet, die Sendungsnummern zurückschreibt und die Bestellungen auf versandt setzt. Die Fachlogik pro Bestellung ist in einem Processor gekapselt und ordentlich unit-getestet. Was dort nicht steht, ist das Verhalten des Laufs als Ganzes: Chunk-Größe, Commit-Grenzen, Verhalten bei einem kaputten Datensatz, Wiederanlauf nach Abbruch.
In Spring Batch 6 hat sich die Test-API geändert. JobLauncherTestUtils.launchJob() ist seit 6.0 abgekündigt und soll in 6.2 oder später verschwinden. An seine Stelle tritt JobOperatorTestUtils.startJob(). Die Annotation @SpringBatchTest stellt beide Hilfsklassen bereit, also JobOperatorTestUtils und JobRepositoryTestUtils.
@SpringBootTest
@SpringBatchTest
class ParcelHandoverJobTest {
@Autowired
private JobOperatorTestUtils jobOperatorTestUtils;
@Autowired
private OrderRepository orders;
@Test
void shouldHandOverAllReadyParcelsInOneRun() {
orders.saveAll(readyOrders(250));
JobParameters parameters = new JobParametersBuilder()
.addLocalDate("runDate", LocalDate.of(2026, 8, 6))
.toJobParameters();
JobExecution execution = jobOperatorTestUtils.startJob(parameters);
assertEquals(ExitStatus.COMPLETED.getExitCode(), execution.getExitStatus().getExitCode());
assertEquals(250, orders.countByStatus(OrderStatus.HANDED_OVER));
}
}Die eigentliche Zusage steckt aber im nächsten Test. Ein Lauf, der bei einem kaputten Datensatz abbricht, darf die bereits übergebenen Pakete nicht erneut anmelden, wenn er wieder anläuft. Doppelte Anmeldungen kosten Geld und Vertrauen.
@Test
void shouldNotHandOverParcelsTwiceAfterRestart() {
orders.saveAll(readyOrders(120));
orders.save(orderWithInvalidPostcode());
JobParameters parameters = new JobParametersBuilder()
.addLocalDate("runDate", LocalDate.of(2026, 8, 6))
.toJobParameters();
JobExecution failed = jobOperatorTestUtils.startJob(parameters);
assertEquals(ExitStatus.FAILED.getExitCode(), failed.getExitStatus().getExitCode());
orders.fixPostcode(orderWithInvalidPostcode());
JobExecution restarted = jobOperatorTestUtils.startJob(parameters);
assertEquals(ExitStatus.COMPLETED.getExitCode(), restarted.getExitStatus().getExitCode());
assertEquals(121, carrier.countRegistrations());
}Diese Zusage wird erst wahr, wenn Reader, Writer, Chunk-Grenze und das Job-Repository zusammenspielen. Sie ist der Musterfall für dieses Testlevel, und sie ist der Grund, warum die Datenbank aus Testcontainers kommt und nicht aus dem Speicher: Ein Wiederanlauf, der gegen ein anderes Schema läuft als in Produktion, beweist nichts.
Qualitätsszenarien aus arc42 als ausführbarer Test
Bis hierhin ging es um fachliche Zusagen. Der zweite Teil sind die Qualitätszusagen, und der Weg dorthin ist kürzer, als die meisten Projekte annehmen.
Kapitel 10 ordnet die Qualitätsziele als Baum an, nach Wichtigkeit sortiert, und an den Blättern dieses Baums hängen die Szenarien. Jedes davon benennt ein Ereignis, das erwartete Verhalten und die Zahl, an der sich beides prüfen lässt. Drei Bestandteile, immer dieselben:
| Szenario-Teil | Im Test |
|---|---|
| Auslöser | Der Aufbau, also was der Test herstellt und anstößt |
| Reaktion | Das erwartete Verhalten, also die Assertion |
| Messgröße | Die Zahl in der Assertion |
Ein Szenario aus dem Versandlauf könnte lauten, dass ein nächtlicher Lauf über den gesamten übergabebereiten Bestand innerhalb des Wartungsfensters fertig wird. Auslöser ist der Start des Laufs mit einem Bestand in realistischer Größenordnung, Reaktion ist der reguläre Abschluss, Messgröße ist die Dauer des Wartungsfensters.
@Test
void shouldFinishNightlyRunWithinMaintenanceWindow() {
orders.saveAll(readyOrders(50_000));
Instant start = Instant.now();
JobExecution execution = jobOperatorTestUtils.startJob(parametersFor(LocalDate.now()));
Duration elapsed = Duration.between(start, Instant.now());
assertEquals(ExitStatus.COMPLETED.getExitCode(), execution.getExitStatus().getExitCode());
assertTrue(elapsed.compareTo(MAINTENANCE_WINDOW) < 0,
"Lauf brauchte " + elapsed + ", erlaubt sind " + MAINTENANCE_WINDOW);
}Damit daraus kein Ärgernis wird, gehören solche Tests nicht in denselben Durchlauf wie die schnellen. Eine eigene Gruppe, per JUnit-Tag getrennt, und ein Platz in der Pipeline, an dem eine längere Laufzeit niemanden stört.
Die Messgröße ist auf einem Build-Agenten ohnehin eine andere als in Produktion. Ein solcher Test taugt als Regressionsschwelle. Was er fängt, ist die Verschlechterung gegenüber dem letzten Stand.
Und für die Qualitätsszenarien gilt dieselbe Auswahlregel wie für die fachlichen Zusagen. Nur was im Verbund entsteht, gehört hierher. Die Laufzeit einer einzelnen Methode misst ein Mikrobenchmark besser.
Wenn du die Nummerierung aus dem Qualitätsbaum im Testnamen behältst, siehst du an der Testliste sofort, welches Ziel gerade rot ist. Wertvoller als die roten Tests sind allerdings die Ziele, zu denen es überhaupt keinen gibt: Diese Lücke ist der eigentliche Ertrag der Übung.
Brauchst du Cucumber dafür?
Wer Akzeptanzkriterien testet, landet früher oder später bei der Frage nach Gherkin. Cucumber übersetzt Sätze in Given-When-Then-Form auf Schrittdefinitionen im Code, und die Sätze sind für Fachleute lesbar.
Wertvoll ist der Katalog der Zusagen selbst. Ein Team, das Szenarien in Fachsprache sammelt, hat den größten Teil der Arbeit hinter sich, unabhängig vom Werkzeug. Dieser Teil ist wertvoll und hat mit Cucumber wenig zu tun.
Der Preis ist eine zusätzliche Schicht. Jeder Satz braucht eine Schrittdefinition, die Zuordnung driftet, wenn sich die Anwendung ändert, und in der Praxis pflegen Entwickler diese Schicht ohnehin allein. Die Lesbarkeit kommt aus dem Satz, nicht aus dem Format:
@Test
void shouldNotCreateLabelWhenPaymentDeclined() { }Szenario: Kein Etikett bei abgelehnter Zahlung
Angenommen die Zahlung wurde abgelehnt
Wenn die Bestellung verarbeitet wird
Dann wird kein Versandetikett erzeugtBeides sagt dasselbe. Die zweite Fassung braucht dafür eine Datei mehr, eine Bibliothek mehr und eine Zuordnungsebene, die gepflegt werden muss.
Cucumber lohnt sich, wenn Menschen ohne Zugriff auf den Code die Szenarien wirklich lesen oder schreiben, etwa in regulierten Umgebungen mit einer Abnahme durch die Fachseite. Lesen die Szenarien nur Entwickler, ist eine sprechend benannte Testmethode das einfachere Mittel. Sie ist immer aktuell, weil sie derselbe Code ist, der auch läuft.
Wie viele Tests gehören auf dieses Level?
Weniger, als der erste Impuls nahelegt. Ein brauchbarer Maßstab ist die Anzahl der Ausgänge im Artefakt: jeder Endzustand des Prozesses und jeder Zeitgeber. Der Beispielprozess hat drei Endzustände und einen Zeitgeber, daraus werden fünf Tests, und die decken das Modell vollständig ab.
Alles, was sich mit einem Unit-Test beantworten lässt, gehört nicht hierher. Die Prüfung, ob eine Postleitzahl gültig ist, gehört zum Validator. Die Berechnung des Portos gehört zum Tarifdienst. Diese Tests im Verbund zu wiederholen kostet Laufzeit und bringt keine zusätzliche Aussage.
Ein guter Indikator für ein aufgeblähtes Level ist die Fehlersuche: Wenn ein roter Test dich nicht in die Nähe der Ursache bringt, sondern nur sagt, dass irgendetwas im Ganzen nicht stimmt, ist er wahrscheinlich zu hoch angesiedelt.
Grenzen: was dieses Testlevel nicht leistet
Es ersetzt keine Unit-Tests. Ein Verbundtest sagt dir, dass die Zusage gebrochen ist, aber nicht welche Zeile schuld ist. Diese Auflösung liefern die schnellen Tests darunter, und ohne sie wird jede Fehlersuche zur Grabung.
Beobachtbarkeit im Betrieb wird dadurch ebenfalls nicht überflüssig. Kein Testlauf deckt die Fälle ab, die erst mit echten Daten entstehen: die Adresse mit dem Zeilenumbruch, der Zusteller, der zweimal dieselbe Bestätigung schickt. Was hier grün ist, ist geprüft, nicht bewiesen.
Es ist langsam. Ein voller Spring-Kontext samt Engine und Datenbank braucht Zeit, und die Laufzeit steigt mit jedem Test. Deshalb bleibt die Schicht klein, und deshalb ist die Auswahlregel wichtiger als jede einzelne Assertion.
Und es macht aus einem falsch modellierten Prozess kein richtiges Produkt. Ein Test hält fest, was ihr zugesagt habt. Ob die Zusage fachlich sinnvoll war, entscheidet er nicht.
FAQ
Was ist der Unterschied zwischen einem Integrationstest und diesem Testlevel?
Ein Integrationstest prüft das Zusammenspiel technischer Bausteine, etwa Anwendung und Datenbank. Dieses Level prüft eine Zusage an den Kunden, die erst im Verbund entsteht. Entscheidend ist die Frage, die der Test beantwortet. Die Technik dahinter kann dieselbe sein.
Ist Flowable kostenlos?
Die Kern-Engines für BPMN, DMN und CMMN stehen unter Apache 2.0 und sind frei nutzbar, auch kommerziell. Kostenpflichtig sind die Enterprise-Zusätze und der Support des Herstellers.
Warum ist der asynchrone Executor im Test abgeschaltet?
Damit Zeitgeber nicht im Hintergrund abgearbeitet werden, während der Test läuft. Der Job bleibt dann in der Datenbank liegen, und du führst ihn über den ManagementService gezielt aus. Das macht den Test deterministisch statt zeitabhängig.
Brauche ich für die Prozesstests Docker?
Für die Engine nicht, die läuft eingebettet. Für die Datenbank schon, wenn du wie hier mit Testcontainers gegen dasselbe Datenbanksystem testest wie in Produktion.
Muss ich in Spring Batch 6 sofort auf JobOperatorTestUtils umstellen?
Nicht sofort, aber bald. JobLauncherTestUtils.launchJob() ist seit Spring Batch 6.0 abgekündigt und für die Entfernung in 6.2 oder später vorgesehen. Der Umbau betrifft im Wesentlichen den Methodennamen, aus launchJob wird startJob.
Wie halte ich die Qualitätsszenarien und die Tests zusammen?
Über den Namen. Trägt der Test die Nummer des Szenarios aus dem Qualitätsbaum, findet jeder den Weg vom Dokument zum Code und zurück. Ein Verweis in beide Richtungen kostet eine Zeile und spart die Suche.
Gilt das auch ohne Prozess-Engine?
Ja. Das Muster hängt nicht an BPMN, sondern daran, dass ein Artefakt neben dem Code die Entscheidungen trägt. Regel-Tabellen, Migrationen, Routing-Konfigurationen und Infrastruktur-Manifeste haben dieselbe Eigenschaft.
Fazit
Die Testpyramide bleibt richtig, ihr fehlt nur eine kleine Spitze für die Aussagen, die erst im Verbund wahr werden. Zwei Fragen genügen, um sie zu füllen: Welche Zusagen entstehen erst, wenn alle Teile zusammen laufen, und welche davon stehen schon als Qualitätsszenario in eurer Dokumentation?
Der nächste Schritt ist klein. Nimm das Artefakt, das in eurem System die meisten Entscheidungen trägt, zähle seine Ausgänge und schreib für jeden einen Test, dessen Name die Zusage ist. Danach nimm Kapitel 10 eurer Architekturdokumentation und mach aus dem obersten Qualitätsszenario einen Test. Ein Qualitätsziel, das niemand nachmisst, bleibt sonst eine Absichtserklärung.
Quellen
- arc42, Kapitel 10 Qualitätsanforderungen, Qualitätsbaum und Qualitätsszenarien: arc42.de
- DokChess, öffentliches arc42-Beispielprojekt mit vollständigem Qualitätsbaum und durchnummerierten Szenarien: dokchess.de
- Flowable Open Source, Spring-Boot-Anbindung und Testunterstützung: flowable.com
- Flowable Open Source Code und Lizenz Apache 2.0: flowable.com
- Spring Batch Referenz, Unit Testing und Neuerungen in Version 6: docs.spring.io
Alle Code-Beispiele sind eigene und auf den in den Voraussetzungen genannten Versionsstand geschrieben. Der Bestellprozess ist erfunden und bildet kein reales Projekt ab.