Prozessabhängigkeiten testen

Heute geht es um das Thema "Testing process dependencies". Für die Ausführung eines Modells werden häufig weitere Ressourcen benötigt. Dabei kann es sich um Quellcode oder die Abhängigkeit zu anderen Modellen handeln. Doch wie gehen wir damit beim Testen unserer Modelle um?

Ursprünglich am 20.11.2020 auf dem offiziellen Camunda-Blog gepostet: https://camunda.com/blog/2020/11/testing-process-dependencies/.

In diesem Post werden wir die folgenden Abhängigkeiten genauer unter die Lupe nehmen:

  • Modelle: Abhängigkeiten zu BPMN-Diagrammen, die vom ausgeführten Modell referenziert werden.
  • Code: Abhängigkeiten zu Quellcode, der im BPMN referenziert wird.

Dabei werden wir eine weitere Library kennenlernen, die uns das Testen erleichtert: camunda-bpm-mockito. Die Beispiele für diesen Blogbeitrag findet ihr in diesem GitHub-Repository.

Im letzten Post haben wir uns einen kleinen Bestell-Prozess näher angeschaut und getestet. Diesen könnt ihr hier finden. In diesem Teil wollen wir das nun weiter ausbauen und um Funktionalitäten erweitern, die wir beim Testen berücksichtigen müssen:

  • Ein weiterer Prozess, der in einer Call Activity referenziert wird
  • Ein Java Delegate, das weitere Abhängigkeiten zu einem Service hat

Abhängigkeiten zu anderen BPMN-Modellen

Es gibt verschiedene Gründe dafür, BPMN-Diagramme als Call Activities einzubinden oder aus dem Code heraus zu starten:

  • Komplexität reduzieren: Umfangreiche BPMN-Modelle können sich negativ auf das Verständnis für den eigentlichen Prozessfluss auswirken. Deshalb ist es manchmal sinnvoll, technische Details und Besonderheiten in ein eigenes Diagramm auszulagern.
  • Wiederverwendbare Komponenten: Mit der Anzahl an automatisierten Prozessen steigt häufig die Anzahl an Funktionen, die an unterschiedlichen Stellen verwendet werden können. Wenn es sich dabei nicht nur um einfache Service-Aufrufe handelt, kann es sinnvoll sein, diese Funktionen in separate Prozesse auszulagern.
  • Starten von Prozessen: Manchmal ist es notwendig, Prozesse asynchron zu starten. Dies kann der Fall sein, wenn nach der Beendigung einer Instanz weitere Verarbeitungsschritte durchgeführt werden sollen, ohne, dass der Prozess bis zum Abschluss warten soll.

Alle drei Fälle führen dazu, dass wir beim Testen eine Abhängigkeit auf ein weiteres Prozessmodell haben. Doch wie sollen wir damit umgehen?

Das referenzierte Modell verwenden

Wir können das referenzierte Modell in einem Unit-Test verwenden und testen. Dies ist jedoch aus den folgenden Gründen nicht zu empfehlen:

  • Das BPMN-Modell muss mitgetestet werden und der Testfall wird umfangreicher
  • Wiederverwendbare Komponenten werden unnötig mehrfach getestet
  • Gibt es im referenzierten Modell unterschiedliche Rückgabewerte oder Fehler, auf die im Prozess reagiert werden muss, führt dies zu einem enormen Mehraufwand im Testfall
  • Es müssen die Abhängigkeiten des referenzierten Modells im Testfall berücksichtigt werden
  • Modifikationen im referenzierten Modell wirken sich auf den Testfall des anderen Prozesses aus

Ein anderes Modell mit dem gleichen Key verwenden

Anstatt das referenzierte Diagramm zu verwenden, kann ein eigenes Modell mit dem gleichen Key deployed werden, dessen Ergebnis parametrisiert werden kann. Dies ist mit wenigen Zeilen Code erledigt:

BpmnModelInstance modelInstance = Bpmn.createExecutableProcess()
        .id("callActivity")
        .startEvent()
        .serviceTask().camundaResultVariable("result").camundaExpression(result)
        .endEvent()
        .done();

Deployment deployment = rule.getProcessEngine()
        .getRepositoryService()
        .createDeployment()
        .addModelInstance("callActivity" + ".bpmn", modelInstance)
        .deploy();

Für einfache Modelle ist dies durchaus ein praktikabler Weg. Es gibt jedoch Fälle, die wiederum zu Mehraufwand führen. Besonders dann, wenn es im referenzierten Modell unterschiedliche Rückgabewerte oder Fehler gibt, auf die im Prozess reagiert werden muss.

Das Modell mit camunda-bpm-mockito mocken

