Skip to main content

Testing process definitions

Test your executable BPMN processes as they are software. If possible, do automated unit tests with a fast in-memory workflow engine. Before releasing, verify with integration tests close to your real-life environment, which might include human-driven, exploratory integration tests.

This best practice uses the following process example:

1

New tweets need to be reviewed before publication.

2

The tweeting employee is notified about rejected tweets.

3

Approved tweets get published.

4

Duplicate tweets are rejected by Twitter and dealt with by the original author (e.g. rephrased) just to be reviewed again.

Testing scopes​

There are basically three typical test scopes used when building process solutions:

  1. Unit tests: Testing glue code or programming code you developed for your process solution. How to unit test your software itself is not discussed here, as this is a common practice for software development.

  2. Process tests: Testing the expected behavior of the process model, including glue code and specifically the data flowing through the process model. Those tests should run frequently, so they should behave like unit tests (quick turnaround, no need for external resources, etc.)

  3. Integration tests: Testing the system in a close-to-real-life-environment to make sure it is really working. This is typically done before releasing a new version of your system. Those tests include human-driven, exploratory tests.

Scopes

Writing process tests in Java​

Camunda Cloud only

This section targets Camunda Cloud. Refer to the specific Camunda 7 section below if you are looking for Camunda Platform 7.x.

This section describes how to write process tests as unit tests in Java. Later in this best practice, you will find some information on writing tests in other languages, like Node.Js or C#.

When using Java, most customers use Spring Boot. While this is a common setup for customers, it is not the only one. Find some more examples of Java process tests in the README.md of the zeebe-process-test project.

Technical setup using Spring​

JUnit 5

You need to use JUnit 5. Ensure you use JUnit 5 in every test class: the @Test annotation you import needs to be org.junit.jupiter.api.Test.

  1. Use JUnit 5 as unit test framework.
  2. Use spring-zeebe.
  3. Use @ZeebeSpringTest to ramp up an in-memory process engine.
  4. Use annotations from zeebe-process-test to check whether your expectations about the state of the process are met.
  5. Use mocking of your choice, e.g. Mockito to mock service methods and verify that services are called as expected.

A test can now look like the following example. The complete source code is available on GitHub:

@SpringBootTest
@ZeebeSpringTest
public class TestTwitterProcess {

@Autowired
private ZeebeClient zeebe;

@MockBean
private TweetPublicationService tweetPublicationService;


@Test
public void testTweetApproved() throws Exception {
// Prepare data input
TwitterProcessVariables variables = new TwitterProcessVariables()
.setTweet("Hello world")
.setBoss("Zeebot");

// start a process instance
ProcessInstanceEvent processInstance = zeebe.newCreateInstanceCommand() //
.bpmnProcessId("TwitterDemoProcess").latestVersion() //
.variables(variables) //
.send().join();

// And then retrieve the UserTask and complete it with 'approved = true'
waitForUserTaskAndComplete("user_task_review_tweet", Collections.singletonMap("approved", true));

// Now the process should run to the end
waitForProcessInstanceCompleted(processInstance);

// Let's assert that it passed certain BPMN elements (more to show off features here)
assertThat(processInstance)
.hasPassedElement("end_event_tweet_published")
.hasNotPassedElement("end_event_tweet_rejected")
.isCompleted();

// And verify it caused the right side effects b calling the business methods
Mockito.verify(twitterService).tweet("Hello world");
Mockito.verifyNoMoreInteractions(twitterService);
}
}

Test scope and mocking​

In such a test case, you want to test the executable BPMN process definition, plus all the glue code which logically belongs to the process definition in a wider sense. Typical examples of glue code you want to include in a process test are:

  • Worker code, typically connected to a service task
  • Expressions (FEEL) used in your process model for gateway decisions or input/output mappings
  • Other glue code, for example, a REST API that does data mapping and delegates to the workflow engine

In the example above, this is the worker code and the REST API:

Process test scope example

