Всем привет! Меня зовут Елена Пилюгина, я ведущий разработчик отдела автоматизации бизнес процессов складской логистики в Magnit Tech.

В этой статье я расскажу про свой опыт покрытия тестами процессов в приложении на Spring Boot с Camunda 7. Покажу, как можно создать конструктор для тестирования процессов, варианты тестирования процессов в динамике и статике, поделюсь конкретными примерами кода, покажу преимущества BDD тестирования. Конечно, в рамках одной статьи невозможно охватить все технические аспекты, но я постараюсь показать выбранный мной подход к тестированию. Также буду рада, если заодно получится показать, что создание тестов может быть увлекательным и творческим процессом.

Задача

Изначально у меня в руках оказался Spring Boot проект с Camunda 7 и была поставлена задача покрыть тестами это приложение. 

Краткая справка о Camunda

Camunda 7 – это open-source платформа для автоматизации бизнес-процессов. Она реализует стандарт BPMN 2.0 и позволяет проектировать, исполнять и контролировать workflow напрямую через BPMN схемы.  Таким образом, Camunda 7 является оркестратором бизнес-процессов, который управляет выполнением сложных сценариев, координируя взаимодействие между системами, сервисами и людьми.

Центральным компонентом данной платформы является Camunda Engine – движок выполнения BPMN процессов.  Являясь по сути java библиотекой, он добавляется в Spring Boot приложение в качестве зависимости.

Camunda Engine выполняет роль дирижера, или центрального координатора при оркестрации процессов: загружает BPMN-схему, контролирует состояние каждого экземпляра процесса, передает задачи между людьми и сервисами. Движок взаимодействует с БД, где он хранит состояния.

Помимо самого движка, есть другие компоненты. При создании тестов я активно использовала Camunda Modeler. Это визуальный редактор BPMN диаграмм, который используется для проектирования и отладки процессов.  

Сами процессы в приложении представлены в виде файлов .bpmn и содержат в себе xml конфигурацию. Если же смотреть эти файлы через Camunda Modeler, то процессы предстают в виде удобных для восприятия схем.

Очевидно, что приложение с Camunda предполагает тестирование процессов. Оказалось, что Camunda предоставляет очень широкие возможности для тестирования. Проще говоря, тестировать там можно все, но надо суметь вовремя остановиться. 

Следующим шагом надо было решить, как именно организовать эти тесты, чтобы они были максимально полезны, удобны, понятны, лаконичны, и чтобы добавление тестов на новые процессы не превращалось в трудоемкую задачу. 

Поиск решения

Раздумывая над этой задачей, я поняла, что отличным решением будет соединить тестирование процессов с Cucumber. Сочетание процессов, представленных на схемах, и сценариев Cucumber показалось мне идеальным.

Cucumber – это инструмент для BDD (Behavior-Driven Development) тестирования, который позволяет описывать тесты на естественном языке и автоматизировать их выполнение. 

В результате тестируются сценарии, которые хорошо читаются не только разработчиками, но и другими коллегами. Таким образом можно создать общее пространство для аналитиков, разработчиков и тестировщиков. 

Если проанализировать схемы процессов, то становится понятно, что они состоят из отдельных элементов, которые связаны друг с другом в различные цепочки. Причем количество уникальных элементов ограничено, и каждый из них может неоднократно встречаться в процессе, отличаясь только характеристиками. Более того, любой процесс состоит из данных уникальных элементов, и снова отличие будет только в их характеристиках.

Я выделила набор уникальных элементов, которые задействованы в наших процессах. После этого определила из них те, которые необходимо отдельно обрабатывать во время тестирования. Под каждый такой элемент (или связку элементов) предстояло написать unit тест, учитывая, что в каждом случае мы можем иметь разные характеристики элемента. И в итоге у нас появляется возможность связывать эти элементы в необходимые нам цепочки. Так мы получим возможность создавать тесты на условно любые процессы, просто добавляя новый сценарий без необходимости внесения изменений в кодовую базу.

