Zum Inhalt springen
Architektur

Testing BPMN processes and arc42 quality scenarios: the test level above the pyramid

An order process can pass every unit test and still ship parcels nobody has paid for. The reason is not carelessness, it is the place where the logic lives: in a process model, in a job setup, in a configuration. This article shows, on an online shop for T-shirts, how you set up this test level, with Flowable 8 for the order process, with Spring Batch 6 for the nightly shipping run, and how you turn the quality scenarios from arc42 chapter 10 into executable tests.

Contents

The order nobody shipped

The shop sells printed T-shirts. An order always runs through the same stations: charge the payment, reserve the stock, create the shipping label, hand the parcel over to the carrier, wait for the delivery confirmation. That is modelled as a BPMN process, executed by a process engine, and the business logic sits in Spring beans attached to the Service Tasks.

On a Tuesday somebody notices that an order with a declined credit card got a label anyway. The parcel is gone, the amount was never charged. It is a single case, but it can be reproduced: if the payment service reports the rejection as a business result instead of an exception, the process takes the wrong exit at the gateway.

The mistake sits in a condition inside the process model. It sits in no Java class.

Why all the unit tests stayed green

The project has good test coverage. The payment service has tests for accepted and declined cards. The stock service has tests for available and missing items. The label service has tests for valid and invalid addresses. Each of these tests checks exactly what its class is responsible for, and each of them is justified.

None of them executes the process model. The order of the stations, the conditions on the gateways, the behaviour of the timer: all of that is described in an XML file that the engine interprets at runtime. To the Java compiler that file is a resource like an image.

The same gap appears everywhere an artefact next to the code carries the decisions. A batch job whose behaviour lies in the interplay of Reader, Processor, Writer and Chunk size. A database migration whose effect only becomes visible against the real schema. A rule table, a Helm chart, a routing configuration. The code around it is tested, and nobody ever executed the artefact.

The selection rule for this test level

Which statement about the product only becomes true once all the parts run together? That question decides what belongs on this level.

That a declined card leads to a rejection is a statement about the payment service. It belongs in a unit test, where it is checked in milliseconds.

That a declined card never leads to a shipping label is a statement about the whole. It only becomes true once the process model, the gateway condition and the payment service work together. No single class can deliver it, and no mock can prove it, because the mock is exactly the assumption under discussion here.

Everything with the first shape stays down in the pyramid, where it is fast and cheap. Only the second shape justifies a test that boots an engine.

Two kinds of promises

If you apply the rule consistently, two groups remain, and they behave differently.

The business promises only arise from several components working together. Explicitly not every acceptance criterion, only the ones that cross component boundaries: no label without a payment, no return without a refund, no reservation left standing after a cancellation. This layer is expensive, because every test needs a runtime environment, and it is therefore allowed to stay small.

The quality promises are not about the what, they are about the how well. Response time, throughput, behaviour on restart, recovery after a failure. If an arc42 documentation sits in your project, this part is already written. Chapter 10 is called quality requirements and is split into the quality tree and the quality scenarios. A quality scenario records how the system is meant to behave on a particular event, concretely enough that afterwards you can decide whether it worked. A test needs exactly that too: a trigger, an expected behaviour and a number to check both against.

In most projects this second group stays a document. A quality goal nobody measures is a declaration of intent.

Prerequisites

The article reflects the state as of August 2026.

  • Java 25. Spring Batch 6 requires at least Java 21, Flowable gets by with less.
  • JUnit 5. Flowable 8 removed support for JUnit 3 and 4, deprecated since 7.2. Spring Batch 6 dropped JUnit 4 as well.
  • Flowable 8.0.0, released on 27 February 2026, with official support for Spring Boot 4 and Spring 7.
  • Spring Boot 4.1, released on 10 June 2026.
  • Spring Batch 6.0.4 for the second part.
  • Docker, though not for the engine. The process engine runs embedded in the test. You need Docker for the database from Testcontainers, because an in-memory database in the test says nothing about the real schema.

The core is a single dependency that brings all the engines and the Spring integration with it.

<dependency>
    <groupId>org.flowable</groupId>
    <artifactId>flowable-spring-boot-starter</artifactId>
