Zielgruppe: Java-Entwickler mit Grundkenntnissen in Maven — Erfahrung mit Activiti/BPMN hilft, ist aber nicht nötig

Projekt auf GitHub     ← Zurück

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());
Zero Runtime-Dependencies. Die Library braucht nur JDK 11+ — kein Spring, kein Apache Commons, kein XML-Parser. Der gesamte produktive Code läuft mit der Java-Standardbibliothek.

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 ActivitiLösung hier
Braucht H2/PostgreSQL als Prozess-DBKeine DB. Zustand ist ein ProcessContext im RAM
BPMN-XML-Editor nötigProzess als Java-Builder-Code — IDE-Autocomplete, Refactoring, Debugger
Umfangreiches Spring-SetupKein Spring, keine Container
Dependency-Graph mit ~30 LibrariesNull externe Dependencies (nur Test: JUnit, AssertJ)
Variablen nur serialisierbarBeliebige 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:

ProjektRolle
testsupport_clientActiviti-Original (DemoMode). Quelle für Handler-Portierungen
TestSupport-StateMachineDiese Library — Engine
TestSupport-ToolActiviti-freier Re-Build. Konsument dieser Library
ITSQ-TestfaelleTest-Set-Lieferant (wird über maven-dependency-plugin eingebunden)
API-Breaks wirken stromabwärts. Änderungen an ProcessDefinition, Step oder ProcessContext schlagen sofort in TestSupport-Tool auf — dort mit bauen und testen.

4. Voraussetzungen & Build

Was wird benötigt?

SoftwareVersionZweck
Java JDK11 oder höherCompile + Runtime
Maven3.6+Build-Tool
GitbeliebigQuellcode

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.

Hinweis zu 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:

TypRolle
StepKleinste Ausführungseinheit — Pendant zu Activiti-userTask
StepResultRückgabe eines Steps: NEXT, FAIL oder ABORT
ProcessContextVariablen + Cancel-Flag + Listener + aktiver Pfad
ProcessDefinitionUnveränderliche Beschreibung des Prozesses (per Builder)
ProcessEngineFü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

WertBedeutung
NEXTStep ok, nächsten Step ausführen
FAILFachlicher Fehler — Engine fragt Listener nach Retry, sonst Failure-Branch
ABORTHarter Abbruch — kein Failure-Branch, Finally-Steps laufen trotzdem
Exception vs. FAIL: Eine Exception behandelt die Engine wie 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-MethodeZweck
.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");
}
Thread-Modell: Die Engine führt Steps synchron im Aufrufer-Thread aus — keine implizite Parallelisierung. Langlaufende Steps sollten 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

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:

Zweck: Die Demo zeigt, wie Konsumenten (z.B. 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:

ActivitiStateMachine
*.bpmn-DateiProcessDefinition.builder(...).build()
userTaskStep (oder ActionStep/UserTaskAdapterStep)
sequenceFlow.step(...) in Builder-Reihenfolge
exclusiveGatewayConditionalStep(guard, whenTrue, whenFalse)
callActivitySubProcessStep(subDefinition)
„Immer“-Cleanup am Ende.finallyStep(...) am Builder
signal cancelProcessSignalProcessContext.cancel()
Prozessvariablen (Map)ProcessContext.variables()
ActivitiProcessControllerProcessEngine.run(definition, ctx)
TesunClientJobListenerProcessListener
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:

14. Nächste Schritte

Zum Ausprobieren

Zum Weiterentwickeln

Links