Flowable is the open source business process engine using BPMN 2.0 standards, which has been forked from Activiti along with other famous Camunda engine. We’ve picked Flowable as our BPMN engine for one of the project I’ve been recently involved in and I’ve developed some processes using it. In this article I’d like to share some ideas about some tricky parts of this development process and how to test such processes properly.
The project I mention was my first one using Kotlin which I enjoyed at least as much as I decided to write the test project for this article in Kotlin as well. To write tests we will use JUnit integrated with Spring Boot. You can also find all the related sources on GitHub.
Let’s start with the case overview. What we have is a simple User
entity (note, that I’m omitting less important parts of presented classes, but you can still find the full source on GitHub):
class User(val id: String): Serializable {
var banned = false
}
This entity is controlled by this dummy service:
@Service
class UserService {
fun findUser(id: String): User? {
throw NotImplementedError()
}
fun isBanned(user: User): Boolean {
throw NotImplementedError()
}
fun ban(user: User) {
throw NotImplementedError()
}
}
So, what we basically can do here is: retrieving the user by id, banning him and checking if is banned. This service is deliberately left not implemented. We don’t need the working implementation for the tests because we will mock this service in the next chapter.
Having all this stuff I defined following process of banning the user:
BanUserProcess
In the first step we check if user is active and then - depending on it’s state - we either ban active user or throw ERR_USER_BAN
error if user is already banned. The additional thing can go awry here is when the user cannot be found by his ID. In such scenario we also can’t ban him and we throw the same error.
To illustrate full spectrum of problems with unit testing I designed Check user active step as a call activity. This is a separate process to be run in this step, but modelled outside the current process scope. This is very useful construct to be used when you want to reuse your processes: checking if user is active can be also a part of different processes we can imagine.
This is the diagram of this internal process:
IsUserActiveProcess
Under the hood, the outer process starts to work with userId
variable and before running call activity it passes this variable to it, so that the inner process know which user to check. Then, the inner process checks if the user is active and sets the result into its result
variable accordingly. Finally, when the inner process ends, the outer process gets the result
variable from the inner process variables, copies it as isUserActiveResult
variable into its own process variables, and uses it to make further decision.
When the user doesn’t exist the inner process throws ERR_USER_NOT_EXISTS
error which is then caught in the outer process by boundary error event and converted to ERR_USER_BAN
error.
Having everything done this way we can now face all problems we faced in the real project with testing processes, because we both have two processes connected by a call activity and the necessity of proper exceptions handling.
The last thing in this puzzle are the Spring beans controlling both processes, which are called service tasks. The outer process service task can only ban the user:
@Component
class BanUserServiceTask @Autowired constructor(
val userService: UserService
) {
fun ban(userId: String) {
userService.findUser(userId)?.let {
logger.debug("Banning user: $it")
userService.ban(it)
return
}
throw IllegalStateException() // shouldn't ever happen in the process
}
}
While the inner process service task implements two methods: checking if user exists and if is active:
@Component
class IsUserActiveServiceTask @Autowired constructor(
val userService: UserService
) {
fun checkUserExists(userId: String) {
userService.findUser(userId).let {
if (it == null) {
logger.error("User with id: $userId is not found")
throw BpmnError(ERR_USER_NOT_EXIST)
}
logger.debug("Found user: $it")
}
}
fun isUserActive(userId: String): Boolean {
userService.findUser(userId)?.let {
return !userService.isBanned(it).apply {
logger.debug("Check user: $it is active and returning: ${!this}")
}
}
throw IllegalStateException() // shouldn't ever happen in the process
}
}
When you use Flowable - Spring integration all beans become available for the processes through the expression language (EL), so that you can always refer you beans in process nodes with following example syntax: #{banUserServiceTask.ban(userId)}
.
Now few words about testing approach we finally decided to apply. The first important thing we assumed is that all processes should be tested in isolation. It means that for our both processes we should have two separate tests. Imagine for example a situation where Check user is active process is used in few different processes as a call activity. If you don’t have separate test for this process itself, you’d need to test this process together with all of their parent processes, which would be redundant and overhelming. If this process has a bug, all your (parent process) tests will fail and it will be very difficult to find the culprit. To avoid such problem we assumed two things:
The second important issue is how far the process test should reach, and our conculsion is that process tests should test only process flows and nothing more. In our example the UserService
can check if user exists in some kind of database. However, in process tests we don’t want to touch this database at all because we’d be vulnerable to problems in the persistence layer. Another operation here is banning the user. For now it can be done only by setting a flag on database entity, but in the future we may ask separate authorization service for this kind of information. Again, we don’t want to execute this logic in process test and be vulnerable to the problems with this code. Testing whether checking if user is active logic works belongs to completely different tests.
As a conclusion for this part we assumed that all service tasks should be simple beans with all dependencies injected by the constructor. Like this one:
@Component
class BanUserServiceTask @Autowired constructor(
val userService: UserService
)
This way we can mock these service tasks for the process tests this way:
val userService = mock(UserService::class.java)
val serviceTask = BanUserServiceTask(userService)
Reviewing org.flowable.engine.test
package reveals that Flowable already has a support for mocking service tasks and gives two options for that:
TestActivityBehaviorFactory
seems to be appropriate for mocking class delegates. However, because of using Spring integration we don’t use them in this project.Mocks
which looks to be appropriate for mocking beans and works in conjunction with MockExpressionManager
to resolve beans in EL expressions.To carry on with the second solution we need to register MockExpressionManager
as our EL expression manager in flowable config (for the test context only):
@Configuration
class TestConfiguration {
@Bean
fun flowableTestSpringProcessEngineConfig() = EngineConfigurationConfigurer<SpringProcessEngineConfiguration> {
it.expressionManager = MockExpressionManager()
}
}
Then to be DRY we can put some common logic of all process tests into a single super class:
@RunWith(SpringRunner::class)
@SpringBootTest
abstract class BaseProcessTest {
@Autowired protected lateinit var runtimeService: RuntimeService
abstract fun processName(): String
fun startProcess(variables: Map<String, Any> = mapOf()) {
logger.debug("Starting process: ${processName()}")
runtimeService.startProcessInstanceByKey(processName(), variables)
}
@After
fun tearDown() {
logger.debug("Unregistering mocks")
Mocks.reset()
}
}
In tearDown()
method, which is invoked after each @Test
, we always unregister all mocks that could be previously registered by concrete tests.
Having this whole setup we can now create our first tests with mocked service tasks:
class IsUserActiveProcessTest: BaseProcessTest() {
protected fun prepareEnvAndStartProcess(userExists: Boolean = true, userIsActive: Boolean = true): ProcessAssertions {
val mockUserService = mock(UserService::class.java)
val mockUser = User()
if (userExists) {
`when`(mockUserService.findUser(mockUser.id)).thenReturn(mockUser)
`when`(mockUserService.isBanned(mockUser)).thenReturn(!userIsActive)
} else {
`when`(mockUserService.findUser(ArgumentMatchers.anyString())).thenReturn(null)
}
Mocks.register("isUserActiveServiceTask", IsUserActiveServiceTask(mockUserService))
return startProcess(mapOf("userId" to mockUser.id))
}
@Test
fun testUserInactive() {
logger.debug("testUserInactive()")
prepareEnvAndStartProcess(userIsActive = false)
}
@Test
fun testUserActive() {
logger.debug("testUserActive()")
prepareEnvAndStartProcess(userIsActive = true)
}
}
By default Flowable uses SpringExpressionManager
to resolve EL expressions and the example #{isUserActiveServiceTask}
expression evaluates to Spring bean with isUserActiveServiceTask
name. In prepareEnvAndStartProcess()
we use Mocks.register()
to register mocked bean instance for tests with the same isUserActiveServiceTask
name and overwrite the default one. All further #{isUserActiveServiceTask}
expressions will now return our mocked instance.
After running our two test methods we can observe following results in the log:
testUserActive()
Starting process: IsUserActive
Found user: User(id='6dbcb954-ac34-4945-abdc-ff239c662069', banned=false)
Check user: User(id='6dbcb954-ac34-4945-abdc-ff239c662069', banned=false) is active and returning: true
Unregistering mocks
testUserInactive()
Starting process: IsUserActive
Found user: User(id='dcf81663-abfb-48c3-a181-d19b81c1d323', banned=false)
Check user: User(id='dcf81663-abfb-48c3-a181-d19b81c1d323', banned=false) is active and returning: false
Unregistering mocks
So far, so good :)
At this point we should add some assertions to our test methods, checking if the process works in the desired way. We could also be tempted to do these assertions on ProcessInstance
which apparently can be retrieved from runtimeService.startProcessInstanceByKey()
and passed to the test method from BaseProcessTest.startProcess()
.
Unfortunately it seems to be a wrong direction. Firstly because ProcessInstance
doesn’t provide any useful information about the process flows taken and can be used only to test the values of process variables after the process is finished. Secondly the ProcessInstance
won’t even be returned from the methods above if the process ends up with an exception, what can be a proper path of the process which we would also like to test. In such scenario the exception will be thrown directly from runtimeService.startProcessInstanceByKey()
and the original ProcessInstance
will be lost.
The solution for both checking process paths and not to have ProcessInstance
lost is using FlowableEventListener
which can be registered directly on RuntimeService
and allows to listen all events from the process during its execution. In the example below we are going to derive our listener from already implemented flowable EventLogger
. The primary usage of this listener is to log all events from the process, convert them to EventLogEntryEntity
entities and write them back to db. We are going to use all these features but finally we won’t save events to db. Instead we will write them to the internal map for further investigation in our unit tests:
class ProcessTestEnvironment: EventLogger(DefaultClockImpl(), ObjectMapper()) {
var processInstance: ProcessInstance? = null
var exception: Throwable? = null // [1] the exception thrown by the process, if any
set(value) {
logger.debug("Exception thrown", value)
field = value
}
val events: List<Map<String, Any>> = mutableListOf()
protected val eventFlusher: AbstractEventFlusher = object: AbstractEventFlusher() {
override fun closeFailure(commandContext: CommandContext?) {
}
override fun afterSessionsFlush(commandContext: CommandContext?) {
}
override fun closing(commandContext: CommandContext?) {
// [2] extract data from event handlers
for (eventHandler in eventHandlers) {
val eventLogEntryEntity = eventHandler.generateEventLogEntry(commandContext)
val map = objectMapper.readValue(eventLogEntryEntity.data, HashMap::class.java) as HashMap<String, Any>
map[FIELD_TYPE] = eventLogEntryEntity.type
(events as MutableList).add(map)
}
}
}
override fun createEventFlusher(): EventFlusher = eventFlusher
override fun onEvent(event: FlowableEvent?) {
// [3] getting the process instance on process start
if (event is FlowableProcessStartedEventImpl && event.entity is ExecutionEntityImpl) {
processInstance = (event.entity as ExecutionEntityImpl).processInstance
}
super.onEvent(event)
}
}
Three important things happen here (please check the markups in the code above):
startProcess()
code, because this way we will loose the event logger instance. Instead, we store the exception in event logger field and return the logger. This allows us to do both: proper assertions about the process execution and checking the type of the exceptin thrown.ProcessInstance
, which is not susceptible on described previously exception problem. We need this object for further investigation of process variables.We could already use this class to do assertions in process tests, however I’ve found it useful to hide its implemenation details from the user code and give him the handly abstraction allowing to do assertions only:
class ProcessAssertions(protected val processTestEnvironment: ProcessTestEnvironment) {
/**
* Checks whether the process variable is set
**/
fun assertVariable(name: String, value: Any) {
assertThat((processTestEnvironment.processInstance as VariableScopeImpl).getVariable(name)).isEqualTo(value)
}
/**
* Checks whether the activity has been started. This means the process flow reached the activity, but it could be either completed
* or fail with an exception.
**/
fun assertActivityStarted(name: String, started: Boolean = true) {
val events = processTestEnvironment.events.filter {
it[ProcessTestEnvironment.FIELD_TYPE] == FlowableEngineEventType.ACTIVITY_STARTED.name && it[Fields.ACTIVITY_ID] == name
}
assertThat(events)
.withFailMessage("Activity $name has ${if (started) "not" else ""} been started")
.hasSize(if (started) 1 else 0)
}
fun assertActivityNotStarted(name: String) = assertActivityStarted(name, false)
/**
* Checks whether the activity has been completed, ie. ended without exception.
**/
fun assertActivityCompleted(name: String, completed: Boolean = true) {
val events = processTestEnvironment.events.filter {
it[ProcessTestEnvironment.FIELD_TYPE] == FlowableEngineEventType.ACTIVITY_COMPLETED.name && it[Fields.ACTIVITY_ID] == name
}
assertThat(events)
.withFailMessage("Activity $name has ${if (completed) "not" else ""} been completed")
.hasSize(if (completed) 1 else 0)
}
fun assertActivityNotCompleted(name: String) = assertActivityCompleted(name, false)
/**
* Checks whether process ended with the exception of given class.
*/
fun assertException(clazz: KClass<Throwable>) {
assertThat(processTestEnvironment.exception)
.withFailMessage("Process hasn't ended with exception")
.isNotNull()
assertThat(processTestEnvironment.exception)
.withFailMessage("Process ended with exception: ${processTestEnvironment.exception!!.javaClass.simpleName} " +
"which is different than asserted: ${clazz.java.simpleName}")
.isOfAnyClassIn(clazz.java)
}
}
There are two useful events used in the assertion methods above:
ACTIVITY_STARTED
is when the process reaches and enters given activity.ACTIVITY_COMPLETED
is when the process exits given activity.In the most of cases we will check if activity has been completed. However, if the activity ends up with an exception ACTIVITY_COMPLETED
event is not generated, but we at least have ACTIVITY_STARTED
started to check if the activity has been started.
Let’s now bind all this stuff together in BaseProcessTest
class:
abstract class BaseProcessTest {
fun startProcess(variables: Map<String, Any> = mapOf()): ProcessAssertions {
logger.debug("Starting process: ${processName()}")
val processTestEnvironment = ProcessTestEnvironment()
runtimeService.addEventListener(processTestEnvironment)
try {
runtimeService.startProcessInstanceByKey(processName(), variables)
} catch(e: Throwable) {
processTestEnvironment.exception = e
} finally {
runtimeService.removeEventListener(processTestEnvironment)
}
return ProcessAssertions(processTestEnvironment)
}
// [...]
}
And we can finally remake our first process unit test using the introduced concepts:
class IsUserActiveProcessTest: BaseProcessTest() {
protected fun prepareEnvAndStartProcess(userExists: Boolean = true, userIsActive: Boolean = true): ProcessAssertions {
// [...]
return startProcess(/* [...] */)
}
@Test
fun testUserInactive() {
logger.debug("testUserInactive()")
with (prepareEnvAndStartProcess(userIsActive = false)) {
assertActivityCompleted("checkUserExists")
assertActivityCompleted("isUserActive")
assertVariable(IsUserActiveServiceTask.VAR_RESULT, false)
}
}
@Test
fun testUserActive() {
logger.debug("testUserActive()")
with (prepareEnvAndStartProcess(userIsActive = true)) {
assertActivityCompleted("checkUserExists")
assertActivityCompleted("isUserActive")
assertVariable(IsUserActiveServiceTask.VAR_RESULT, true)
}
}
// [...]
}
As you’ve probable noticed there’s not yet a test for the path when user doesn’t exists in database and the process ends with the exception. We can now add corresponding method to our test case:
class IsUserActiveProcessTest: BaseProcessTest() {
@Test
fun testUserNotExists() {
logger.debug("testUserNotExists()")
with (prepareEnvAndStartProcess(userExists = false)) {
assertActivityStarted("checkUserExists")
assertActivityNotCompleted("checkUserExists")
assertException(FlowableException::class)
}
}
// [...]
}
We check here if the activity checkUserExists
has been started, but hasn’t been completed, what means the exeption has been thrown during this activity execution.
The problem with the code above is that if we log the exception to the console we can see it this way:
org.flowable.common.engine.api.FlowableException: No matching parent execution for error code ERR_USER_NOT_EXIST found
at org.flowable.engine.impl.bpmn.helper.ErrorPropagation.executeCatch(ErrorPropagation.java:189) ~[flowable-engine-6.4.1.jar:6.4.1]
at org.flowable.engine.impl.bpmn.helper.ErrorPropagation.propagateError(ErrorPropagation.java:86) ~[flowable-engine-6.4.1.jar:6.4.1]
at org.flowable.engine.impl.bpmn.behavior.ErrorEndEventActivityBehavior.execute(ErrorEndEventActivityBehavior.java:34) ~[flowable-engine-6.4.1.jar:6.4.1]
...
In the runtime this process would be executed as a call activity of the parent process, and ERR_USER_NOT_EXIST
exception thrown here would be caught on the parent process level and then properly handled. Yet, we test here the subprocess in isolation and there’s no one who catches this exception. Unfortunately in such scenario Flowable wraps everything with FlowableException
without even giving the original exception as the cause, what prevents from doing any assertions in our tests about the exception thrown.
To solve this problem I’ve discovered useful class that can be used to customize any Flowable behavior which is DefaultActivityBehaviorFactory
. We may extend this class to introduce our customization to exceptions handling:
class TestActivityBehaviorFactory: DefaultActivityBehaviorFactory() {
override fun createErrorEndEventActivityBehavior(endEvent: EndEvent?, errorEventDefinition: ErrorEventDefinition?): ErrorEndEventActivityBehavior {
return object: ErrorEndEventActivityBehavior(errorEventDefinition?.errorCode) {
override fun execute(execution: DelegateExecution?) {
try {
super.execute(execution)
} catch (e: FlowableException) {
errorEventDefinition?.errorCode?.let {errorCode ->
logger.debug("Re-throwing BPMN error with code: {} on uncaught end event exception", errorCode)
throw BpmnError(errorCode)
}
throw e
}
}
}
}
}
In case of the exception we try to extract original errorCode
from the BPMN activity and if it works out, we throw BpmnError
exception with the same code. Otherwise we fallback to the original exception thrown.
To make it working we still need to register our class as a Flowable activity behavior factory during BPMN engine initialization:
@Configuration
class TestConfiguration {
/** Custom activity behavior factory for tests **/
@Bean fun testActivityBehaviorFactory(): TestActivityBehaviorFactory = TestActivityBehaviorFactory()
/** Flowable configuration bean */
@Bean
fun flowableTestSpringProcessEngineConfig(testActivityBehaviorFactory: TestActivityBehaviorFactory) =
EngineConfigurationConfigurer<SpringProcessEngineConfiguration> {
// registers custom activity behavior factory
it.activityBehaviorFactory = testActivityBehaviorFactory()
// [...]
}
}
Now after logging the exception during the test case execution we get this:
org.flowable.engine.delegate.BpmnError:
at com.lifeinide.flowable.test.TestActivityBehaviorFactory$createErrorEndEventActivityBehavior$1.execute(TestActivityBehaviorFactory.kt:26) ~[classes/:na]
at org.flowable.engine.impl.agenda.ContinueProcessOperation.executeActivityBehavior(ContinueProcessOperation.java:264) ~[flowable-engine-6.4.1.jar:6.4.1]
at org.flowable.engine.impl.agenda.ContinueProcessOperation.executeSynchronous(ContinueProcessOperation.java:158) ~[flowable-engine-6.4.1.jar:6.4.1]
...
We can now refine our process assertions helper to check error codes of BpmnError
-s:
class ProcessAssertions(protected val processTestEnvironment: ProcessTestEnvironment) {
/**
* Checks whether process ended with [BpmnError] with given error code.
*/
fun assertBpmnError(code: String) {
assertException(BpmnError::class)
val thrownCode = (processTestEnvironment.exception as BpmnError).errorCode
assertThat(thrownCode)
.withFailMessage("Process ended with error with code: $thrownCode which is different than asserted: $code")
.isEqualTo(code)
}
// [...]
}
And finally assert appropriate error code in our test case:
class IsUserActiveProcessTest: BaseProcessTest() {
@Test
fun testUserNotExists() {
logger.debug("testUserNotExists()")
with (prepareEnvAndStartProcess(userExists = false)) {
assertActivityStarted("checkUserExists")
assertActivityNotCompleted("checkUserExists")
assertBpmnError("ERR_USER_NOT_EXIST")
}
}
// [...]
}
Reviewing test execution logs we can now see how this facility works:
testUserNotExists()
Starting process: IsUserActive
User with id: 22b77d86-a4b0-4dd2-9999-8ba14a204785 is not found
Re-throwing BPMN error with code: ERR_USER_NOT_EXIST on uncaught end event exception
Exception thrown: ERR_USER_NOT_EXIST
Unregistering mocks
At this point we can continue our work with adding second test scenario which tests the process of banning the user. Because it depends on subprocess (call activity) in a first step I’m going to show how does it look like to do call this activity during the process execution:
class BanUserProcessTest: BaseProcessTest() {
protected fun prepareEnvAndStartProcess(userExists: Boolean = true, userIsActive: Boolean = true): ProcessAssertions {
val mockUserService = mock(UserService::class.java)
val mockUser = User()
if (userExists) {
`when`(mockUserService.findUser(mockUser.id)).thenReturn(mockUser)
`when`(mockUserService.isBanned(mockUser)).thenReturn(!userIsActive)
} else {
`when`(mockUserService.findUser(ArgumentMatchers.anyString())).thenReturn(null)
}
doNothing().`when`(mockUserService).ban(mockUser)
// [1] register mocked service task for outer process
Mocks.register("banUserServiceTask", BanUserServiceTask(mockUserService))
// [2] register mocked service task for inner process (call activity)
Mocks.register("isUserActiveServiceTask", IsUserActiveServiceTask(mockUserService))
return startProcess(mapOf("userId" to mockUser.id))
}
@Test
fun testUserNotExists() {
logger.debug("testUserNotExists()")
with (prepareEnvAndStartProcess(userExists = false)) {
assertActivityStarted("isUserActiveCallActivity")
assertActivityNotCompleted("isUserActiveCallActivity")
assertBpmnError("ERR_USER_BAN")
}
}
@Test
fun testUserInactive() {
logger.debug("testUserInactive()")
with (prepareEnvAndStartProcess(userIsActive = false)) {
assertActivityCompleted("isUserActiveCallActivity")
assertActivityNotStarted("banUserTask")
assertBpmnError("ERR_USER_BAN")
}
}
@Test
fun testUserActive() {
logger.debug("testUserActive()")
with (prepareEnvAndStartProcess(userIsActive = true)) {
assertActivityCompleted("isUserActiveCallActivity")
assertActivityCompleted("banUserTask")
}
}
}
Test cases follow the patterns we’ve built previously, i.e.:
testUserNotExists()
the activity isUserActiveCallActivity
should be started but never completed, and the process should end up with ERR_USER_BAN
error.testUserInactive()
the activity isUserActiveCallActivity
should be completed, but process anyway should end up with ERR_USER_BAN
error without even caling banUserTask
activity.testUserActive()
our both activities should be executed and completed.
However, as you can see there’s a kind of copy&paste pattern here. Because we need to call subprocess (call activity) we need both to mock two services tasks (in step [1]
and [2]
- please refer the above code comments) and to duplicate the whole behavior for IsUserActiveProcessTest
. For this simple scenario this might not look very bad, but imagine 15 other processes using IsUserActive
as their call activities or imagine 20 possible process paths in IsUserActive
process instead of 3.
To avoid problems with duplicating mocking code we’ve made simple assumption: instead of mocking every possible flow scenario for call activity to test if outer process works, we would rather mock every possible result of the call activity. A call activity communicates with the outer world usually by returning some variables or throwing an exception. In our case the final result of IsUserActive
call activity execution is stored in result
variable (which is then mapped by outer process to its own isUserActiveResult
variable and used in gateway expressions) or the activity throws ERR_USER_NOT_EXIST
error. Let’s build a simple framework to mock these results for the outer process.
We start from defining our own behavior for different kinds mocked of call activity result:
abstract class MockedCallActivityResult: AbstractBpmnActivityBehavior() {
class NoOpCallActivityResult: MockedCallActivityResult() {
override fun execute(execution: DelegateExecution?) {
logger.debug("Mocking call activity: ${execution!!.currentFlowElement.id} by doing nothing")
super.execute(execution)
}
}
class VariableSetCallActivityResult(vararg val variables: Pair<String, Any>): MockedCallActivityResult() {
override fun execute(execution: DelegateExecution) {
for ((key, value) in variables) {
(execution.currentFlowElement as CallActivity).let {
var mapped = false
for (ioParameter in it.outParameters)
if (ioParameter.source == key) {
logger.debug("Mocking call activity: ${execution.currentFlowElement.id} by mapping result variable: " +
"${ioParameter.source} -> ${ioParameter.target} with value: $value")
execution.setVariable(ioParameter.target, value)
mapped = true
break
}
if (!mapped) {
logger.debug("Skipping mocking call activity: ${execution.currentFlowElement.id} by mapping result variable: " +
"$key, because of not found variable mapping in output")
}
}
}
super.execute(execution)
}
}
class ExceptionCallActivityResult(val ex: Throwable): MockedCallActivityResult() {
constructor(errorCode: String): this(BpmnError(errorCode))
override fun execute(execution: DelegateExecution?) {
try {
logger.debug("Mocking call activity: ${execution!!.currentFlowElement.id} by throwning exception: {}",
if (ex is BpmnError) "BpmnError(${ex.errorCode})" else ex::class.simpleName)
throw ex
} catch (exc: Throwable) {
// taken from ServiceTaskExpressionActivityBehavior
var cause: Throwable? = exc
var error: BpmnError? = null
while (cause != null) {
if (cause is BpmnError) {
error = cause
break
} else if (cause is RuntimeException) {
if (ErrorPropagation.mapException(cause as RuntimeException?, execution as ExecutionEntity, ArrayList())) {
return
}
}
cause = cause.cause
}
if (error != null) {
ErrorPropagation.propagateError(error, execution)
} else {
throw exc
}
}
}
}
}
We start from AbstractBpmnActivityBehavior
which is a base for Flowable activity behaviors. It represents the activity doing nothing but leaving the current state and this exact behavior is used in default NoOpCallActivityResult
implementation. In VariableSetCallActivityResult
we want to simulate leaving some values in call activity process variables, which then are propagated to outer process variables. And finally ExceptionCallActivityResult
simulates throwing an exception by call activity. The code in catch
block asserts appropriate error propagation to the outer process in a similar way the original call activity does.
Now it’s time to extend our TestActivityBehaviorFactory
with our custom activity behavior support:
class TestActivityBehaviorFactory: DefaultActivityBehaviorFactory() {
protected val mockedCallActivityResults: MutableMap<String, MockedCallActivityResult> = ConcurrentHashMap()
inner class MockableCallActivityBehavior(
internal val wrappedBehavior: CallActivityBehavior,
protected val id: String,
processDefinitionKey: String?,
calledElementType: String?,
mapExceptions: MutableList<MapExceptionEntry>?
): CallActivityBehavior(processDefinitionKey, calledElementType, true, mapExceptions) {
override fun execute(execution: DelegateExecution) {
mockedCallActivityResults[id]?.execute(execution) ?: run { wrappedBehavior.execute(execution) }
}
override fun completed(execution: DelegateExecution?) {
if (!isMocked())
wrappedBehavior.completed(execution)
}
override fun completing(execution: DelegateExecution?, subProcessInstance: DelegateExecution?) {
if (!isMocked())
wrappedBehavior.completing(execution, subProcessInstance)
}
fun isMocked(): Boolean {
return mockedCallActivityResults[id] != null
}
}
override fun createCallActivityBehavior(callActivity: CallActivity): CallActivityBehavior {
return MockableCallActivityBehavior(super.createCallActivityBehavior(callActivity), callActivity.id,
callActivity.calledElement, callActivity.calledElementType, callActivity.mapExceptions)
}
/**
* Registers mocked call activity result.
*/
fun registerMockedCallActivityResults(vararg mockedCallActivities: Pair<String, MockedCallActivityResult>) {
for ((callActivityId, mockedCallActivityResult) in mockedCallActivities) {
if (mockedCallActivityResults[callActivityId] != null && mockedCallActivityResults[callActivityId] != mockedCallActivityResult)
throw IllegalStateException("Mock call activity: $callActivityId result is already registered")
mockedCallActivityResults[callActivityId] = mockedCallActivityResult
}
}
/**
* Unregisters all mocked call activity results.
*/
fun unregisterMockedCallActivityResults() {
mockedCallActivityResults.clear()
}
// [...]
}
MockableCallActivityBehavior
is registered now as default CallActivityBehavior
for all Flowable processes and before falling back to the default action of executing the call activity itself, it checks if there’s a custom behavior registered for given flow element and executes it instead the original one. We also have new methods exposed allowing to register and unregister mocked call activity behavior, which we can now use in our BaseProcessTest
:
abstract class BaseProcessTest {
@Autowired protected lateinit var testActivityBehaviorFactory: TestActivityBehaviorFactory
fun startProcess(variables: Map<String, Any> = mapOf(),
vararg mockedCallActivities: Pair<String, MockedCallActivityResult>): ProcessAssertions {
testActivityBehaviorFactory.registerMockedCallActivityResults(*mockedCallActivities)
try {
runtimeService.startProcessInstanceByKey(processName(), variables)
} finally {
testActivityBehaviorFactory.unregisterMockedCallActivityResults()
}
// [...]
}
// [...]
}
BaseProcessTest
now allows to register custom activity behavior instead of original call activities, what is modeled by Pair
of String
representing flowable element ID and MockedCallActivityResult
itself. The initialization of the latter one can be now moved into IsUserActiveProcessTest
, which represents the test of call activity we want to mock. For the simplicity let’s push it into the class as a static method:
class IsUserActiveProcessTest: BaseProcessTest() {
companion object {
@JvmStatic fun mock(userExists: Boolean = true, userIsActive: Boolean = true): MockedCallActivityResult {
return if (!userExists)
MockedCallActivityResult.ExceptionCallActivityResult("ERR_USER_NOT_EXIST")
else
MockedCallActivityResult.VariableSetCallActivityResult("result" to userIsActive)
}
}
// [...]
}
Finally we can use the whole facility to mock call activity results in BanUserProcessTest
:
class BanUserProcessTest: BaseProcessTest() {
protected fun prepareEnvAndStartProcess(userExists: Boolean = true, userIsActive: Boolean = true): ProcessAssertions {
val mockUserService = mock(UserService::class.java)
val mockUser = User()
`when`(mockUserService.findUser(mockUser.id)).thenReturn(mockUser)
doNothing().`when`(mockUserService).ban(mockUser)
Mocks.register(BanUserServiceTask.BEAN_NAME, BanUserServiceTask(mockUserService))
return startProcess(mapOf(BanUserServiceTask.VAR_USER_ID to mockUser.id),
"isUserActiveCallActivity" to IsUserActiveProcessTest.mock(userExists, userIsActive))
}
// [...]
}
Here are the logs from our test cases:
testUserInactive()
Starting process: BanUser
Mocking call activity: isUserActiveCallActivity by mapping result variable: result -> isUserActiveResult with value: false
Re-throwing BPMN error with code: ERR_USER_BAN on uncaught end event exception
Exception thrown: ERR_USER_BAN
Unregistering mocks
testUserActive()
Starting process: BanUser
Mocking call activity: isUserActiveCallActivity by mapping result variable: result -> isUserActiveResult with value: true
Banning user: User(id='3f63db4f-40a3-48e8-b3ce-b9fbe66e74f7', banned=false)
Unregistering mocks
testUserNotExists()
Starting process: BanUser
Mocking call activity: isUserActiveCallActivity by throwning exception: BpmnError(ERR_USER_NOT_EXIST)
Re-throwing BPMN error with code: ERR_USER_BAN on uncaught end event exception
Exception thrown: ERR_USER_BAN
Unregistering mocks
Note, that because I wanted to keep everything simple as possible, you might not see instant value of mocking call activities in the presented example. This is because the original call activity has three possible paths of execution (user doesn’t exists -> error, user is active and user is not active) while mocked version has also three possible results (ERR_USER_NOT_EXIST
exception, result == true
and result == false
). However, if you consider the process with many possible paths of execution but still having only few possible results, you will clearly see the value here.