</dependency>

Process definitions are deployed automatically by the starter as soon as they sit under classpath*:/processes/ and end in .bpmn20.xml or .bpmn. So you need no deployment annotation of your own in the test.

One setting belongs in the test configuration, otherwise the test becomes unreliable:

flowable.async-executor-activate=false

With that, the asynchronous executor does not work off timers in the background. It sounds like a restriction, but it is the precondition for keeping time in your own hands inside the test. What that looks like comes further down at the turning point.

The process from cart to parcel

The process is called shirt-order and looks like this. The boxes are Service Tasks, the diamonds are Gateways, and the dashed box waits for an event from outside. Under every label sits the element ID from the BPMN XML, and those exact IDs show up again in the tests in a moment.

The shirt-order process as a flow diagram, with the main path from payment to delivery and the three failure paths

What is interesting about this model is not the upper path, it is that the refund hangs on two places and that there are three different end states. That is exactly what decides whether the process is correct.

The Service Tasks call Spring beans through expressions and write the result into a process variable. For the payment that looks like this in the model:

<serviceTask id="charge-payment" name="Charge payment"
             flowable:expression="${paymentService.charge(orderId, amount)}"
             flowable:resultVariable="paymentAccepted"/>

The gateway behind it decides on ${paymentAccepted}. That is exactly the line that was wrong in the failure case, and exactly the line no unit test checks.

Step 1: starting the process in a real engine

The test loads the full Spring context, replaces the business beans with mocks and talks to the engine through its services. Note @MockitoBean: the earlier @MockBean is gone with Spring Boot 4, and the annotation now sits under 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));
    }
}

The engine executes the real model while doing so, gateways, timers and error handling included. Only the work behind a station is replaced, not the flow itself. That is the decisive difference to a unit test with mocks: here the object under test is the model, not the class.

Two small helpers take the repetition out of all the tests that follow.

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;
}

Step 2: the path that goes well

The first test records that a paid order with a shirt in stock reaches the customer. The process waits for the delivery confirmation at a Receive Task that is triggered from outside.

@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"));
}

This test is the least important one in the whole article. It proves that the process runs at all, and it would have stayed green with the broken model from the first chapter too.

Step 3: the payment is declined

The failure from the opening scene can be written as a test. The last three lines are what matters.

@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"));
}

The counter-check is the assertion that is missing most often in practice. A test that only checks that the process ended somehow is just as green with a wrong gateway as with a right one. Only the proof that the other branch was never entered turns the test into a promise.

Remember the shape: for every branch, one path taken belongs in the check, plus the counter-check for all the paths that are forbidden.

Step 4: the shirt is out of stock

The second failure path is more interesting, because money has to flow back here. The promise is not only that the order is cancelled, it is that the refund actually happens, and exactly once.

@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");
}

The times(1) is more than cosmetics at this point. A double refund is real damage, and a model that accidentally has the refund task caught in a loop would otherwise go unnoticed.

Step 5: the delivery fails

The last branch connects both failure paths, because it uses the same refund task as step 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");
}

The turning point: green test, wrong process

At this point the test class looks complete. Four paths, four green tests. And still the model can be changed so that all four stay green and the process is wrong.

Assume somebody adds a timer: if the delivery confirmation fails to arrive after fourteen days, the process is supposed to book the return automatically. None of the tests above changes its result, because they all trigger the Receive Task immediately. The new path exists in the model and is touched by no promise.

Because the asynchronous executor is switched off in the test, the timer sits in the database as a job and waits. You fetch it and execute it, without waiting fourteen days.

@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, "there is no timer on the delivery");

    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");
}

The lesson is that the list of tests has to follow from the model. More tests help nothing as long as nobody counts the exits. Every exit, every timer, every error branch is a promise. If one of them has no test, the promise exists only as a drawing.

The complete test class

Put together and with the imports, it looks like this.

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, "there is no timer on the delivery");
        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;
    }
}

Five tests, five promises, and the names are the promises.

The queries you need in the test

Flowable does not ship an assertion library of its own. That is not a shortcoming, it only means you work with the normal queries of the engine and phrase the statement yourself. The stock of them is manageable.