Workflow engine-independent business code should not be included in the tests. In the Twitter example, the TwitterService will be mocked, and the TwitterWorker will still read process variables and call this mock. This way, you can make test the process model, the glue code, and the data flow in your process test.

The following code examples highlight the important aspects around mocking.

The PublishTweetWorker is executed as part of the test. It does input data mapping (1) and also translates a specific business exception into a BPMN error (2):

@Autowired
private TwitterService twitterService;

@ZeebeWorker( type = "publish-tweet", autoComplete = true)
public void handleTweet(@ZeebeVariablesAsType TwitterProcessVariables variables) throws Exception {
try {
twitterService.tweet(
variables.getTweet() // 1
);
} catch (DuplicateTweetException ex) { // 2
throw new ZeebeBpmnError("duplicateMessage", "Could not post tweet, it is a duplicate.");
}
}

The TwitterService is considered a business service (it could, for example, wrap the twitter4j API) and shall not be executed during the test. This is why this interface is mocked:

@MockBean
private TwitterService tweetPublicationService;

@Test
public void testTweetApproved() throws Exception {
// ...
// Using Mockito you can make sure a business method was called with the expected parameter
Mockito.verify(tweetPublicationService).tweet("Hello world");
}

@Test
public void testDuplicate() throws Exception {
// Using Mockito you can define what should happen if a method is called, in this case an exception is thrown to simulate a business error
Mockito.doThrow(new DuplicateTweetException("DUPLICATE")).when(tweetPublicationService).tweet(anyString());
//...

Drive the process and assert the state​

For tests, you drive the process from waitstate to waitstate and assert that you see the expected process and variable states. For example, you might implement a test to test the scneario that a tweet gets approved:

@Test
public void testTweetApproved() throws Exception {
// Prepare data input
TwitterProcessVariables variables = new TwitterProcessVariables()
.setTweet("Hello world")
.setBoss("Zeebot");

// start a process instance <1>
ProcessInstanceEvent processInstance = zeebe.newCreateInstanceCommand() //
.bpmnProcessId("TwitterDemoProcess").latestVersion() //
.variables(variables) //
.send().join();

// And then retrieve the UserTask and complete it with 'approved = true' <2>
waitForUserTaskAndComplete("user_task_review_tweet", Collections.singletonMap("approved", true));

// Now the process should run to the end
waitForProcessInstanceCompleted(processInstance);

// Let's assert that it passed certain BPMN elements (more to show off features here) <3>
assertThat(processInstance)
.hasPassedElement("end_event_tweet_published")
.hasNotPassedElement("end_event_tweet_rejected")
.isCompleted();

// And verify it caused the right side effects b calling the business methods <4>
Mockito.verify(twitterService).tweet("Hello world");
Mockito.verifyNoMoreInteractions(twitterService);
}
  1. Create a new process instance. You may want to use some glue code to start your process (e.g. the REST API facade), or also create helper methods within your test class.

  2. Drive the process to its next waitstate, e.g. by completing a waiting user task. You may extract boilerplate code into helper methods as shown below.

  3. Assert that your process is in the expected state.

  4. Verify with your mocking library that your business service methods were called as expected.

This is the helper method used to verify the workflow engine arrived in a specific user task, and complete that task with passing on some variables. As you can see, a user task behaves like a service task with the type io.camunda.zeebe:userTask:

public void waitForUserTaskAndComplete(String userTaskId, Map<String, Object> variables) {
// Let the workflow engine do whatever it needs to do
inMemoryEngine.waitForIdleState();

// Now get all user tasks
List<ActivatedJob> jobs = zeebe.newActivateJobsCommand().jobType(USER_TASK_JOB_TYPE).maxJobsToActivate(1).send().join().getJobs();

// Should be only one
assertTrue(jobs.size()>0, "Job for user task '" + userTaskId + "' does not exist");
ActivatedJob userTaskJob = jobs.get(0);
// Make sure it is the right one
if (userTaskId!=null) {
assertEquals(userTaskId, userTaskJob.getElementId());
}

// And complete it passing the variables
if (variables!=null) {
zeebe.newCompleteCommand(userTaskJob.getKey()).variables(variables).send().join();
} else {
zeebe.newCompleteCommand(userTaskJob.getKey()).send().join();
}
}

Be careful not to "overspecify" your test method by asserting too much. Your process definition will likely evolve in the future and such changes should break as little test code as possible, but just as much as necessary!

As a rule of thumb always assert that the expected external effects of your process really took place (e.g. that business services were called as expected). Additionally, carefully choose which aspects of internal process state are important enough so that you want your test method to warn about any related change later on.

Testing your process in chunks​

Divide and conquer by testing your process in chunks. Consider the important chunks and paths the Tweet Approval Process consists of:

1

The happy path: The tweet just gets published.

2

The tweet gets rejected.

3

A duplicated tweet gets rejected by Twitter.

Testing the happy path​

The happy path is kind of the default scenario with a positive outcome, so no exceptions or errors or deviations are experienced.

Fully test the happy path in one (big) test method. This makes sure you have one consistent data flow in your process. Additionally, it is easy to read and to understand, making it a great starting point for new developers to understand your process and process test case.

You were already exposed to the happy path in our example, which is the scenario that the tweet gets approved:

@Test
public void testTweetApproved() throws Exception {
// Prepare data input
TwitterProcessVariables variables = new TwitterProcessVariables()
.setTweet("Hello world")
.setBoss("Zeebot");

// start a process instance <1>
ProcessInstanceEvent processInstance = zeebe.newCreateInstanceCommand() //
.bpmnProcessId("TwitterDemoProcess").latestVersion() //
.variables(variables) //
.send().join();

// And then retrieve the UserTask and complete it with 'approved = true' <2>
waitForUserTaskAndComplete("user_task_review_tweet", Collections.singletonMap("approved", true));

// Now the process should run to the end
waitForProcessInstanceCompleted(processInstance);

// Let's assert that it passed certain BPMN elements (more to show off features here) <3>
assertThat(processInstance)
.hasPassedElement("end_event_tweet_published")
.hasNotPassedElement("end_event_tweet_rejected")
.isCompleted();

// And verify it caused the right side effects b calling the business methods <4>
Mockito.verify(twitterService).tweet("Hello world");
Mockito.verifyNoMoreInteractions(twitterService);
}

Testing detours​

Test forks/detours from the happy path as well as errors/exceptional paths as chunks in separate test methods. This allows to unit test in meaningful units.

The tests for the exceptional paths are basically very similar to the happy path in our example:

@Test
public void testRejectionPath() throws Exception {
TwitterProcessVariables variables = new TwitterProcessVariables()
.setTweet("Hello world")
.setBoss("Zeebot");

ProcessInstanceEvent processInstance = zeebe.newCreateInstanceCommand() //
.bpmnProcessId("TwitterDemoProcess").latestVersion() //
.variables(variables) //
.send().join();

waitForUserTaskAndComplete("user_task_review_tweet", Collections.singletonMap("approved", false));

waitForProcessInstanceCompleted(processInstance);
waitForProcessInstanceHasPassedElement(processInstance, "end_event_tweet_rejected");
Mockito.verify(twitterService, never()).tweet(anyString());
}

and:

@Test
public void testDuplicateTweet() throws Exception {
// throw exception simulating duplicateM
Mockito.doThrow(new DuplicateTweetException("DUPLICATE")).when(twitterService).tweet(anyString());

TwitterProcessVariables variables = new TwitterProcessVariables()
.setTweet("Hello world")
.setAuthor("bernd")
.setBoss("Zeebot");

ProcessInstanceEvent processInstance = zeebe.newCreateInstanceCommand() //
.bpmnProcessId("TwitterDemoProcess").latestVersion() //
.variables(variables) //
.send().join();

waitForUserTaskAndComplete("user_task_review_tweet", Collections.singletonMap("approved", true));

waitForProcessInstanceHasPassedElement(processInstance, "boundary_event_tweet_duplicated");
// TODO: Add human task to test case
waitForUserTaskAndComplete("user_task_handle_duplicate", new HashMap<>());
}

Integration tests​

Test the process in a close-to-real-life environment. This verifies that it really works before releasing a new version of your process definition, which includes human-driven, exploratory tests.

Clearly define your goals for integration tests! Goals could be:

  • End user & acceptance tests
  • Complete end-to-end tests
  • Performance & load tests, etc.

Carefully consider automating tests on scope 3. You need to look at the overall effort spent on writing test automation code and maintaining it when compared with executing human-driven tests for your software project's lifespan. The best choice depends very much on the frequency of regression test runs.

Most effort is typically invested in setting up proper test data in surrounding systems.

Configure your tests to be dedicated integration tests, and separate them from unit or process tests.

You can use typical industry standard tools for integration testing together with Camunda.

Technical setup and example using Camunda Platform 7​

Camunda Platform 7 only

This section targets Camunda Platform 7.x only. Refer to the previous sections if you are using Camunda Cloud.

Camunda Platform 7 also has support for writing tests in Java. This section gives you an example, the basic ideas of test scopes and testing in chunks are also valid with Camunda Platform 7.

The technical setup for Camunda Platform 7:

  1. Use JUnit as unit test framework.
  2. Use Camunda's JUnit Rule to ramp up an in-memory process engine where the JobExecutor is turned off.
  3. Use Camunda's @Deployment annotation to deploy and un-deploy one or more process definitions under test for a single test method.
  4. Use camunda-bpm-assert to easily check whether your expectations about the state of the process are met.
  5. Use mocking of your choice, e.g. Mockito plus PowerMock to mock service methods and verify that services are called as expected.
  6. Use Camunda's MockExpressionManager to resolve bean names used in your process definition without the need to ramp up the dependency injection framework (like CDI or Spring).
  7. Use an In-Memory H2 database as default database to test processes on developer machines. If required, you can run the same tests on multiple databases, e.g. Oracle, DB2, or MS-SQL on a CI-Server. To achieve that, you can make use of (e.g. Maven) profiles and Java properties files for database configuration.

Let's use the same example as above.

A typical test case will look like this:

// ...
import static org.camunda.bpm.engine.test.assertions.ProcessEngineTests.*; // <4>
import static org.mockito.Mockito.*; // <5>

@RunWith(PowerMockRunner.class) // <1> <5>
public class TwitterTest {

@Rule
public ProcessEngineRule processEngineRule = new ProcessEngineRule(); // <2>

@Mock // Mockito mock instantiated by PowerMockRunner <5>
private TweetPublicationService tweetPublicationService;

@Before
public void setup() {
// ...
Mocks.register("tweetPublicationDelegate", tweetPublicationDelegate); // <6>
}

@Test // <1>
@Deployment(resources = "twitter/TwitterDemoProcess.bpmn") // <3>
public void testTweetApproved() {
// ...
}
// ...
}

The service task Publish on Twitter delegates to Java code:

<serviceTask id="service_task_publish_on_twitter" camunda:delegateExpression="#{tweetPublicationDelegate}" name="Publish on Twitter">
</serviceTask>

And this Java delegate itself calls a business method:

@Named
public class TweetPublicationDelegate implements JavaDelegate {

@Inject
private TweetPublicationService tweetPublicationService;

public void execute(DelegateExecution execution) throws Exception {
String tweet = new TwitterDemoProcessVariables(execution).getTweet(); // 1
// ...
try {
tweetPublicationService.tweet(tweet); // 2
} catch (DuplicateTweetException e) {
throw new BpmnError("duplicateMessage"); // 3
}
}
// ...

The TweetPublicationService is mocked:

@Mock // 1
private TweetPublicationService tweetPublicationService;

@Before
public void setup() {
// set up java delegate to use the mocked tweet service
TweetPublicationDelegate tweetPublicationDelegate = new TweetPublicationDelegate(); // 2
tweetPublicationDelegate.setTweetService(tweetPublicationService);
// register a bean name with mock expression manager
Mocks.register("tweetPublicationDelegate", tweetPublicationDelegate); // 3
}

@After
public void teardown() {
Mocks.reset(); // 3
}

Now you can test the happy path to a published tweet:

@Test
@Deployment(resources = "twitter/TwitterDemoProcess.bpmn")
public void testTweetApproved() {
// given
ProcessInstance processInstance = runtimeService().startProcessInstanceByKey(
"TwitterDemoProcess",
withVariables(TwitterDemoProcessConstants.VAR_NAME_TWEET, TWEET)); // 1
assertThat(processInstance).isStarted();
// when
complete(task(), withVariables(TwitterDemoProcessConstants.VAR_NAME_APPROVED, true)); //2
// then
assertThat(processInstance) // 3
.hasPassed("end_event_tweet_published")
.hasNotPassed("end_event_tweet_rejected")
.isEnded();
verify(tweetPublicationService).tweet(TWEET); // 4
verifyNoMoreInteractions(tweetPublicationService);
}

As a next step, you might want to test the path where a tweet gets rejected. You don't have to start at the start event, but can start anywhere in your process:

@Test
@Deployment(resources = "twitter/TwitterDemoProcess.bpmn")
public void testTweetRejected() {

// create a process instance directly at the point at which a tweet was rejected
ProcessInstance processInstance = runtimeService()
.createProcessInstanceByKey("TwitterDemoProcess")
.startBeforeActivity("service_task_publish_on_twitter")
.setVariables(variables)
.execute();
assertThat(processInstance)
.isStarted()
.hasPassed("service_task_publish_on_twitter")
.hasVariables(TwitterDemoProcessConstants.VAR_NAME_TWEET);

// when
complete(task(), withVariables(TwitterDemoProcessConstants.VAR_NAME_APPROVED, false)); // 2

// then
assertThat(processInstance)
.hasPassed("end_event_tweet_rejected")
.hasNotPassed("end_event_tweet_published")
.isEnded();
verifyZeroInteractions(tweetPublicationService);
}

You could also implement another testTweetDuplicated() to verify the logic in case a tweet turns out to be a duplicate and is rejected by Twitter. For this case, we attached an error event to the service task Publish on Twitter. In the BPMN XML we see an error event defined with an errorCode duplicateMessage.

  <boundaryEvent id="boundary_event_tweet_duplicated" name="Tweet duplicated" attachedToRef="service_task_publish_on_twitter">
<errorEventDefinition id="error_event_definition_tweet_duplicated" errorRef="error_tweet_duplicated"/>
</boundaryEvent>
<error id="error_tweet_duplicated" errorCode="duplicateMessage" name="Tweet duplicated"/>

Above, we already saw the Java delegate code throwing the BPMN error exception with that code duplicateMessage. Here is the method testing for the case a tweet is duplicated:

@Test
@Deployment(resources = "twitter/TwitterDemoProcess.bpmn")
public void testTweetDuplicated() {
// given
doThrow(new DuplicateTweetException()) // 1
.when(tweetPublicationService).tweet(anyString());
// when
ProcessInstance processInstance = rejectedTweet(withVariables(TwitterDemoProcessConstants.VAR_NAME_TWEET, TWEET)); // 2
// then
assertThat(processInstance) // 3
.hasPassed("boundary_event_tweet_duplicated")
.hasNotPassed("end_event_tweet_rejected").hasNotPassed("end_event_tweet_published")
.isWaitingAt("user_task_handle_duplicate");
verify(tweetPublicationService).tweet(TWEET); // 4
verifyNoMoreInteractions(tweetPublicationService);
// when
complete(task()); // 5
// then
assertThat(processInstance) // 6
.isWaitingAt("user_task_review_tweet")
.hasVariables(TwitterDemoProcessConstants.VAR_NAME_TWEET)
.task().isAssignedTo("demo");
}