Anstatt einen eigenen Mock des Modells zu bauen, kann hierfür die camunda-bpm-mockito library verwendet werden. Dies bringt folgende Vorteile:

  • Übersichtliche Tests, die sich auf den eigentlichen Prozess konzentrieren
  • Fehler in einem verwendeten Modell wirken sich nicht auf den Test des übergeordneten Prozesses aus
  • Unterschiedliches Verhalten des referenzierten Modells kann einfacher simuliert werden
  • Schnellere Durchlaufzeiten bei Tests

Werfen wir nun einen Blick auf unseren Bestellprozess. Die Lieferung soll als eigenständiger, wiederverwendbarer Prozess ausgelagert werden, der als Call Activity referenziert wird.

Diesen Lieferprozess referenzieren wir nun als Call Activity im Bestellprozess. Doch wie gehen wir damit nun in unserem Test um? Es gibt zwei Aufgaben für uns:

  • Den Lieferprozess im Test mocken
  • Einen separaten Test für den Lieferprozess schreiben

Den Lieferprozess im Test mocken

Hierzu ergänzen wir die defaultScenario()-Methode wie folgt:

ProcessExpressions.registerCallActivityMock(DELIVERY_PROCESS_KEY)
        .deploy(rule);

when(testOrderProcess.runsCallActivity(TASK_DELIVER_ORDER1))
        .thenReturn(Scenario.use(deliveryRequest));

Im shouldExecuteOrderCancelled müssen wir das Verhalten des Call Activity-Mocks anpassen, um bei der Ausführung einen Fehler zu werfen:

ProcessExpressions.registerCallActivityMock(DELIVERY_PROCESS_KEY)
          .onExecutionDo(execution -> {
              throw new BpmnError("deliveryFailed");
          })
          .deploy(rule);

Und schon haben wir unterschiedliche Varianten für unseren aufgerufenen Bestellprozess definiert - ziemlich einfach! Mit camunda-bpm-mockito ist noch vieles mehr möglich, ausprobieren lohnt sich.

Einen separaten Test für den Lieferprozess schreiben

Als nächstes erstellen wir für den Lieferprozess noch eine eigene Testklasse und übernehmen die Methoden shouldExecuteOrderCancelled und shouldExecuteDeliverTwice.

@Deployment(resources = "deliver-process.bpmn")
public class DeliveryProcessTest {

    public static final String PROCESS_KEY = "deliveryprocess";
    public static final String TASK_DELIVER_ORDER = "Task_DeliverOrder";
    public static final String VAR_ORDER_DELIVERED = "orderDelivered";
    public static final String END_EVENT_DELIVERY_COMPLETED = "EndEvent_DeliveryCompleted";
    public static final String END_EVENT_DELIVERY_CANCELLED = "EndEvent_DeliveryCancelled";

    @Rule
    public ProcessEngineRule rule = new ProcessEngineRule();

    @Mock
    private ProcessScenario testDeliveryProcess;

    @Before
    public void defaultScenario() {
        MockitoAnnotations.initMocks(this);

        //Happy-Path
        when(testDeliveryProcess.waitsAtUserTask(TASK_DELIVER_ORDER))
                .thenReturn(task -> {
                    task.complete(withVariables(VAR_ORDER_DELIVERED, true));
                });
    }

    @Test
    public void shouldExecuteHappyPath() {
        Scenario.run(testDeliveryProcess)
                .startByKey(PROCESS_KEY)
                .execute();

        verify(testDeliveryProcess)
                .hasFinished(END_EVENT_DELIVERY_COMPLETED);
    }

    @Test
    public void shouldExecuteOrderCancelled() {
        when(testDeliveryProcess.waitsAtUserTask(TASK_DELIVER_ORDER)).thenReturn(task -> {
            taskService().handleBpmnError(task.getId(), "DeliveryCancelled");
        });

        Scenario.run(testDeliveryProcess)
                .startByKey(PROCESS_KEY)
                .execute();

        verify(testDeliveryProcess)
                .hasFinished(END_EVENT_DELIVERY_CANCELLED);
    }

    @Test
    public void shouldExecuteDeliverTwice() {
        when(testDeliveryProcess.waitsAtUserTask(TASK_DELIVER_ORDER)).thenReturn(task -> {
            task.complete(withVariables(VAR_ORDER_DELIVERED, false));
        }, task -> {
            task.complete(withVariables(VAR_ORDER_DELIVERED, true));
        });

        Scenario.run(testDeliveryProcess)
                .startByKey(PROCESS_KEY)
                .execute();

        verify(testDeliveryProcess, times(2))
                .hasCompleted(TASK_DELIVER_ORDER);
        verify(testDeliveryProcess)
                .hasFinished(END_EVENT_DELIVERY_COMPLETED);
    }
}