Другими словами, было решено создать конструктор, который состоит из “кубиков” (шагов тестирования), из которых, в свою очередь, можно строить любые сценарии для тестирования процессов.

Далее оставалось, как говорится, дело техники.  

Реализация

Сами процессы можно тестировать различными способами. Изначально я выбрала динамическое тестирование процесса с поднятием Spring контекста и движка Camunda, чтобы во время тестов мы могли реально пройти по всему процессу и проверить его в действии. 

Причем у нас есть возможность пройти «от и до», протестировав Happy Path, и есть возможность тестировать отдельные участки процесса. Это позволяет нам оптимизировать тесты, выбирая именно те сценарии, которые нам нужны.

В дальнейшем были добавлены тесты, которые статически анализируют файл процесса. Это потребовалось для того, чтобы мы могли в тестах зафиксировать определенные компоненты процесса и их характеристики, и у нас была уверенность, что данные компоненты не подверглись изменениям или не были случайно удалены при каких-либо изменениях процесса. 

Далее я покажу, как это можно сделать. 

Практическая часть:

При тестировании я использовала следующие библиотеки:

  1. org.springframework.boot:spring-boot-starter-test – базовый набор для тестирования Spring Boot (JUnit 5, Mockito, AssertJ) 

  2. org.camunda.bpm:camunda-bpm-assert – специализированные ассерты для проверки состояния процессов Camunda

  3. org.camunda.bpm.springboot:camunda-bpm-spring-boot-starter-test – интеграция Camunda с Spring Boot Test (включает тестовый движок)

  4. org.camunda.community.process_test_coverage:camunda-process-test-coverage-starter-platform-7 – визуализация покрытия BPMN-процессов тестами

  5. io.cucumber:cucumber-java – поддержка шагов на Java 

  6. io.cucumber: cucumber-junit-platform-engine - интеграция Cucumber с JUnit 5 Platform

  7. io.cucumber:cucumber-spring – интеграция Cucumber с Spring Context

  8. org.junit.platform:junit-platform-suite - организация тестовых наборов в JUnit 5

  9. com.h2database:h2 – in-memory база данных для тестового режима Camunda

Для удобства управления версиями также использовались BOM-ы для Camunda и Cucumber.

Рекомендуемые плагины для IntelliJ IDEA для удобства работы с Cucumber:

  1. Cucumber for Java – подсветка синтаксиса в .feature-файлах

  2. Gherkin – навигация между шагами и определениями

На данном этапе я решила не идти в интеграционное тестирование, но при необходимости можно расширить эти тесты до интеграционных, убрав моки и заменив их контейнерами с внешними сервисами.

Конфигурация:

Чтобы иметь возможность запускать Spring Boot тесты с Cucumber, необходимо добавить два класса. 

Один отвечает за запуск Cucumber (он так и останется пустым):

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
//в value прописываем путь к папке, где лежат step definitions
@ConfigurationParameter(key = "cucumber.glue", value = "ru.example.appname.step")
public class CucumberIntegrationTest {}

Второй – это класс, где будут находится описания шагов, т.е. непосредственно сами тесты:

@ActiveProfiles("test")
@CucumberContextConfiguration
@SpringBootTest
public class StepDefinitionsTest {}

В тестовом профиле в .yml файле необходимо подменить реальную базу данных Camunda на тестовую, в моем случае это in memory:

spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_ON_EXIT=FALSE
    username: admin
    password: p
    driver-class-name: org.h2.Driver

Весь код наших тестов будет находиться в классе StepDefinitionsTest, в то время как сценарии будут лежать в папке features. 

базовая структура тестов
базовая структура тестов

Каждый шаг сценария в файле .feature связывается с конкретным методом в классе StepDefinitionsTest. Эта связь обеспечивается за счет аннотации над методом, в которой прописывается шаблон описания шага. Когда мы добавляем в сценарий шаг, под капотом идет поиск подходящего шаблона среди аннотаций методов. Таким образом происходит привязка к конкретному методу, который будет стоять за данным шагом.

