TestSupport-StateMachine Tutorial
Leichtgewichtige State-Machine-Engine als Activiti-Ersatz — für Einsteiger erklärt
Zuletzt aktualisiert: 19.04.2026
Zielgruppe: Java-Entwickler mit Grundkenntnissen in Maven — Erfahrung mit Activiti/BPMN hilft, ist aber nicht nötig
Inhaltsverzeichnis
1. Was ist TestSupport-StateMachine?
TestSupport-StateMachine ist eine leichtgewichtige, dependency-freie State-Machine-Engine in purem Java. Sie dient als Ersatz für die Activiti-BPMN-Engine im internen TestSupport-Client.
Statt BPMN-XML-Diagramme zu pflegen, beschreibt man den Prozess direkt in lesbarem Java-Code:
ProcessDefinition main = ProcessDefinition.builder("TestAutomation")
.step(new ActionStep("Prepare", ctx -> prepareSystem()))
.step(new ConditionalStep("Gateway",
ctx -> "FULL".equals(ctx.get("TYPE")),
new ActionStep("FullRun", ctx -> runAll()),
new ActionStep("SmokeRun", ctx -> runSmoke())))
.onFailure(new ActionStep("SendMail", ctx -> notifyAdmin()))
.finallyStep(new ActionStep("Cleanup", ctx -> cleanup()))
.build();
ProcessEngine engine = new ProcessEngine();
ProcessOutcome outcome = engine.run(main, ProcessContext.create());
2. Warum diese Library?
Activiti ist mächtig — aber im konkreten Einsatzfeld (Integrationstests für ein internes System) bringt es deutlich mehr Ballast als Nutzen mit:
| Problem mit Activiti | Lösung hier |
|---|---|
| Braucht H2/PostgreSQL als Prozess-DB | Keine DB. Zustand ist ein ProcessContext im RAM |
| BPMN-XML-Editor nötig | Prozess als Java-Builder-Code — IDE-Autocomplete, Refactoring, Debugger |
| Umfangreiches Spring-Setup | Kein Spring, keine Container |
| Dependency-Graph mit ~30 Libraries | Null externe Dependencies (nur Test: JUnit, AssertJ) |
| Variablen nur serialisierbar | Beliebige Java-Objekte im ProcessContext |
| Schlecht debugbar (XML + Engine-State) | Normaler Java-Stacktrace |
Gleichzeitig soll der Umstieg schrittweise möglich sein: bestehende Activiti-UserTaskRunnable-Handler werden über einen UserTaskAdapterStep 1:1 weitergenutzt — kein Rewrite der Handler-Logik nötig.
3. Einordnung in die TestSupport-Familie
Die Library ist Teil einer Projekt-Landschaft mit vier verbundenen Repos:
| Projekt | Rolle |
|---|---|
| testsupport_client | Activiti-Original (DemoMode). Quelle für Handler-Portierungen |
| TestSupport-StateMachine | Diese Library — Engine |
| TestSupport-Tool | Activiti-freier Re-Build. Konsument dieser Library |
| ITSQ-Testfaelle | Test-Set-Lieferant (wird über maven-dependency-plugin eingebunden) |
ProcessDefinition, Step oder ProcessContext schlagen sofort in TestSupport-Tool auf — dort mit bauen und testen.
4. Voraussetzungen & Build
Was wird benötigt?
| Software | Version | Zweck |
|---|---|---|
| Java JDK | 11 oder höher | Compile + Runtime |
| Maven | 3.6+ | Build-Tool |
| Git | beliebig | Quellcode |
Clone & Build
git clone https://github.com/CavdarKemal/TestSupport-StateMachine.git
cd TestSupport-StateMachine
# Build ohne Tests (schnell)
ci.cmd 11
# Build mit Tests (gründlich)
cit.cmd 11
Nach erfolgreichem Build liegt die JAR unter target/testsupport-statemachine-1.0.0-SNAPSHOT.jar.
ci.cmd/cit.cmd: Das sind lokale Hilfsskripte, die eine bestimmte JDK-Version aktivieren und mvn clean install ausführen. Alternativ direkt: mvn -DskipTests clean install bzw. mvn clean install.
Als Dependency einbinden
<dependency>
<groupId>de.creditreform.crefoteam.cte</groupId>
<artifactId>testsupport-statemachine</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
5. Kernkonzepte
Die Engine besteht aus wenigen, klar abgegrenzten Typen. Wer diese fünf kennt, kann Prozesse bauen und ausführen:
| Typ | Rolle |
|---|---|
| Step | Kleinste Ausführungseinheit — Pendant zu Activiti-userTask |
| StepResult | Rückgabe eines Steps: NEXT, FAIL oder ABORT |
| ProcessContext | Variablen + Cancel-Flag + Listener + aktiver Pfad |
| ProcessDefinition | Unveränderliche Beschreibung des Prozesses (per Builder) |
| ProcessEngine | Führt die Definition aus — serialisiert Steps, verwaltet Pfad |
Das Step-Interface
@FunctionalInterface
public interface Step {
StepResult execute(ProcessContext context) throws Exception;
default String name() {
return getClass().getSimpleName();
}
}
execute() ist der Kern: er bekommt den Kontext, tut etwas und liefert ein StepResult.
StepResult
| Wert | Bedeutung |
|---|---|
| NEXT | Step ok, nächsten Step ausführen |
| FAIL | Fachlicher Fehler — Engine fragt Listener nach Retry, sonst Failure-Branch |
| ABORT | Harter Abbruch — kein Failure-Branch, Finally-Steps laufen trotzdem |
FAIL. Unterschied ist rein stilistisch: FAIL = „fachlich nicht ok“, Exception = „technischer Fehler“.
6. Step-Typen im Detail
Vier vorgefertigte Step-Typen decken die gängigen Fälle ab:
ActionStep — einfache Aktion
Wrapper für einen Consumer<ProcessContext>. Liefert immer NEXT.
new ActionStep("PrepareSystem", ctx -> {
ctx.put("preparedAt", Instant.now());
prepareTestSystemImpl();
});
UserTaskAdapterStep — Activiti-Handler einbinden
Migrationsbrücke für bestehende Activiti-UserTaskRunnable-Implementierungen. Die alte runTask(DelegateExecution)-Methode wird über einen UserTaskRunnable-Adapter aufgerufen.
new UserTaskAdapterStep("StartCtImport", startCtImportHandler::runTask);
ConditionalStep — Gateway-Ersatz
Aktivitäts-Gateway: prüft eine Guard-Bedingung und wählt einen von zwei Branches.
new ConditionalStep(
"TestTypeGateway",
ctx -> "PHASE1_AND_PHASE2".equals(ctx.get("TEST_TYPE")),
new UserTaskAdapterStep("GeneratePseudo", pseudoHandler::runTask),
new UserTaskAdapterStep("SkipPseudo", skipHandler::runTask));
SubProcessStep — callActivity-Ersatz
Führt eine komplette Sub-ProcessDefinition als einen Step aus. Skip-Predicate für Resume-Phasen verfügbar (withSkipPredicate).
ProcessDefinition subPhase = ProcessDefinition.builder("Phase")
.step(new UserTaskAdapterStep("StartCtImport", startCtImportHandler::runTask))
.step(new UserTaskAdapterStep("WaitForCtImport", waitCtImportHandler::runTask))
.build();
new SubProcessStep(subPhase); // als ein Step im Haupt-Prozess
7. Einen Prozess bauen
ProcessDefinition wird über einen Builder zusammengesetzt:
ProcessDefinition main = ProcessDefinition.builder("TestAutomationProcess")
.step(new UserTaskAdapterStep("PrepareTestSystem", prepareHandler::runTask))
.step(new ConditionalStep(
"TestTypeGateway",
ctx -> "PHASE1_AND_PHASE2".equals(ctx.get("TEST_TYPE")),
new UserTaskAdapterStep("GeneratePseudo", pseudoHandler::runTask),
new UserTaskAdapterStep("SkipPseudo", skipHandler::runTask)))
.step(new SubProcessStep(subPhase)) // Phase 1
.step(new SubProcessStep(subPhase)) // Phase 2
.step(new UserTaskAdapterStep("SuccessMail", successHandler::runTask))
.onFailure(new UserTaskAdapterStep("FailureMail", failureHandler::runTask))
.finallyStep(new UserTaskAdapterStep("RestoreSystem", restoreHandler::runTask))
.build();
| Builder-Methode | Zweck |
|---|---|
| .step(...) | Hängt einen Schritt an den Main-Branch an |
| .onFailure(...) | Failure-Branch — läuft, wenn der Main-Branch scheitert und Retry abgelehnt wird |
| .finallyStep(...) | Läuft IMMER am Ende (erfolgreich, failed, abortiert) |
| .build() | Liefert die unveränderliche ProcessDefinition |
8. Engine ausführen
// Variablen vorbereiten
Map<String, Object> initialVars = Map.of(
"TEST_TYPE", "PHASE1_AND_PHASE2",
"environment", "ENE");
// Kontext + Listener erstellen
ProcessListener myListener = new ConsoleProcessListener();
ProcessContext ctx = ProcessContext.create(initialVars, myListener);
// Ausführen
ProcessOutcome outcome = new ProcessEngine().run(main, ctx);
switch (outcome) {
case COMPLETED -> log.info("Prozess erfolgreich");
case FAILED -> log.warn("Prozess gescheitert");
case ABORTED -> log.info("Prozess abgebrochen");
}
ctx.isCancelled() regelmäßig prüfen, damit ein Cancel-Signal wirkt.
9. Retry & Cancel
Retry
Wenn ein Step FAIL liefert (oder eine Exception wirft), fragt die Engine den Listener via askForRetry(...) ob nochmal versucht werden soll:
public class ConsoleProcessListener implements ProcessListener {
@Override
public boolean askForRetry(ProcessContext ctx, Step step, Throwable cause) {
System.out.println("Step " + step.name() + " failed: " + cause.getMessage());
System.out.print("Retry? [y/N] ");
return new Scanner(System.in).nextLine().trim().equalsIgnoreCase("y");
}
}
Bei true wird derselbe Step erneut ausgeführt. Bei false verlässt die Engine den Main-Branch und läuft in den onFailure-Branch.
Cancel
Ein Aufrufer kann den Prozess von außen abbrechen:
ctx.cancel(); // Flag setzen
// Step prüft selbst:
if (ctx.isCancelled()) {
return StepResult.ABORT;
}
Das Outcome ist dann ABORTED. Der Failure-Branch läuft nicht, der Finally-Branch aber schon.
10. Prozess-Visualisierung
Als Ersatz für Activitis RepositoryService.getProcessDiagram() liefert die Library einen ProcessDiagramRenderer, der die ProcessDefinition + den aktiven Pfad als PNG rendert — ohne externe Dependencies, nur AWT/ImageIO:
ProcessListener imageListener = new ProcessListener() {
@Override
public void onStepStarted(ProcessContext ctx, Step step) {
try (InputStream png = ProcessDiagramRenderer.renderToPng(
ctx.rootDefinition(), ctx.activePath())) {
updateGuiTabPanel(png);
} catch (IOException e) {
log.warn("Prozessbild nicht verfügbar", e);
}
}
};
Rendering-Features
- Linearer Top-Down-Flow mit Pfeilen
ConditionalStepals Raute (Gateway) mit zwei beschrifteten BranchesSubProcessStepals gerahmter Block mit verschachtelten Kind-Steps- Aktiver Step-Pfad gelb hervorgehoben mit oranger Kontur
- Failure-Branch mit rotem Separator, Finally mit grünem Separator
- Positions-basiertes Highlighting — derselbe Step in zwei SubProcesses wird nur an der tatsächlich aktiven Position markiert
11. Demo-GUI
Im Test-Source liegt eine kleine Swing-Anwendung als Referenz-Implementierung:
cit.cmd 11 # einmal bauen inkl. Test-Classes
run-demo.cmd # startet die GUI
# Alternativ von Hand:
java -cp target/classes;target/test-classes \
de.creditreform.crefoteam.cte.statemachine.diagram.ProcessFlowDemoApp
Funktionen:
- Start-/Abbruch-Button
- Gateway-Auswahl per Combobox (
TEST_TYPE) - Tab mit aktuellem Prozessbild — aktualisiert bei jedem Step-Wechsel
- Log-Tab mit Step-Timestamps
- Jeder Step schläft 800ms — der Ablauf ist visuell beobachtbar
TestSupport-Tool) den Renderer in ihre eigene GUI einbauen können.
12. Vergleich zu Activiti
Die Library wurde als 1:1-Ersatz designt — jede relevante Activiti-Funktion hat ein Pendant:
| Activiti | StateMachine |
|---|---|
*.bpmn-Datei | ProcessDefinition.builder(...).build() |
userTask | Step (oder ActionStep/UserTaskAdapterStep) |
sequenceFlow | .step(...) in Builder-Reihenfolge |
exclusiveGateway | ConditionalStep(guard, whenTrue, whenFalse) |
callActivity | SubProcessStep(subDefinition) |
| „Immer“-Cleanup am Ende | .finallyStep(...) am Builder |
signal cancelProcessSignal | ProcessContext.cancel() |
Prozessvariablen (Map) | ProcessContext.variables() |
ActivitiProcessController | ProcessEngine.run(definition, ctx) |
TesunClientJobListener | ProcessListener |
getProcessImage() (REST) | ProcessDiagramRenderer.renderToPng(...) |
13. Projektstruktur
src/main/java/de/creditreform/crefoteam/cte/statemachine/
├── Step.java Kern-Interface (kompatibel zu UserTaskRunnable)
├── StepResult.java NEXT / FAIL / ABORT
├── Guard.java Boolesche Bedingung für Verzweigungen
├── ProcessContext.java Variablen + Cancel-Flag + Listener + activePath
├── ProcessDefinition.java Unveränderliche Builder-Beschreibung
├── ProcessEngine.java Ausführungs-Engine
├── ProcessOutcome.java COMPLETED / FAILED / ABORTED
├── ProcessListener.java Lifecycle-Hooks + askForRetry
├── CompositeProcessListener.java Mehrere Listener bündeln
├── steps/
│ ├── ActionStep.java Consumer-Wrapper
│ ├── ConditionalStep.java Gateway-Ersatz
│ ├── SubProcessStep.java callActivity-Ersatz (mit Skip-Predicate)
│ └── UserTaskAdapterStep.java Migrationsbrücke zu Activiti-Handlern
└── diagram/
└── ProcessDiagramRenderer.java PNG-Rendering des Prozessbilds
Detaillierte Doku
Im Ordner docs/ liegen vertiefende Kapitel:
docs/steps.md— alle Step-Typen, eigene Steps bauen, Retry, Canceldocs/engine.md— Engine-Ablauf, Context, Listener-Lifecycledocs/diagram.md— Renderer, positions-basiertes Highlightingdocs/migration-activiti.md— Umstieg von Activitidocs/API-Usage.md— API-Übersicht
14. Nächste Schritte
Zum Ausprobieren
- Demo laufen lassen:
cit.cmd 11+run-demo.cmd - Eigenen Prozess bauen: Mit
ActionStep-Lambdas einen minimalen Prozess im Test-Source zusammenstellen und ausführen - Gateway ausprobieren: Einen
ConditionalStepmit zwei Lambdas bauen und per Variable steuern - Failure-Pfad: Einen Step bauen, der bewusst
FAILliefert — beobachten, wie Listener und Failure-Branch reagieren
Zum Weiterentwickeln
- Eigene Step-Typen: Ein
RetryableHttpStep, der HTTP-Aufrufe mit exponential backoff wiederholt - Persistenter Zustand: Einen
ProcessListenerbauen, der denProcessContextnach jedem Step in JSON serialisiert (für Resume nach Crash) - Neuer Renderer: Einen SVG-Renderer als Alternative zu PNG implementieren