Query Answers
historyService.createHistoricProcessInstanceQuery().finished() Is the instance finished
historyService.createHistoricActivityInstanceQuery().activityId(id).finished() Was this element passed through
historyService.createHistoricVariableInstanceQuery().variableName(name) What value did a variable have
runtimeService.createProcessInstanceQuery().processInstanceId(id) Is the instance still running
runtimeService.createExecutionQuery().activityId(id) Where exactly is the instance waiting right now
taskService.createTaskQuery().processInstanceId(id) Which User Tasks are open
managementService.createTimerJobQuery().processInstanceId(id) Which timer is waiting
managementService.createDeadLetterJobQuery().processInstanceId(id) What has failed for good

The last row is the one hardly anybody thinks of. A job lands in the dead letter queue once its retries are exhausted, and the instance stands still without anything turning red. So every test that describes a successful path should also check that nothing is sitting there.

assertEquals(0, managementService.createDeadLetterJobQuery()
        .processInstanceId(instance.getId())
        .count());

The second artefact: the nightly shipping run

The process model is not the only artefact in this shop. At night a batch job runs that registers all parcels ready for handover with the carrier, writes the tracking numbers back and sets the orders to shipped. The business logic per order is wrapped in a Processor and properly unit tested. What is not covered there is the behaviour of the run as a whole: Chunk size, commit boundaries, behaviour on a broken record, restart after an abort.

In Spring Batch 6 the test API has changed. JobLauncherTestUtils.launchJob() has been deprecated since 6.0 and is meant to disappear in 6.2 or later. JobOperatorTestUtils.startJob() takes its place. The annotation @SpringBatchTest provides both helper classes, so JobOperatorTestUtils and 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));
    }
}

The actual promise, though, sits in the next test. A run that aborts on a broken record must not register the already handed over parcels a second time when it starts again. Double registrations cost money and trust.

@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());
}

This promise only becomes true once Reader, Writer, Chunk boundary and the job repository work together. It is the model case for this test level, and it is the reason why the database comes from Testcontainers and not from memory: a restart that runs against a different schema than production proves nothing.

arc42 quality scenarios as an executable test

Up to here it was about business promises. The second part is the quality promises, and the way there is shorter than most projects assume.

Chapter 10 arranges the quality goals as a tree, sorted by importance, and the scenarios hang from that tree’s leaves. Each of them names an event, the expected behaviour and the number both can be checked against. Three parts, always the same ones:

Scenario part In the test
Trigger The setup, so what the test builds up and sets off
Response The expected behaviour, so the assertion
Measure The number in the assertion

A scenario from the shipping run could read that a nightly run over the entire stock ready for handover finishes within the maintenance window. The trigger is the start of the run with a stock of realistic size, the response is the regular completion, the measure is the length of the maintenance window.

@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,
            "run took " + elapsed + ", allowed is " + MAINTENANCE_WINDOW);
}

So this does not turn into a nuisance, tests like this do not belong in the same run as the fast ones. Give them their own group, separated by a JUnit tag, and a place in the pipeline where a longer runtime bothers nobody.

The measure on a build agent is a different one than in production anyway. A test like this works as a regression threshold. What it catches is the degradation against the last state.

And the same selection rule applies to quality scenarios as to the business promises. Only what arises from the parts working together belongs here. The runtime of a single method is better measured by a microbenchmark.

If you keep the numbering from the quality tree in the test name, the list of tests tells you straight away which goal is currently red. More valuable than the red tests, though, are the goals that have no test at all: that gap is the real yield of the exercise.

Do you need Cucumber for this?

Anyone testing acceptance criteria ends up at the question of Gherkin sooner or later. Cucumber maps sentences in Given-When-Then form onto step definitions in the code, and the sentences are readable for domain people.

The catalogue of promises is the valuable part. A team that collects scenarios in domain language has the biggest part of the work behind it, whatever the tool. That part is valuable and has little to do with Cucumber.

The price is an additional layer. Every sentence needs a step definition, the mapping drifts when the application changes, and in practice developers maintain that layer on their own anyway. The readability comes from the sentence, not from the format:

