viadee / vPAV

viadee Process Application Validator
https://www.viadee.de/java/process-application-validator
BSD 3-Clause "New" or "Revised" License
48 stars 14 forks source link

Recreation of vPAV using Spock Framework unit testing #51

Closed StephenOTT closed 5 years ago

StephenOTT commented 6 years ago

Hey!

been watching progress and wanted to get some insights into the work to date:

I have been working on https://github.com/camunda/camunda-bpm-process-test-coverage/issues/32 and I wanted to add the vPav style tests into the coverage testing:

Here is a working example that recreates your Redundant sequence flow test:

class HamcrestTestsSpec extends Specification implements redundantSequenceFlows{

  @Shared BpmnModelInstance model

  def setupSpec(){
    model = prepModelForCoverage('bpmn/conditionalstart/HamcrestTests.bpmn')
  }

  def 'Redundant Sequence Flows'() {
    when: 'A model is being validated'

    then: 'There are no redundant sequence flows'
    assertThat doesNotContainRedundantSequenceFlows(model, ["StartEvent_1ugw7xf":"Task_1qb2k3a"])
  }
}

I decided to test out these reusable tests using Groovy Traits, so each "trait" / validation can be chosen to be added as seen fit by the testers.

see the implements doesNotContainRedundantSequenceFlows

The trait looks like:

package io.digitalstate.camunda.coverage

import static org.camunda.bpm.engine.test.assertions.ProcessEngineTests.*
import org.camunda.bpm.model.bpmn.BpmnModelInstance
import org.camunda.bpm.model.bpmn.instance.SequenceFlow

trait redundantSequenceFlows{
    def doesNotContainRedundantSequenceFlows(BpmnModelInstance model, Map<String,String> ignoredSequenceFlows = [:]){
        Collection<SequenceFlow> sequenceFlows =  model.getModelElementsByType(SequenceFlow.class)
        ArrayList targetSource = sequenceFlows.collect {
            [(it.getSource().getId()) : it.getTarget().getId()]
        }
        targetSource.removeAll(ignoredSequenceFlows)
        assertThat(targetSource).doesNotHaveDuplicates()
    }
}

I also add the ability to allow redundant sequence flows in scenarios where the BPMN may warrant duplication. see the second param of the doesNotContainRedundantSequenceFlows() method.

StephenOTT commented 6 years ago

Or here is another example:

class HamcrestTestsSpec extends Specification{

  @Shared BpmnModelInstance model

  def setupSpec(){
    model = prepModelForCoverage('bpmn/conditionalstart/HamcrestTests.bpmn') withTraits(redundantSequenceFlows)
  }

  def 'Redundant Sequence Flows'() {
    when: 'Given a Model Definition'
    then: 'Ensure there  are no redundant sequence flows'
//      assertThat model.doesNotContainRedundantSequenceFlows(["StartEvent_1ugw7xf":"Task_1qb2k3a"])
    assertThat model.doesNotContainRedundantSequenceFlows()
  }
}

which will throw the following assert exception:

assertThat model.doesNotContainRedundantSequenceFlows()
           |     |
           |     java.lang.AssertionError: 
           |     Found duplicate(s):
           |      <[{"StartEvent_1ugw7xf"="Task_1qb2k3a"}]>
           |     in:
           |      <[{"StartEvent_1ugw7xf"="Task_1qb2k3a"}, {"StartEvent_1ugw7xf"="Task_1qb2k3a"}, {"Task_1qb2k3a"="EndEvent_10ag13t"}]>
           BpmnModelInstanceImpl1_groovyProxy@62d363ab

And if the commented out line is used instead, the assertion will pass due to the ["StartEvent_1ugw7xf":"Task_1qb2k3a"] redundancy being ignored.

key in this example is the usage of: model = prepModelForCoverage('bpmn/conditionalstart/HamcrestTests.bpmn') withTraits(redundantSequenceFlows) which lets us apply the traits to the specific model in question. This has interesting implications for running different combinations of assertions against multiple models.