Давайте посмотрим это на примере шага запуска процесса. Так выглядит этот шаг в сценарии файла .feature:

этот же шаг кодом, а не скриншотом
Given Пользователь запускает процесс "Selectout" из файла "selectout.bpmn" с параметрами:
  | varName   | varValue      | varType   |
  | userId    | 3             | string    |

Белым подсвечивается шаблонная часть шага, синим – переменные (подсветка обеспечивается плагином IDEA). Т.е. подсвеченный белым текст фиксирован, а значения переменных мы можем варьировать. В данном шаге используются следующие переменные: 

  1. ID процесса (в данном случае "Selectout"). ID процесса, как и прочие характеристики процесса, можно найти либо непосредственно в .bpmn файле процесса, либо с помощью средств визуализации процесса (Camunda Modeler / Excamad / Cockpit).

  2. относительный путь к файлу, в котором лежит этот процесс.

  3. переменные, с которыми будет запущен процесс. Переменные задаются через таблицу, в которой первой строкой идут названия колонок (их нельзя менять, т.к. они используются в парсинге таблицы), а дальше может быть необходимое кол-во строк в соответствии с необходимым количеством переменных процесса.

Теперь посмотрим на метод в классе StepDefinitionsTest, который стоит за этим шагом.

Сразу скажу, что для того, чтобы у меня была возможность выбирать внутри сценария, какой именно процесс я планирую запустить, мне потребовалось отказаться от использования аннотации @Deploy, предоставляемой Camunda, с помощью которой обычно идет разворачивание процессов в тестах, и перевести запуск процесса в ручной режим: 

@Given("Пользователь запускает процесс {string} из файла {string} с параметрами:")
  public void verifyStartProcess(String processKey, String bpmnResource, DataTable parameters) {
    // конвертируем таблицу Cucumber в Map<String, Object> для Camunda
    var inputVariables = convertDataTableToVariables(parameters);

    // деплой указанного процесса
    deployProcess(bpmnResource);
    // запуск процесса по ключу с переданными параметрами
    processInstance = runtimeService.startProcessInstanceByKey(processKey, inputVariables);
    // проверка корректности старта процесса
    verifyProcessStarted(processKey, inputVariables);
  }

  private void deployProcess(String bpmnResource) {
    repositoryService.createDeployment().addClasspathResource("processes/" + bpmnResource).deploy();
  }

  private void verifyProcessStarted(String processKey, Map<String, Object> expectedVariables) {
    assertThat(processInstance)
        .as("Экземпляр процесса %s должен быть создан", processKey)
        .isNotNull()
        .as("Экземпляр процесса %s должен быть запущен", processKey)
        .isStarted()
        .as("Экземпляр процесса %s должен быть активен", processKey)
        .isNotEnded()
        .as("Экземпляр процесса %s должен иметь ключ процесса %s", processKey, processKey)
        .hasProcessDefinitionKey(processKey);

    assertThat(processInstance)
        .variables()
        .as(
            "Экземпляр процесса %s должен иметь все входящие параметры %s",
            processKey, expectedVariables)
        .containsAllEntriesOf(expectedVariables)
        .as("Экземпляр процесса %s не должен иметь переменную %s", processKey, ERROR_VARIABLE)
        .doesNotContainKey(ERROR_VARIABLE);
  }

В шаге запуска процесса идет инициализация приватного поля

private ProcessInstance processInstance;

текущим процессом. Это позволяет нам взаимодействовать с нашим процессом в любом шаге Cucumber: проверять состояния, получать переменные, завершать задачи. 

При проверках в этом шаге используем ассерты, предоставленные со стороны Camunda, которые специализированы под тестирование самих процессов.

Шаг запуска сценария с конкретного элемента аналогичен, но в него добавляется переменная, указывающая, с какого элемента мы должны запустить процесс: 

