Files
2025.2/customer-work/docs/model-tests.md
T
2026-07-10 21:41:28 +02:00

371 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
1. Directly in Java (simple, single-scenario tests)
2. As JSON parameter files (one scenario per file)
3. 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. `P002` for process `TST_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 in `com.customer.work.model` normally 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": value` maps `${__IN.name}` to the variable `name` of the called process.
- The special variable `__OUT` (a map) creates **out-mappings**: each entry `"source": "target"` copies variable `source` of the called process to variable `target` of 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:
```properties
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:
```java
flowableModelTest.exportApp("http://localhost:8106", "myWorkspace", "TST_APP", "admin", "test");
```
---
## 3. Writing a model test class
Annotate the class with `@FlowableModelTest` and inject `FlowableModelTestUtils`:
```java
@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
```bash
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`
```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
```java
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_P002` with no parameters and no checks (smoke test).
- Row 4: sets root and in parameters, maps `result` out to `outResult`, fires one timer, then asserts `result == "hello root"` and `outResult == "hello"`.
- Row 5: no root param; asserts that `result` was **not** set (`__N_A`) and skips the `outResult` check.
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:
```java
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):
```java
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 |