To support this functionality, the trait was updated to:

trait redundantSequenceFlows {
//    https://docs.camunda.org/javadoc/camunda-bpm-platform/7.8/?org/camunda/bpm/model/bpmn/instance/package-summary.html
    def doesNotContainRedundantSequenceFlows(Map<String,String> ignoredSequenceFlows = [:]){
        Collection<SequenceFlow> sequenceFlows = this.getModelElementsByType(SequenceFlow.class)
        ArrayList targetSource = sequenceFlows.collect {
            [(it.getSource().getId()) : it.getTarget().getId()]
        }
        targetSource.removeAll(ignoredSequenceFlows)
        assertThat(targetSource).doesNotHaveDuplicates()
    }
}

The idea/point of using traits here is to provide flexibility and easy readability + the ability to "technical" analysts to create new traits / re-usable assertions that match their specific use cases.

fkoehne commented 6 years ago

Hey,

thank you for your interest! We need to think about this - the Spock approach may also be interesting for @larsbe to constrain data flows, right?

Best regards, Frank

StephenOTT commented 6 years ago

Hey

so here is an updated example for large simplification using traits for Message Events:

class MessageEventsSpec extends Specification{

  @Shared BpmnModelInstance model

  def setupSpec(){
    String path = 'bpmn/qa-test.bpmn'
    InputStream bpmnFile = this.class.getResource(path.toString()).newInputStream()
    model = Bpmn.readModelFromStream(bpmnFile).withTraits(bpmnEvents)
  }

  @Unroll
  def "Message Events Check: External Implementation"(){
    expect:
      verifyAll {
        that(event['implementation']['topic'], notNullValue())
        that(event['eventId'], notNullValue())
        that(event['eventName'], notNullValue())
      }
   where:
   event << model.getMessageEvents().findAll { it['implementation']['camundaType'] == 'external'}
//   event << model.getMessageEvents('endEvent')

  }

  trait bpmnEvents{
    List<Map<String,Object>> getMessageEvents(String activityType = null, List <String> ignoredMessageEvents = []){
      BpmnModelInstance model = (BpmnModelInstance)this
      Collection<MessageEventDefinition> messageEventDefinitions = model.getModelElementsByType(MessageEventDefinition.class)

      if (activityType != null){
        messageEventDefinitions = messageEventDefinitions.findAll {
          it.getParentElement().getElementType().getTypeName() == activityType
        }
      }
        List<Map<String,Object>> events = messageEventDefinitions.collect {
        [
                ('activityType') : it.getParentElement().getElementType().getTypeName(),
                ('activityId') : it.getParentElement().getAttributeValue('id'),
                ('eventId')  : it.getMessage()?.getId() ?: null,
                ('eventName') : it.getMessage()?.getName() ?: null,
                ('implementation') : [
                        ('delegateExpression') : it.getCamundaDelegateExpression(),
                        ('camundaType') : it.getCamundaType(),
                        ('expression') : it.getCamundaExpression(),
                        ('resultVariable') : it.getCamundaResultVariable(),
                        ('topic') : it.getCamundaTopic(),
                        ('operation') : it.getOperation(),
                        ('extension') : it.getExtensionElements()?.getElementsQuery()?.filterByType(CamundaConnector.class)?.singleResult()?.getElementType()?.getTypeName()
                ]
        ]
      }
      events.removeAll{
        ignoredMessageEvents.any {it['activityId']}
      }
      println JsonOutput.prettyPrint(JsonOutput.toJson(events))
      return events
    }
  }

}

you will get output similar to:

that(event['implementation']['topic'], notNullValue())
|    |    |                 |
false|    |                 null
     |    [delegateExpression:null, camundaType:external, expression:null, resultVariable:null, topic:null, operation:null, extension:null]
     [activityType:intermediateThrowEvent, activityId:message_throw, eventId:Message_1jggs9q, eventName:null, implementation:[delegateExpression:null, camundaType:external, expression:null, resultVariable:null, topic:null, operation:null, extension:null]]