этот же шаг кодом, а не скриншотом
Given Пользователь запускает процесс "Selectout" из файла "selectout.bpmn" с задачи "InputQuant" с параметрами:
  | varName   | varValue        | varType |
  | userId    | 3               | string  |

А в соответствующем методе при старте процесса мы указываем, с какой задачи необходимо начать: 

processInstance =
        runtimeService
            .createProcessInstanceByKey(processKey)
            .startBeforeActivity(taskId)
            .setVariables(inputVariables)
            .execute();

Таким образом мы получили возможность запускать любой процесс как со старта, так и с конкретного элемента.

Теперь давайте посмотрим на процесс, который мы будем использовать в качестве примера:

В данном процессе сотрудник склада сканирует штрихкод слота, отбирает товар и вводит количество отобранного товара.

Если абстрагироваться от бизнес-логики самого процесса и сосредоточиться на его структуре, то мы видим, что он состоит из следующих элементов: 

  • Начальное событие (Start Event)

  • Конечное событие (End Event)

  • Пользовательская задача (User task)

  • Сервисная задача (Service task) 

  • Исключающий шлюз (Exclusive Gateway)

  • Поток последовательности (Sequence Flow)

Для тестирования процессов с данным набором элементов мной реализованы следующие шаги:

  • шаг запуска процесса с самого начала (Start Event)

  • шаг запуска процесса с конкретного элемента процесса

  • шаг установки заглушки для сервисной задачи

  • шаг запуска пользовательской задачи

  • шаг проверки, на каком элементе находится процесс

  • шаг завершения процесса

  • шаг проверки порядка вызовов сервисных задач на пройденном участке процесса

Приведу пример сценария тестирования отдельного участка процесса. 

Сначала посмотрим на сценарий:

этот же сценарий кодом, а не скриншотом
  Scenario: Возврат на предыдущий шаг с элемента ввода количества
    Given Пользователь запускает процесс "Selectout" из файла "selectout.bpmn" с задачи "InputQuant" с параметрами:
      | varName   | varValue        | varType |
      | userId    | 3               | string  |
    And Процесс ожидает выполнения на элементе с идентификатором "InputQuant"
    When Пользователь нажимает кнопку назад на задаче "InputQuant"
      | varName   | varValue        | varType |
      | isBack    | true            | boolean |
    Then Процесс ожидает выполнения на элементе с идентификатором "ScanSlot"

На визуализации теста от Camunda можно посмотреть, какой именно участок процесса тестируется в данном сценарии: 

Красными линиями на схеме указаны границы транзакционных блоков —  ключевые участки, где процесс приостанавливается, ожидая внешнего воздействия. 

В Camunda транзакционный блок — это последовательность шагов процесса между двумя точками остановки:

  1. Начало блока: элемент, который ожидает внешнего события (например, пользовательская задача (User Task), ожидание сообщения (Receive Task), внешний сервис (External Task) и др.)

  2. Конец блока: Следующая точка остановки или завершение процесса.

Это как раз те точки, где мы можем проконтролировать, что процесс действительно идет по ожидаемому нами маршруту. Для таких контрольных точек создан шаг:

Он имеет очень простую реализацию в коде:

@Then("Процесс ожидает выполнения на элементе с идентификатором {string}")
  public void verifyProcessWaitingNextStep(String stepName) {
    assertThat(processInstance).isWaitingAt(stepName);
  }

Здесь мы также используем ассерты Camunda. 

Как следует из визуализации, в данном процессе есть три элемента, которые являются началом транзакционного блока. Мы можем использовать этот шаг каждый раз, когда ожидаем наш процесс на любом из этих элементов. Для этого нам необходимо только корректно прописать переменную.

Надо учитывать, что при тестировании участка процесса не предполагается, что процесс обязательно должен завершиться конечным событием. 

Чтобы разные сценарии не аффектили друг друга во время общего прогона тестов, перед каждым из них необходимо делать сброс состояний: деплоя, моков, полей тестового класса. Для этого используется метод с аннотацией @Before, которая приходит к нам от Cucumber. Корректно реализованный метод @Before, очищающий все необходимые состояния, гарантирует нам изолированность сценариев теста.

