17 KiB
Model Tests for Flowable Apps
This page describes how to write automated tests for Flowable app models (BPMN processes, CMMN cases) in the customer-work module. Model tests run as plain JUnit tests against an in-memory H2 database — no Docker, no running platform needed.
Test data can be provided in three ways:
- Directly in Java (simple, single-scenario tests)
- As JSON parameter files (one scenario per file)
- As Excel workbooks (many scenarios as rows; can be edited with Microsoft Excel or LibreOffice Calc)
1. Project structure
All model-test code and data lives in the customer-work module:
customer-work/
├── src/main/java/com/customer/work/
│ └── service/ # custom runtime code used by the models
│ └── JsonUtils.java # JSON conversion helpers for service tasks
│
├── src/test/java/com/customer/work/
│ ├── config/
│ │ └── TestConfiguration.java # test Spring configuration
│ └── model/ # the test framework
│ ├── FlowableModelTest.java # @FlowableModelTest meta-annotation
│ ├── FlowableModelTestUtils.java # main utility bean (start, complete, assert, load)
│ ├── FlowableExcelParser.java # reads .xlsx workbooks from the classpath
│ ├── FlowableExcelMapper.java # maps workbook rows to test parameters
│ ├── FlowableJsonParser.java # converts plain test data to model variable types
│ ├── EmailDto.java # captured email (subject, receivers, content)
│ ├── TestMailServer.java # GreenMail SMTP server for email assertions
│ ├── TestMailServerExtension.java # starts/stops the mail server per test
│ └── test/ # the actual test classes
│ └── ModelTest.java # tests for the TST_APP models
│
└── src/test/resources/
├── application.properties # H2 datasource, mail server port, app location
├── test-auto-deploy-apps/
│ └── TST_APP.zip # the app under test (exported from Design)
└── model/test/ # test data, one directory per model
├── C001/
│ └── T001.json # task completion payload
├── P001/
│ └── initiator.json # JSON parameter file
├── P002/
│ ├── boolean1.json … date.json # JSON parameter files
│ └── p002Test.xlsx # Excel workbook with test cases
└── P005/
└── T001.json
Conventions:
- Test data goes to
src/test/resources/model/test/<MODEL>/, where<MODEL>is the short name of the model under test (e.g.P002for processTST_P002). - JSON files are named after the scenario they cover (
boolean1.json,initiator.json); task completion payloads are named after the task (T001.json). - One Excel workbook per model (
p002Test.xlsx) holds all data-driven cases for that model. - Test classes live in
com.customer.work.model.test; the framework classes incom.customer.work.modelnormally do not need to be touched when adding new tests.
2. How it works
Every test deploys the app under test and starts the model through a generated wrapper process:
[Wrapper process <KEY>_T] → Call Activity → [Process under test <KEY>]
The wrapper is generated at runtime by FlowableModelTestUtils.createRootTestProcessInstance(testKey, variables). This makes the test realistic for models that read or write variables on their root process (e.g. via varutil with the root. prefix), and it allows testing the model's in/out parameter mappings:
- Variables passed at the top level are set on the wrapper (root) process.
- The special variable
__IN(a map) creates in-mappings on the call activity: each entry"name": valuemaps${__IN.name}to the variablenameof the called process. - The special variable
__OUT(a map) creates out-mappings: each entry"source": "target"copies variablesourceof the called process to variabletargetof the root process when the call activity completes.
The method returns the IDs of both instances:
| Key | Content |
|---|---|
ROOT_PROCESS_ID |
ID of the generated wrapper (root) process instance |
TEST_PROCESS_ID |
ID of the process under test (the call activity child) |
Assertions are usually made against the historic payload of the root process (getHistProcessPayload), so they see both the root variables and everything mapped out of the model under test.
Test infrastructure
| Piece | Purpose |
|---|---|
@FlowableModelTest |
Meta-annotation: boots the Spring context with all Flowable engine test extensions, makes each test @Transactional (automatic rollback) and starts the test mail server |
FlowableModelTestUtils |
The main utility bean: start instances, complete tasks, execute timers, load test data, assert emails / audit records |
FlowableExcelParser / FlowableExcelMapper |
Read Excel workbooks from the classpath and turn each data row into test parameters |
TestMailServer (GreenMail) |
Captures emails sent by the model; configured via flowable.mail.server.host/port in src/test/resources/application.properties |
App deployment
The app under test must be available as a zip in src/test/resources/test-auto-deploy-apps/ (e.g. TST_APP.zip). It is auto-deployed at context start via:
flowable.app.resource-location=classpath*:/test-auto-deploy-apps/
To refresh the zip from a running Flowable Design, temporarily enable the exportApp test (remove @Disabled) and run it once:
flowableModelTest.exportApp("http://localhost:8106", "myWorkspace", "TST_APP", "admin", "test");
3. Writing a model test class
Annotate the class with @FlowableModelTest and inject FlowableModelTestUtils:
@FlowableModelTest
public class ModelTest {
@Autowired
protected FlowableModelTestUtils flowableModelTest;
@Test
public void p005HappyPathTest() {
// Start TST_P005 wrapped in a root process, no start variables
ObjectNode processIds = flowableModelTest.createRootTestProcessInstance("TST_P005", flowableModelTest.emptyMap());
// Work on the open user task
flowableModelTest.claimOpenTask("TST_P005_T001", "admin");
flowableModelTest.completeOpenTask("TST_P005_T001",
flowableModelTest.loadObjectNodeFromResources("model/test/P005/T001.json"), null);
// Assert against the historic root process payload
Map<String, Object> rootPayload = flowableModelTest.getHistProcessPayload(
processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText());
Assertions.assertThat(rootPayload.get("dataEntry")).isEqualTo("my root text");
}
}
Frequently used utility methods
| Method | Purpose |
|---|---|
createRootTestProcessInstance(key, vars) |
Deploy wrapper + start the model under test, returns the two instance IDs |
startCaseInstance(key, vars) |
Start a CMMN case directly |
setTestAuthenticatedUser(userId, tenantId, groups...) |
Set the authenticated user for subsequent engine calls |
claimOpenTask(taskKey, userId) / completeOpenTask(taskKey, vars, outcome) |
Claim / complete the single open task with the given task definition key. vars may be flat ("a.b": 1) — existing task variables are merged automatically |
executeTimer(processId) |
Fire the active timer job of the given process instance |
getHistProcessPayload(id) / getRuntimeCasePayload(id) / getHistoryCasePayload(id) |
Read variables for assertions |
getMailList() |
All emails captured by the test mail server |
getAuditTrail() |
All audit records |
loadObjectNodeFromResources(path) / loadTestVarsFromResources(path) |
Load JSON test data from src/test/resources (see below) |
getJsonArgumentsFromExcel(path) / getObjArgumentsFromExcel(path) |
Excel rows as JUnit parameterized-test arguments (see below) |
testJsonExcelRow(path, row) / testObjExcelRow(path, row) |
Run one Excel row: start, fire timers, assert payload / audit / email counts |
Running the tests
cd C-2025.2
./mvnw test -pl customer-work -Dtest=ModelTest # one class
./mvnw test -pl customer-work # whole module
4. JSON parameter files
JSON files define the start parameters for one test scenario. They live under src/test/resources/model/test/<MODEL>/ and are loaded with loadTestVarsFromResources(path).
Structure
All parameters must be declared explicitly in one of three top-level blocks:
| Block | Meaning |
|---|---|
__ROOT |
Variables set on the root (wrapper) process |
__IN |
In-mappings of the call activity: "name": value passes the value as variable name into the model under test |
__OUT |
Out-mappings: "source": "target" copies variable source of the model to variable target of the root process on completion |
Every block is optional, but any other top-level field fails the test with:
IllegalArgumentException: Implicit parameter 'param' in model/test/P002/boolean1.json:
parameters must be declared explicitly inside __ROOT, __IN or __OUT
Keys inside __ROOT must not start with __.
Example: model/test/P002/boolean1.json
{
"__ROOT": {
"param": true
},
"__IN": {
"param": false
},
"__OUT": {
"result": "out"
}
}
This sets param = true on the root process, passes param = false into TST_P002, and copies the model's result variable to the root variable out when the model completes.
Example: using JSON files in a parameterized test
private Stream<Arguments> p002TestData() {
return Stream.of(
Arguments.of("model/test/P002/boolean1.json", true, false),
Arguments.of("model/test/P002/int.json", 123, 456)
);
}
@ParameterizedTest
@MethodSource("p002TestData")
public void p002Test(String path, Object result, Object out) {
ObjectNode processIds = flowableModelTest.createRootTestProcessInstance("TST_P002",
flowableModelTest.loadTestVarsFromResources(path));
Map<String, Object> rootPayload = flowableModelTest.getHistProcessPayload(
processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText());
Assertions.assertThat(rootPayload.get("result")).isEqualTo(result);
Assertions.assertThat(rootPayload.get("out")).isEqualTo(out);
}
5. Excel test workbooks
For data-driven testing of many scenarios, define one workbook per model, e.g. src/test/resources/model/test/P002/p002Test.xlsx. Both Microsoft Excel and LibreOffice Calc can be used to create and edit the file (.xlsx format).
Each sheet is a group of test cases, each data row is one test case that starts the model, optionally fires timers, and asserts the results.
Sheet layout
| Row | Content |
|---|---|
| 1 | Path of each column's value (see path notation below) |
| 2 | Type of each column: String, Boolean, Integer, Long, Double, Date |
| 3+ | One test case per row |
Parsing rules:
- The column count is taken from row 1: columns are read from the first cell up to the first empty header cell.
- Rows are read until the first completely empty row — everything below is ignored (useful for comments or temporarily disabled cases).
- Empty cells are skipped — the variable / check is simply not used for that row.
- Formulas are evaluated (e.g.
=TRUE()); the displayed value is what counts.
Path notation (row 1)
The path decides where the value goes. Nested objects use ., array elements use [index]:
| Path prefix | Meaning |
|---|---|
root.<name> |
Variable on the root process (like __ROOT in JSON) |
in.<name> |
In-mapping into the model (like __IN) |
out.<source> |
Out-mapping; the cell value is the target variable name (like __OUT) |
id |
Required. Process definition key of the model under test, e.g. TST_P002 |
timer |
Number of timer jobs to fire after start (Integer) |
test.<name> |
Expected value of variable <name> in the root process payload after the run |
audit |
Expected total number of audit records (Integer) |
email |
Expected total number of sent emails (Integer) |
Nested examples:
| Header | Effect |
|---|---|
root.param |
Root variable param |
root.param[0], root.param[1] |
Root variable param as array with two elements |
root.items[1].name |
Root variable items, array whose second element is an object with field name |
test.result[0] |
Asserts the first element of array variable result |
Array elements that are skipped (e.g. only [1] is filled) are padded with empty objects, because Flowable cannot store null list elements.
Special cell values
| Value | In input columns (root., in., out.) |
In test. columns |
|---|---|---|
| (empty cell) | Variable not set | No check |
__NULL |
Variable set to JSON null |
No check (a null expectation matches anything) |
__N_A |
– | Asserts the variable/field is absent or null |
__EMPTY |
Empty string "" (type String only) |
Expects "" |
__BLANK |
Single space " " (type String only) |
Expects " " |
Types (row 2)
| Type | Cell content |
|---|---|
String |
Any text |
Boolean |
TRUE / FALSE (or a formula like =TRUE()) |
Integer, Long, Double |
Numeric value |
Date |
A date cell displayed as yyyy-MM-dd or yyyy-MM-dd HH:mm:ss. The displayed (local) date is interpreted as UTC |
Example sheet
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | root.nr |
root.param |
in.param |
out.result |
id |
timer |
test.result |
test.outResult |
| 2 | String |
String |
String |
String |
String |
Integer |
String |
String |
| 3 | 11 | TST_P002 | ||||||
| 4 | 12 | hello root | hello | outResult | TST_P002 | 1 | hello root | hello |
| 5 | 13 | hello | outResult | TST_P002 | 0 | __N_A |
__NULL |
- Row 3: starts
TST_P002with no parameters and no checks (smoke test). - Row 4: sets root and in parameters, maps
resultout tooutResult, fires one timer, then assertsresult == "hello root"andoutResult == "hello". - Row 5: no root param; asserts that
resultwas not set (__N_A) and skips theoutResultcheck.
Tip: a column like root.nr is a handy row number — it appears in the test log line of every row, which makes failures easy to locate.
How assertions work
After the run, the expected values from the test. columns are compared as a subset against the historic root process payload: every expected field must exist with the same (string-compared) value; extra variables in the payload are ignored. On failure the test prints both sides:
test : {"result":"hello root","outResult":"hello"}
root : {"nr":"12","param":"hello root", ...}
If audit / email columns are present, the total counts are asserted as well.
Wiring the workbook into JUnit
Every data row of every sheet becomes one JUnit test invocation:
private Stream<Arguments> p002ExcelTestData() {
return flowableModelTest.getJsonArgumentsFromExcel("model/test/P002/p002Test.xlsx");
}
@ParameterizedTest
@MethodSource("p002ExcelTestData")
public void p002ExcelTest(String path, JsonNode argument) {
flowableModelTest.testJsonExcelRow(path, argument);
}
The Obj variant works with plain Java maps and additionally returns a result map that can drive detailed email / audit assertions (they only run for rows where the email / audit count is > 0):
private Stream<Arguments> p002ExcelTestData2() {
return flowableModelTest.getObjArgumentsFromExcel("model/test/P002/p002Test.xlsx");
}
@ParameterizedTest
@MethodSource("p002ExcelTestData2")
public void p002ExcelTest2(String path, Map<String, Object> argument) {
Map<String, Object> result = flowableModelTest.testObjExcelRow(path, argument);
flowableModelTest.checkAndAssertEmail(result, 1,
"Email", "Test", "test@flowable.com");
flowableModelTest.checkAndAssertAuditRecord(result, 1,
"Audit record", "TST_P002 Audit trail entry", "system", null);
}
6. Troubleshooting
| Symptom | Cause / fix |
|---|---|
Implicit parameter 'x' in <file> |
A top-level field outside __ROOT / __IN / __OUT in a JSON parameter file — move it into the right block |
Resource not found: <path> |
Path is relative to src/test/resources and is loaded from the classpath — check spelling and location |
Column 'id' missing in row of <file> |
The Excel sheet has no id column, or the cell is empty for that row |
No open task with key X found |
The model did not reach the expected user task — or more than one task with that key is open |
No active timer |
The timer count in the row is higher than the number of timer jobs the model actually creates |
Missing ']' in path / Invalid array index in path |
Malformed header path in row 1, e.g. root.param[0 or root.param[a] |
Variable type not supported: X |
Typo in row 2 — allowed: String, Boolean, Integer, Long, Double, Date |
Subset assertion fails with test: / root: output |
Compare both printed JSON documents; remember values are compared as strings and __N_A requires absence |