@Test
void shouldNotCreateLabelWhenPaymentDeclined() { }
Scenario: No label when the payment is declined
  Given the payment was declined
  When the order is processed
  Then no shipping label is created

Both say the same thing. The second version needs one more file, one more library and a mapping layer that has to be maintained.

Cucumber pays off when people without access to the code really read or write the scenarios, for example in regulated environments with a sign-off by the business side. If only developers read the scenarios, a well named test method is the simpler means. It is always up to date, because it is the same code that also runs.

How many tests belong on this level?

Fewer than the first impulse suggests. A usable yardstick is the number of exits in the artefact: every end state of the process and every timer. The example process has three end states and one timer, which makes five tests, and they cover the model completely.

Everything that can be answered with a unit test does not belong here. Checking whether a postcode is valid belongs to the validator. Calculating the postage belongs to the tariff service. Repeating those tests in the whole costs runtime and adds no statement.

A good indicator for a bloated level is the debugging: if a red test does not bring you close to the cause but only says that something in the whole is off, it is probably placed too high.

Limits: what this test level does not do

It does not replace unit tests. A test across the whole tells you that the promise is broken, but not which line is to blame. That resolution comes from the fast tests below, and without them every hunt for a cause turns into an excavation.

Observability in production does not become superfluous either. No test run covers the cases that only arise with real data: the address with the line break, the carrier that sends the same confirmation twice. What is green here is checked, not proven.

It is slow. A full Spring context with engine and database takes time, and the runtime grows with every test. That is why the layer stays small, and why the selection rule matters more than any individual assertion.

And it does not turn a wrongly modelled process into a right product. A test records what you promised. Whether the promise made sense for the business is not something it decides.

FAQ

What is the difference between an integration test and this test level?
An integration test checks the interplay of technical building blocks, for example application and database. This level checks a promise to the customer that only arises from the parts working together. What decides it is the question the test answers. The technology behind it can be the same.

Is Flowable free?
The core engines for BPMN, DMN and CMMN are under Apache 2.0 and free to use, commercially too. What costs money are the enterprise add-ons and the vendor’s support.

Why is the asynchronous executor switched off in the test?
So that timers are not worked off in the background while the test runs. The job then stays in the database and you execute it deliberately through the ManagementService. That makes the test deterministic instead of time dependent.

Do I need Docker for the process tests?
Not for the engine, that runs embedded. For the database yes, if you test with Testcontainers against the same database system as in production, as done here.

Do I have to switch to JobOperatorTestUtils right away in Spring Batch 6?
Not right away, but soon. JobLauncherTestUtils.launchJob() has been deprecated since Spring Batch 6.0 and is scheduled for removal in 6.2 or later. The rework is essentially about the method name, launchJob becomes startJob.

How do I keep the quality scenarios and the tests together?
Through the name. If the test carries the number of the scenario from the quality tree, everybody finds the way from the document to the code and back. A reference in both directions costs one line and saves the search.

Does this apply without a process engine too?
Yes. The pattern does not depend on BPMN, it depends on an artefact next to the code carrying the decisions. Rule tables, migrations, routing configurations and infrastructure manifests have the same property.

Conclusion

The test pyramid stays right, it is only missing a small tip for the statements that become true once the parts run together. Two questions are enough to fill it: which promises only arise when all the parts run together, and which of them already exist as a quality scenario in your documentation?

The next step is small. Take the artefact that carries the most decisions in your system, count its exits and write one test per exit whose name is the promise. After that take chapter 10 of your architecture documentation and turn the topmost quality scenario into a test. Otherwise a quality goal nobody measures stays a declaration of intent.

Sources

  • arc42, chapter 10 quality requirements, quality tree and quality scenarios: arc42.de
  • DokChess, public arc42 example project with a complete quality tree and numbered scenarios: dokchess.de
  • Flowable Open Source, Spring Boot integration and test support: flowable.com
  • Flowable Open Source code and Apache 2.0 licence: flowable.com
  • Spring Batch reference, unit testing and what is new in version 6: docs.spring.io

All code examples are my own and written against the versions named in the prerequisites. The order process is made up and does not depict any real project.

$ lang DE EN ES