Для запуска пользовательской задачи реализован шаг: 

этот же шаг кодом, а не скриншотом

При создании конструктора тестов я, конечно же, столкнулась с проблемой, когда универсализация каждого шага под разные возможные процессы грозила оставить сценарий вообще без какой-либо бизнес-логики. Поэтому для пользовательской задачи мне было важно оставить бизнес-составляющую описания, чтобы была возможность ориентироваться, чем вообще в данном процессе занят пользователь. Для этого я решила использовать дополнительную переменную, которая дает нам такую возможность.

Таким образом я добилась возможности достаточно гибко описывать действие пользователя в сценарии:

@ParameterType(".*")
  public String dynamicAction(String actionText) {
    return actionText;
  }

  @When("Пользователь {dynamicAction} {string} с переменными:")
  @When("Пользователь {dynamicAction} {string}")
  public void verifyUserTask(String actionDescription, String taskName, DataTable dataTable) {
    var inputVariables = convertDataTableToVariables(dataTable);
    var task =
        taskService
            .createTaskQuery()
            .processInstanceId(processInstance.getId())
            .taskDefinitionKey(taskName)
            .singleResult();

    assertThat(task).as("Задача '%s' должна существовать в процессе", taskName).isNotNull();

    taskService.complete(task.getId(), inputVariables);
  }

Теперь посмотрим на сценарий HappyPath. Для статьи я выбрала самый короткий маршрут:

этот же сценарий кодом, а не скриншотом
Feature: Selectout Process Testing
  Тестирование бизнес-процесса Selectout в Camunda

  Scenario: Happy Path - ТП не в наборе "Расходные материалы"
    Given Пользователь запускает процесс "Selectout" из файла "selectout.bpmn" с параметрами:
      | varName   | varValue      | varType   |
      | userId    | 3             | string    |
    And Процесс ожидает выполнения на элементе с идентификатором "ScanSlot"
    And Мокается сервисная задача "PrcScanSlot" с параметрами:
      |direction  | varName       | varValue  | varType |
      |out        | p_exists_ars  | 0         | string  |
    When Пользователь сканирует ШК слота "ScanSlot" с переменными:
      | varName   | varValue      | varType   |
      | isBack    | false         | boolean   |
      | barcode   | S10140393     | string    |
    And Процесс ожидает выполнения на элементе с идентификатором "InputQuant"
    And Мокается сервисная задача "PrcInputQuant" с параметрами:
      |direction  | varName       | varValue  | varType |
    When Пользователь вводит количество "InputQuant" с переменными:
      | varName   | varValue      | varType   |
      | isBack    | false         | boolean   |
      | unit      | кор           | string    |
      | quantity  | 1             | string    |
    And Процесс ожидает выполнения на элементе с идентификатором "ScanSlot"
    When Пользователь завершает процесс "ScanSlot" с переменными:
      | varName   | varValue      | varType   |
      | isBack    | true          | boolean   |
    Then Процесс успешно завершается и все переменные процесса очищены
    Then Сервисные задачи были вызваны строго в таком порядке:
      | taskName      |
      | PrcScanSlot   |
      | PrcInputQuant |

На визуализации показан маршрут, который тестируется в данном сценарии:

Как видим, в этом сценарии переиспользуются уже рассмотренные нами ранее шаги. Но так как на этот раз мы проходим через сервисные задачи, а также проверяем корректное завершение процесса, добавляются шаги сценария, обрабатывающие данные случаи. 

Рассмотрим их подробнее.

Сервисные задачи: 

этот же шаг кодом, а не скриншотом
And Мокается сервисная задача "PrcScanSlot" с параметрами:
  |direction  | varName       | varValue  | varType |
  |out        | p_exists_ars  | 0         | string  |