Abhängigkeiten zu Quellcode

Schauen wir nun, wie wir mit Code-Abhängigkeiten umgehen können:

  • Alle Abhängigkeiten mit Mocks ersetzen
  • Den gesamten Kontext bereitstellen
  • Ausgewählte Klassen mit Abhängigkeiten bereitstellen

Werden alle Abhängigkeiten durch Mocks ersetzt, verliert der Test an Aussagekraft. Stattdessen den kompletten Kontext bereitzustellen, würde wiederum am Ziel eines Unit-Tests vorbeigehen, wäre bei größeren Anwendungen sehr aufwendig und würde zu längeren Test-Laufzeiten führen. Die Lösung liegt somit im letzten Punkt. Doch welche Klassen sollen bereitgestellt und welche durch Mocks ersetzt werden?

Schauen wir uns dieses Beispiel anhand des Java-Delegates an, das im Send Cancellation-Task verwendet wird, und ergänzen es um einen Mailing-Service:

@Component
public class SendCancellationDelegate implements JavaDelegate {

    private final MailingService mailingService;

    @Autowired
    public SendCancellationDelegate(MailingService mailingService) {
        this.mailingService = mailingService;
    }

    @Override
    public void execute(DelegateExecution delegateExecution) throws Exception {
        //input
        final String customer = (String) delegateExecution.getVariable("customer");

        //processing
        mailingService.sendMail(customer);

        //output
        delegateExecution.setVariable("cancellationTimeStamp", Instant.now().getEpochSecond());
    }
}

Dieses Delegate liest eine Prozessvariable, verwendet den Mailing-Service, um die Stornierung zu versenden und schreibt den Zeitpunkt der Stornierung zurück in den Prozess. Es ist durchaus von Vorteil, dieses Delegate während eines Testfalls auszuführen, denn es erhöht die Aussagekraft des Tests. Das Versenden der Mail ist jedoch nicht sinnvoll.

Zusammengefasst: Klassen, die aus dem Diagramm referenziert werden, sollen wenn möglich ausgeführt werden. Deren Abhängigkeiten wiederum gilt es zu mocken.

Hierfür erweitern wir den Test wie folgt:

  1. MailingService-Mock erstellen:
@Mock
private MailingService mailingService;
  1. Mock an das Delegate übergeben:
Mocks.register("sendCancellationDelegate", new SendCancellationDelegate(mailingService));
  1. Nichts tun, wenn sendMail() aufgerufen wird:
doNothing().when(mailingService).sendMail(any());
  1. Prüfen, ob der Mailing-Service aufgerufen wird:
@Test
public void shouldExecuteCancellationSent() {
    when(testOrderProcess.waitsAtUserTask(TASK_CHECK_AVAILABILITY)).thenReturn(task -> {
        task.complete(withVariables(VAR_PRODUCTS_AVAILABLE, false));
    });

    Scenario.run(testOrderProcess)
            .startByKey(PROCESS_KEY, withVariables(VAR_CUSTOMER, "john"))
            .execute();

    verify(testOrderProcess)
            .hasFinished(END_EVENT_CANCELLATION_SENT);

    //verfiy execution of mailingService
    verify(mailingService, (times(1))).sendMail(any());
    verifyNoMoreInteractions(mailingService);
}

Bei komplexen Kontexten kann es schwierig werden, den Überblick über alle in einem Testfall verwendeten Mocks zu behalten. In diesem Fall ist es sinnvoller, diese in Factory-Klassen auszulagern, um auch die Abhängigkeiten untereinander zu berücksichtigen.

Fazit

Mit der camunda-bpm-mockito library ist noch vieles mehr möglich. Es können bspw. Messages gemockt werden, die beim Ausführen des Modells korreliert werden sollen oder es ist möglich, das Ergebnis einer Camunda Query zu simulieren. All diese Funktionen erleichtern das Testen von komplexeren Prozessen.

Dieser Blog-Post war eine Einführung in das Testen von Prozessabhängigkeiten. Code und Modelle sind häufig eng miteinander verknüpft, was sich auch auf den Umfang der Testfälle auswirkt. Die hier gezeigten Beispiele und Empfehlungen können jedoch dabei helfen, die eigenen Tests zu vereinfachen und den Aufwand zu reduzieren.

Aber woher wissen wir, ob die geschriebenen Tests ausreichend sind und alle notwendigen Teile des Prozesses abdecken? Und wie können wir das kontrollieren? Mit diesem Thema beschäftigen wir uns in unserem nächsten Post.