Expected: not null
     but: was null

  Run 2: HamcrestTestsSpec.Message Events Check: External Implementation:31->Specification.verifyAll:223->Message Events Check: External Implementation_closure1:-1->Message Events Check: External Implementation_closure1:34 Condition not satisfied:

that(event['eventName'], notNullValue())
|    |    |
false|    null
     [activityType:intermediateThrowEvent, activityId:message_throw, eventId:Message_1jggs9q, eventName:null, implementation:[delegateExpression:null, camundaType:external, expression:null, resultVariable:null, topic:null, operation:null, extension:null]]

Expected: not null
     but: was null

  Run 3: HamcrestTestsSpec.Message Events Check: External Implementation:31->Specification.verifyAll:223->Message Events Check: External Implementation_closure1:-1->Message Events Check: External Implementation_closure1:34 Condition failed with Exception:

that(event['eventName'], notNullValue())
     |    |              |
     |    null           not null
     [activityType:intermediateThrowEvent, activityId:message_throw, eventId:Message_1jggs9q, eventName:null, implementation:[delegateExpression:null, camundaType:external, expression:null, resultVariable:null, topic:null, operation:null, extension:null]]

Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

and using the definition:

...
  def "Message Events Check: External Implementation"(){
    expect:
      verifyAll {
        that(event['implementation']['topic'], notNullValue())
        that(event['eventId'], notNullValue())
        that(event['eventName'], notNullValue())
      }
   where:
   event << model.getMessageEvents().findAll { it['implementation']['camundaType'] == 'external'}
...

Analysts can quickly create reusable traits for various checks and you can layer your traits to inherit much deeper than with regular diamond issues.

Here is the output of what the getMessageEvents() method will return:

[
    {
        "activityType": "startEvent",
        "activityId": "message_start",
        "eventId": "Message_0d14biw",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "intermediateCatchEvent",
        "activityId": "message_catch",
        "eventId": "Message_1jggs9q",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "intermediateThrowEvent",
        "activityId": "message_throw",
        "eventId": "Message_1jggs9q",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": "external",
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "endEvent",
        "activityId": "message_end",
        "eventId": null,
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "startEvent",
        "activityId": "sub-message-start",
        "eventId": "Message_0gyzbq3",
        "eventName": "5555",
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "endEvent",
        "activityId": "sub-message-end",
        "eventId": null,
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "startEvent",
        "activityId": "sub-event-start",
        "eventId": "Message_1uudc2w",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "startEvent",
        "activityId": "sub-event-start-non",
        "eventId": "Message_0gyzbq3",
        "eventName": "5555",
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "endEvent",
        "activityId": "sub-event-end",
        "eventId": "Message_0d14biw",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "boundaryEvent",
        "activityId": "sub-event-boundary",
        "eventId": "Message_0gyzbq3",
        "eventName": "5555",
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "boundaryEvent",
        "activityId": "sub-event-non-boundary",
        "eventId": "Message_1jggs9q",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "boundaryEvent",
        "activityId": "boundary-throw",
        "eventId": null,
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "intermediateCatchEvent",
        "activityId": "message_catch_post-event",
        "eventId": null,
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    },
    {
        "activityType": "boundaryEvent",
        "activityId": "boundary-non-throw",
        "eventId": "Message_1jggs9q",
        "eventName": null,
        "implementation": {
            "delegateExpression": null,
            "camundaType": null,
            "expression": null,
            "resultVariable": null,
            "topic": null,
            "operation": null,
            "extension": null
        }
    }
]

There is a small bug with "veryifyAll{}" with spock that is preventing the "assert" keywork to be used.

But once fixed you can do things like: assert that(event['implementation']['topic'], notNullValue()), "A Topic is required when using a External implementation in a Message Event", which will output a custom business focused error message that can be reported on.

This pattern is powerful because it drastically simplifies the architecture and the tester's ability to tailor the tests on a per project basis. Where the equiv of this code in vPav is: https://github.com/viadee/vPAV/blob/master/src/main/java/de/viadee/bpm/vPAV/processing/checker/MessageEventChecker.java

StephenOTT commented 6 years ago

Here is a update for Timer evaluation:

trait bpmnTimers{
    Collection<TimerEventDefinition> getTimers(){
        BpmnModelInstance model = (BpmnModelInstance)this
        Collection<TimerEventDefinition> timerEventDefinitions = model.getModelElementsByType(TimerEventDefinition.class)
        return timerEventDefinitions
    }

    List<Date> evaluateTimers() {
        BpmnModelInstance model = (BpmnModelInstance) this
        Collection<TimerEventDefinition> timerEventDefinitions = model.getModelElementsByType(TimerEventDefinition.class)
        List<Date> timers = new ArrayList<Date>()
        timerEventDefinitions.each { timer ->
            Date timerEval = evaluateTimer(timer)
            assert timerEval instanceof Date
            timers << timerEval
        }
        return timers
    }

    Date evaluateTimer(TimerEventDefinition timer){
        Map<String,String> timerInfo = getTimerValue(timer)
        switch (timerInfo) {
            case { it.type == 'date'}:
                DueDateBusinessCalendar dueDateCalendar = new DueDateBusinessCalendar()
                Date dueDate = dueDateCalendar.resolveDuedate(timerInfo.value)
                return dueDate
            case {it.type == 'cycle'}:
                CycleBusinessCalendar cycleBusinessCalendar = new CycleBusinessCalendar()
                Date cycleDueDate = cycleBusinessCalendar.resolveDuedate(timerInfo.value)
                return cycleDueDate
            case {it.type == 'duration'}:
                DurationBusinessCalendar durationCalendar = new DurationBusinessCalendar()
                Date durationDueDate = durationCalendar.resolveDuedate(timerInfo.value)
                return durationDueDate
            default:
                throw new IOException('Invalid Timer mapping found: must be of type: date or cycle or duration')
        }
    }

    Map<String, String> getTimerValue(TimerEventDefinition timer) {
        if (timer.getTimeDate() != null) {
            return [('type'):'date',
                    ('value'): timer.getTimeDate().getRawTextContent()]
        } else if (timer.getTimeCycle() != null) {
            return [('type'):'cycle',
                    ('value'): timer.getTimeCycle().getRawTextContent()]
        } else if (timer.getTimeDuration() != null) {
            return [('type'):'duration',
                    ('value'): timer.getTimeDuration().getRawTextContent()]
        } else {
            throw new IOException('Timer definition missing; Timer definition is required on all timers')
        }
    }
}

You can leverage the flow through something like:

...
 @Shared BpmnModelInstance model

  def setupSpec(){
    String path = 'bpmn/qa-test.bpmn'
    InputStream bpmnFile = this.class.getResource(path.toString()).newInputStream()
    model = Bpmn.readModelFromStream(bpmnFile).withTraits(bpmnEvents, bpmnTimers)
  }

  def 'test timers'(){
    when: 'loaded a process'
    then:
     println  model.evaluateTimers()

  }
...

This will print each of the timers Dates that were evaluated [Wed Jun 27 13:30:00 EDT 2018] or throw a validation exception by the Business Calendar engines or some of the extra logic for pre-config issues

println  model.evaluateTimers()
         |     |
         |     org.camunda.bpm.engine.ProcessEngineException: ENGINE-09026 Exception while parsing cron expression '0 0/5 * *': Unexpected end of expression.
         BpmnModelInstanceImpl1_groovyProxy@64a8c844

Further error messaging can be added for things like what specific activityId had the error.

x-ref for vPav timer evals: https://github.com/viadee/vPAV/blob/master/src/main/java/de/viadee/bpm/vPAV/processing/checker/TimerExpressionChecker.java

StephenOTT commented 6 years ago

Here is updated version of Timer evaluations with ability to provide a custom Time for time simulation

...
  def 'test timers'(){
    when: 'loaded a process'
    then:
      SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy MM dd - HH:mm")
      Date customTime = simpleDateFormat.parse("2011 03 11 - 17:23")
      println  model.evaluateTimers(customTime)
  }
...

and then:

trait bpmnTimers{
    // reference: https://github.com/camunda/camunda-bpm-platform/tree/master/engine/src/main/java/org/camunda/bpm/engine/impl/calendar
    // https://docs.camunda.org/manual/7.9/reference/bpmn20/events/timer-events

    Collection<TimerEventDefinition> getTimers(){
        BpmnModelInstance model = (BpmnModelInstance)this
        Collection<TimerEventDefinition> timerEventDefinitions = model.getModelElementsByType(TimerEventDefinition.class)
        return timerEventDefinitions
    }

    Map<String, Date> evaluateTimers(Date customCurrentTime = null) {
        BpmnModelInstance model = (BpmnModelInstance) this
        Collection<TimerEventDefinition> timerEventDefinitions = model.getModelElementsByType(TimerEventDefinition.class)
        Map<String, Date> timers = [:]
        println customCurrentTime
        if (customCurrentTime){
            setCurrentTime(customCurrentTime)
        }
        println ClockUtil.getCurrentTime()

        timerEventDefinitions.each { timer ->
            Map<String, Date> timerEval = evaluateTimer(timer)
            timers.putAll(timerEval)
        }

        if (customCurrentTime) {
            resetCurrentTime()
        }

        return timers
    }

    private void setCurrentTime(Date customCurrentTime){
        ClockUtil.setCurrentTime(customCurrentTime)
    }
    private void resetCurrentTime(){
        ClockUtil.reset()
    }

    Map<String, Date> evaluateTimer(TimerEventDefinition timer, Date customCurrentTime = null){
        Map<String,String> timerInfo = getTimerValue(timer)
        String activityId = timer.getParentElement().getAttributeValue('id')
        if (activityId == null){
            throw new IOException('Could not get Activity Id of Timer Event Definition')
        }

        if (customCurrentTime){
            setCurrentTime(customCurrentTime)
        }

        switch (timerInfo) {
            case { it.type == 'date'}:
                DueDateBusinessCalendar dueDateCalendar = new DueDateBusinessCalendar()
                Date dueDate = dueDateCalendar.resolveDuedate(timerInfo.value)

                if (customCurrentTime){
                    resetCurrentTime()
                }

                return [(activityId) : dueDate]
            case {it.type == 'cycle'}:
                CycleBusinessCalendar cycleBusinessCalendar = new CycleBusinessCalendar()
                Date cycleDueDate = cycleBusinessCalendar.resolveDuedate(timerInfo.value)

                if (customCurrentTime){
                    resetCurrentTime()
                }

                return [(activityId) : cycleDueDate]
            case {it.type == 'duration'}:
                DurationBusinessCalendar durationCalendar = new DurationBusinessCalendar()
                Date durationDueDate = durationCalendar.resolveDuedate(timerInfo.value)

                if (customCurrentTime){
                    resetCurrentTime()
                }

                return [(activityId) : durationDueDate]
            default:
                throw new IOException('Invalid Timer mapping found: must be of type: date or cycle or duration')
        }
    }

    Map<String, String> getTimerValue(TimerEventDefinition timer) {
        if (timer.getTimeDate() != null) {
            return [('type'):'date',
                    ('value'): timer.getTimeDate().getRawTextContent()]
        } else if (timer.getTimeCycle() != null) {
            return [('type'):'cycle',
                    ('value'): timer.getTimeCycle().getRawTextContent()]
        } else if (timer.getTimeDuration() != null) {
            return [('type'):'duration',
                    ('value'): timer.getTimeDuration().getRawTextContent()]
        } else {
            throw new IOException('Timer definition missing; Timer definition is required on all timers')
        }
    }
}
StephenOTT commented 6 years ago

Here is a cleaner example of datatable usage to provide better expectations of testing:

...
 class TimerTestSpec extends Specification implements bpmnTimers{

  @Shared BpmnModelInstance model
  @Shared SimpleDateFormat dateF = new SimpleDateFormat("yyyy MM dd - HH:mm")

  def setupSpec(){
    String path = 'bpmn/qa-test.bpmn'
    InputStream bpmnFile = this.class.getResource(path.toString()).newInputStream()
    model = Bpmn.readModelFromStream(bpmnFile).withTraits(bpmnTimers)
  }

  def 'Start Event Cycle Cron Test'(Date customStartTime, String expectedResultTime){
    when: 'Given a Timer Start Event that we eval with a custom start date of #customStartTime'
      String activityId = 'StartEvent_0ii048j'
      TimerEventDefinition timerEvent = model.getTimerById(activityId)
      Map<String,Date> result = evaluateTimer(timerEvent, customStartTime)

    then: 'The timer should have a due date set to #execptedResultTime'
      assert result.get(activityId).toString() == expectedResultTime

    where:
    customStartTime                   || expectedResultTime
    dateF.parse('2011 01 01 - 00:00') || 'Sat Jan 01 01:00:00 EST 2011'
    dateF.parse('2011 01 01 - 04:49') || 'Sat Jan 01 05:00:00 EST 2011'
    dateF.parse('2011 01 01 - 05:01') || 'Sat Jan 01 10:00:00 EST 2011'
    dateF.parse('2011 01 01 - 09:59') || 'Sat Jan 01 10:00:00 EST 2011'
    dateF.parse('2011 01 01 - 14:30') || 'Sat Jan 01 15:00:00 EST 2011'
    dateF.parse('2011 01 01 - 19:30') || 'Sat Jan 01 20:00:00 EST 2011'
    dateF.parse('2011 01 01 - 21:00') || 'Tue Feb 01 01:00:00 EST 2011'
  }

}

where traits are:


trait bpmnTimers{
    // reference: https://github.com/camunda/camunda-bpm-platform/tree/master/engine/src/main/java/org/camunda/bpm/engine/impl/calendar
    // https://docs.camunda.org/manual/7.9/reference/bpmn20/events/timer-events

    Collection<TimerEventDefinition> getTimers(){
        BpmnModelInstance model = (BpmnModelInstance)this
        Collection<TimerEventDefinition> timerEventDefinitions = model.getModelElementsByType(TimerEventDefinition.class)
        return timerEventDefinitions
    }
    TimerEventDefinition getTimerById(String activityId){
        BpmnModelInstance model = (BpmnModelInstance)this
        TimerEventDefinition timerEventDefinition = model.getModelElementsByType(TimerEventDefinition.class).find {
                                                                            it.getParentElement().getAttributeValue('id') == activityId
                                                                        }
        return timerEventDefinition
    }

    Map<String, Date> evaluateTimers(Date customCurrentTime = null) {
        BpmnModelInstance model = (BpmnModelInstance) this
        Collection<TimerEventDefinition> timerEventDefinitions = model.getModelElementsByType(TimerEventDefinition.class)
        Map<String, Date> timers = [:]
        println customCurrentTime
        if (customCurrentTime){
            setCurrentTime(customCurrentTime)
        }
        println ClockUtil.getCurrentTime()

        timerEventDefinitions.each { timer ->
            Map<String, Date> timerEval = evaluateTimer(timer)
            timers.putAll(timerEval)
        }

        if (customCurrentTime) {
            resetCurrentTime()
        }

        return timers
    }

    private void setCurrentTime(Date customCurrentTime){
        ClockUtil.setCurrentTime(customCurrentTime)
    }
    private void resetCurrentTime(){
        ClockUtil.reset()
    }

    Map<String, Date> evaluateTimer(TimerEventDefinition timer, Date customCurrentTime = null){
        Map<String,String> timerInfo = getTimerValue(timer)
        String activityId = timer.getParentElement().getAttributeValue('id')
        if (activityId == null){
            throw new IOException('Could not get Activity Id of Timer Event Definition')
        }

        if (customCurrentTime){
            setCurrentTime(customCurrentTime)
        }

        switch (timerInfo) {
            case { it.type == 'date'}:
                DueDateBusinessCalendar dueDateCalendar = new DueDateBusinessCalendar()
                Date dueDate = dueDateCalendar.resolveDuedate(timerInfo.value)

                if (customCurrentTime){
                    resetCurrentTime()
                }

                return [(activityId) : dueDate]
            case {it.type == 'cycle'}:
                CycleBusinessCalendar cycleBusinessCalendar = new CycleBusinessCalendar()
                Date cycleDueDate = cycleBusinessCalendar.resolveDuedate(timerInfo.value)

                if (customCurrentTime){
                    resetCurrentTime()
                }

                return [(activityId) : cycleDueDate]
            case {it.type == 'duration'}:
                DurationBusinessCalendar durationCalendar = new DurationBusinessCalendar()
                Date durationDueDate = durationCalendar.resolveDuedate(timerInfo.value)

                if (customCurrentTime){
                    resetCurrentTime()
                }

                return [(activityId) : durationDueDate]
            default:
                throw new IOException('Invalid Timer mapping found: must be of type: date or cycle or duration')
        }
    }

    Map<String, String> getTimerValue(TimerEventDefinition timer) {
        if (timer.getTimeDate() != null) {
            return [('type'):'date',
                    ('value'): timer.getTimeDate().getRawTextContent()]
        } else if (timer.getTimeCycle() != null) {
            return [('type'):'cycle',
                    ('value'): timer.getTimeCycle().getRawTextContent()]
        } else if (timer.getTimeDuration() != null) {
            return [('type'):'duration',
                    ('value'): timer.getTimeDuration().getRawTextContent()]
        } else {
            throw new IOException('Timer definition missing; Timer definition is required on all timers')
        }
    }
}

Further explanation can be found here:

https://forum.camunda.org/t/timer-evaluation-unit-testing-timer-configurations-outside-of-execution/7875/3?u=stephenott

So imagine we have a Timer Start Event with a Cycle Definition of: 0 0 1,5,10,15,20 1 ? . This cron basically says: On the First of Every Month, at 01:00, 05:00, 10:00, 15:00, and 20:00, the cron should execute.

So in the where statement we test this using a “customStartTime” and a “expectedResultTime” data table. I have used two different styles of data comparison for demonstration purposes for ideas on how data could be entered (as valid dates, as strings, short dates and times, etc).

So the first input start time is 2011/01/91 at 00:00 and so based on the cron the first result should be a 1am Timer. Yada yada yada, and we arrive at the end of the data table were we have tested all of the previous scenarios, and now we are testing the change over to the next month with a start date of 2011/01/01 at 21:00, and thus past the last job which would have been at 20:00, and so the expected result time is Feb 1 at 01:00.

All assets successfully and we did not need to boot up the Camunda engine to test it out

When comparing as Strings the errors look something like:

result.get(activityId).toString() == expectedResultTime
|      |   |           |          |  |
|      |   |           |          |  Sat Jan 01 01:10:00 EST 2011
|      |   |           |          false
|      |   |           |          1 difference (96% similarity)
|      |   |           |          Sat Jan 01 01:(0)0:00 EST 2011
|      |   |           |          Sat Jan 01 01:(1)0:00 EST 2011
|      |   |           Sat Jan 01 01:00:00 EST 2011
|      |   StartEvent_0ii048j
|      Sat Jan 01 01:00:00 EST 2011
[StartEvent_0ii048j:Sat Jan 01 01:00:00 EST 2011]