Поскольку сервисные задачи я решила мокать, то появилась необходимость задавать вручную ожидаемый результат (переменные, которые сервисные задачи в результате своей работы кладут в контекст процесса), причем важно было иметь такую возможность именно при написании сценария, без необходимости менять код. 

Так как сервисные задачи запускаются автоматически внутри транзакционного блока, необходимо задать их поведение до того, как сам этот блок будет запущен. Рабочим вариантом также является просто замокать все участвующие в сценарии сервисные задачи в самом начале этого сценария, даже до запуска процесса. Все заложенные в сценарии результаты сервисных задач я помещаю в поле тестового класса

private final Map<String, Queue<Map<String, Object>>> taskMocks = new HashMap<>();

где ключом является ID сервисной задачи, а в значении лежит очередь заданных выходных параметров. Когда процесс во время прохождения запускает сервисную задачу, поведение мока настроено таким образом, чтобы из данной мапы мы достали заявленный результат для соответствующей задачи.

Если одна и та же сервисная задача должна быть вызвана несколько раз – мы и с этим справимся, работая с этой очередью. 

Также я добавила в тестовый класс поле 

private final List<String> callMockHistory = new ArrayList<>();

в котором записываю всю историю вызовов мока во время прохождения сценария. Таким образом я могу верифицировать последний шаг приведенного выше сценария, проверяющий, что были вызваны конкретные сервисные задачи в конкретном порядке, и не было никаких не ожидаемых вызовов (на самом деле, этот шаг не ограничен только заключительной позицией, он может быть установлен в любой момент сценария и проверит все сервисные задачи, вызванные до него):

этот же шаг кодом, а не скриншотом
Then Сервисные задачи были вызваны строго в таком порядке:
  | taskName      |
  | PrcScanSlot   |
  | PrcInputQuant |

Конечно, библиотека mockito позволяет нам реализовать данные проверки ее средствами, но мне хотелось достичь больше контроля и гибкости в записи истории моков, оставить возможности для будущих расширений. 

В целом, так как есть большая вариативность реализации сервисных задач в различных приложениях, при работе с ними в тестах надо внимательно посмотреть на логику, которая заложена в проекте, и после этого продумать и выстроить логику мокирования, если это необходимо. Также важно соблюсти баланс: с одной стороны, тесты должны реализовать важные для нас проверки, с другой стороны, не надо их перегружать, затрудняя работу по созданию, модификации и чтению тестов. Так, в начальной версии я предполагала, что мы можем делать проверки и на входные, и на выходные параметры. Но после отказалась от этой идеи. Тем не менее, всегда есть возможность к ней при необходимости вернуться. 

Завершение процесса:

Шаг завершения процесса выглядит так:

И под капотом содержит проверки на корректное завершение процесса:

@Then("Процесс успешно завершается и все переменные процесса очищены")
  public void verifyProcessCompleted() {
    verifyProcessEnded();
    verifyVariablesCleared();
    verifyHistoryRecordExists();
  }

  private void verifyProcessEnded() {
    assertThat(processInstance).isEnded();
    assertThat(
            runtimeService
                .createProcessInstanceQuery()
                .processInstanceId(processInstance.getId())
                .singleResult())
        .isNull();
  }

  private void verifyVariablesCleared() {
    org.assertj.core.api.Assertions.assertThat(runtimeService.createVariableInstanceQuery().count())
        .isZero();
  }

  private void verifyHistoryRecordExists() {
    var historicProcessInstance =
        historyService
            .createHistoricProcessInstanceQuery()
            .processInstanceId(processInstance.getId())
            .singleResult();

    org.assertj.core.api.Assertions.assertThat(historicProcessInstance)
        .isNotNull()
        .extracting(HistoricProcessInstance::getEndTime)
        .isNotNull();
  }

В результате в контексте тестирования процессов с поднятием движка Camunda мы получили возможность тестировать любые процессы, состоящие из определенного нами списка элементов. Эти элементы могут располагаться в различном порядке и обладать различными характеристиками. Порядок и характеристики прописываются в сценарии. При этом у нас нет необходимости писать новый код, т.к. мы переиспользуем уже созданный. 

Если мы вводим новый элемент в процессы (например, внешнюю таску или другой тип шлюза) – мы просто добавляем необходимые шаги для его обработки, таким образом расширяя возможности нашего конструктора.

Описание статического тестирования процессов

Также был запрос на то, чтобы у нас была возможность жестко зафиксировать определенные элементы либо характеристики в процессе и не допустить их случайного изменения, либо удаления. 

Для этого мной был предложен статический анализ файла процесса. 

В нашем случае мы решили проверять: 

  • весь список пользовательских задач процесса

  • условия переходов

  • слушателей процесса

На самом деле, проверять можно намного больше, просто меня остановили на этих трех пунктах, пока я сильно не увлеклась. 

Для статического анализа нет необходимости запускать процесс. В этом случае в первом шаге сценария мы просто обозначаем, какой именно процесс тестируем: 

@Given("Загружен BPMN-файл {string}")
  public void loadBpmnFile(String filename) {
    model = Bpmn.readModelFromFile(new File("src/main/resources/processes/" + filename));
  }

Все следующие шаги данных сценариев работают с инициализированной в этом шаге моделью:

private BpmnModelInstance model;

Верифицируем список пользовательских задач процесса:

Было решено зафиксировать список пользовательских задач процесса с их исполнителями. В вашем случае набор характеристик может быть иным, для этого есть все технические возможности.

Шаг сценария выглядит так:

этот же шаг кодом, а не скриншотом
    Then Процесс содержит следующие пользовательские задачи с исполнителями:
      | taskId        | assignee   |
      | ScanSlot      | ${userId}  |
      | EnterDivision | ${userId}  |
      | InputQuant    | ${userId}  |

Если мы добавили в сценарии данный шаг, то не сможем столкнуться с ситуацией, когда какая-то пользовательская задача вдруг выпала из процесса или сменила своего исполнителя, а мы это не отследили. Так как я ставила жесткие проверки, то лишняя, не ожидаемая нами задача тоже не сможет прийти в этот процесс незамеченной.

В статическом анализе используются обычные привычные ассерты.  Ниже приведен метод, соответствующий данному шагу:

@Then("Процесс содержит следующие пользовательские задачи с исполнителями:")
  public void verifyAllUserTasksWithAssignees(DataTable expectedTasksTable) {
    List<UserTask> actualTasks = model.getModelElementsByType(UserTask.class).stream().toList();
    List<Map<String, String>> expectedTasks = expectedTasksTable.asMaps(String.class, String.class);

    List<String> expectedTaskIds = expectedTasks.stream().map(row -> row.get("taskId")).toList();
    List<String> actualTaskIds = actualTasks.stream().map(UserTask::getId).toList();

    org.assertj.core.api.Assertions.assertThat(actualTaskIds)
        .as("Найдены не все ожидаемые пользовательские задачи")
        .containsAll(expectedTaskIds);

    org.assertj.core.api.Assertions.assertThat(expectedTaskIds)
        .as("Обнаружены лишние пользовательские задачи в процессе")
        .containsAll(actualTaskIds);

    expectedTasks.forEach(
        row -> {
          String taskId = row.get("taskId");
          String expectedAssignee = row.get("assignee");

          UserTask task = model.getModelElementById(taskId);
          org.assertj.core.api.Assertions.assertThat(task.getCamundaAssignee())
              .as("Исполнитель задачи '%s' не соответствует ожидаемому", taskId)
              .isEqualTo(expectedAssignee);
        });
  }

Верифицируем условия переходов:

Также у нас есть возможность проверить условие перехода между двумя соседними элементами. Если в результате каких-то доработок прямая связь между элементами потеряется, либо если условие изменится или будет снято, мы сразу отловим это на тестах и получим понятную текстовку.

Шаг сценария:

этот же шаг кодом, а не скриншотом
    Then Элемент "IfAfterInputQuant" имеет переход на "ScanSlot" с условием "${InputQuant___isBack == true}"

Метод:

@Then("Элемент {string} имеет переход на {string} с условием {string}")
  public void verifySequenceFlow(String sourceId, String targetId, String condition) {
    FlowElement source = model.getModelElementById(sourceId);
    Collection<SequenceFlow> outgoing = ((FlowNode) source).getOutgoing();

    org.assertj.core.api.Assertions.assertThat(outgoing)
        .anySatisfy(
            flow -> {
              org.assertj.core.api.Assertions.assertThat(flow.getTarget().getId())
                  .isEqualTo(targetId);
              org.assertj.core.api.Assertions.assertThat(
                      flow.getConditionExpression().getTextContent())
                  .isEqualTo(condition);
            });
  }

Проверка слушателей:

Я реализовала два шага для проверки слушателей: 

  • Проверяем, какие слушатели привязаны к элементу процесса. Также проверяем характеристики этих слушателей: событие и поля. Если в какой-то момент ожидаемое разойдется с реальностью, то наши тесты упадут с понятной текстовкой.

  • Проверяем, что к элементу не привязано никаких слушателей: 

Код первого шага довольно объемный, поскольку идет поиск и проверка различных параметров. Приведу код метода, который реализует второй шаг:

@Then("Элемент {string} не имеет никаких execution listeners")
  public void verifyNoneExecutionListeners(String elementId) {
    var flowElement = findElementById(model, elementId);
    var listeners =
        Optional.ofNullable(flowElement.getExtensionElements())
            .map(this::findExecutionListeners)
            .orElse(Collections.emptyList());

    org.assertj.core.api.Assertions.assertThat(listeners)
        .withFailMessage(
            "Элемент '%s' содержит %d неожиданных execution listener(s): %s",
            flowElement.getId(),
            listeners.size(),
            listeners.stream().map(l -> l.getCamundaEvent() + ":" + l.getCamundaClass()).toList())
        .isEmpty();
  }

private FlowElement findElementById(BpmnModelInstance model, String elementId) {
    FlowElement element = model.getModelElementById(elementId);
    org.assertj.core.api.Assertions.assertThat(element)
        .as("Элемент '%s' не найден в процессе", elementId)
        .isNotNull();
    return element;
  }

В результате добавления статического анализа мы получили возможность жестко фиксировать набор пользовательских задач с их исполнителями, фиксировать необходимых слушателей или их отсутствие на выбранных нами элементах и фиксировать условия переходов между выбранными нами элементами в любом процессе. При этом мы можем каждый раз сами решать, в каком объеме и будем ли мы использовать данные проверки. 

Помимо целей самого тестирования, данные сценарии также могут быть полезны для общего анализа процесса, т.к. в них в виде списка представлены необходимые характеристики. Можно не проводить каждый раз анализ, например, какие пользовательские задачи задействованы в процессе, а просто посмотреть на подготовленный к тестам список. 

Заключение:

Я показала, как можно создать конструктор для тестирования процессов. Данное решение имеет много возможностей для расширения и адаптации под различные задачи. 

Как я и говорила в начале, Camunda предоставляет множество возможностей для тестирования процессов, а в комбинации с Cucumber мы получаем возможность переноса на более верхний уровень самого процесса создания тестов, когда можно оторваться от кода и сосредоточиться на бизнес-логике.

Библиотеки визуализации позволяют наглядно продемонстрировать тестируемые случаи, предоставляя информацию в формате, понятном для всех участников работы над процессами.

Таким образом достигнуты цели:

  • не требуется каждый раз прописывать юнит тесты под конкретный процесс, позволяя переиспользовать уже созданный код 

  • разработка тестов для новых процессов происходит на верхнем уровне

  • ускорение процесса создания тестов

  • сами сценарии читаемы и позволяют сосредоточиться на логике процесса

Комментарии (2)


  1. Logintsev
    26.09.2025 14:04

    Класс! Спасибо.


    1. pielena Автор
      26.09.2025 14:04

      Спасибо, приятно)