Автоматизация тестирования бизнес-процессов через camunda

от автора

1. Введение

Всем привет! Меня зовут Ренат Дасаев и в прошлой статье Автоматизация Е2Е‑тестирования сквозных БП интеграционных проектов Операционного блока было рассказано о том, как устроено e2e‑автотестирование. Сегодня хочу рассказать о том, как используется camunda в автотестировании бизнес‑процессов (далее БП). На практических примерах рассмотрим, что и как мы делаем в своих тестах.

Если интересно узнать о движке camunda и о том, как он применяется в БП Московской Биржи на примере проекта ЦУП, — рекомендую ознакомиться с материалом нашего коллеги.

2. Проверки БП в camunda автотестами

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

Стандартный набор проверок:

  • процесс начинается и завершается успешно;

  • gateway, boundary events и другие элементы двигают БП в нужном направлении;

  • на проверяемых шагах корректный контекст — присутствуют необходимые переменные процесса;

  • корректный результат вычислений в выражениях events/gateway;

  • успешно исполняются service‑таски и создаются user‑таски;

  • на шагах, где есть взаимодействие с внешними сервисами — успешно отрабатывают (в e2e нет заглушек, работаем с реальными интеграциями);

  • не только «success path», но и другие маршруты следования по схеме, в том числе, где обрабатываются ошибки в процессе;

  • процесс не впадает в инцидент.

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

3. Разработка модулей по работе с bpmn – процессами

В прошлой статье упоминалось, что совместно с другой командой разрабатываем python-модули для работы с микросервисами, а также модули (клиенты) по работе с сервисами/движками.

Для работы с bpmn-процессами разработано несколько модулей:

  • moex-pmh-bpmn (работа с bpmn-процессами);

  • moex-pmh-camunda-client (работа с camunda rest api);

  • moex-pmh-<название_БП> (работа с конкретным инстансом БП, на каждый бизнес-процесс по 1 такому модулю).

Иерархия модулей:

moex-pmh-<название_БП> → moex‑pmh‑bpmn → moex-pmh-camunda-client.

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

На текущий момент модуль moex-pmh-camunda-client умеет работать со следующими контроллерами:

  • Decision Definition

  • Execution

  • External task

  • Job

  • Job Definition

  • Historic Process Instance

  • Historic Variable Instance

  • Incident

  • Process Definition

  • Process Instance

  • Task

  • Variable Instance

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

4. Старт бизнес-процесса. Поиск запущенного процесса в camunda.

В любом БП движок camunda доступен через http — https://<тестовый_домен>/<camunda>. С этим endpoint автотест и работает в ходе проверок внутри БП.

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

  • отправки сообщения в шину, например, esb (enterprise service bus);

  • создания объекта в БД сервиса;

  • наступления определенной даты и/или времени;

  • получения электронных писем, файлов и прочее.

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

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

Для наглядности возьмем реальный БП «Активация выкупа ценной бумаги» (входит в один большой процесс по Регистрации, активации и деактивации выкупа). Это одна из самых маленьких схем, что удалось найти. В 99% случаев схемы гораздо больше по объему и взаимодействию с внешними ресурсами. На рисунке 1 представлен скриншот данного БП из camunda:

Рисунок 1. Схема бизнес-процесса “Активация выкупа”

Рисунок 1. Схема бизнес-процесса “Активация выкупа”

В нашем примере поиск процесса будет производиться по переменной secuirtyId (торговый код инструмента). На рисунке 2 можно увидеть запущенный экземпляр процесса «Активация выкупа» со списком переменных процесса, среди которых есть необходимая переменная:

Рисунок 2. Запущенный экземпляр БП “Активация выкупа” и отображение переменной securityId

Рисунок 2. Запущенный экземпляр БП «Активация выкупа» и отображение переменной securityId

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

def wait_for_process_instance_by_security_id(     self,     security_id: str,     *,     max_time: int = 180,     sleep_time: float = 2.0, ) -> CamundaProcessInstance:     with allure.step(         f'Проверка того, что был запущен процесс "{self.pretty_name}" '         f'со значением переменной securityId="{security_id}"',     ):         return poll_by_time_first_success(             fn=lambda: self.get_process_instance_by_security_id(security_id),             checker=lambda p: p is not None,             max_time=max_time,             sleep_time=sleep_time,         )

Производятся попытки в течение max_time (120 секунд по умолчанию) с шагом в 2 секунды (по умолчанию) в цикле (поллинг) найти процесс, в котором обнаружилась необходимая переменная (get_process_variable_instances) и её значение:

def get_process_instance_by_security_id(     self,     security_id: str, ) -> Optional[CamundaProcessInstance]:     with allure.step(         f'Поиск процесса "{self.pretty_name}" '         f'со значением переменной securityId="{security_id}"',     ):         processes = self.camunda.get_process_instances()         for process in processes:             variables = self.camunda.get_process_variable_instances(process.id) or {}             if 'securityId' not in variables:                 continue             v_security_id = variables.get('securityId').value             if v_security_id == security_id:                 return process

Если процесс нашелся, то возвращается объект датакласса CamundaProcessInstance, либо None.

Пример CamundaProcessInstance объекта:

id='007f5c67-44c4-11ef-b669-b2d4f57bb43f' rootProcessInstanceId='007f5c67-44c4-11ef-b669-b2d4f57bb43f' superProcessInstanceId=None superCaseInstanceId=None caseInstanceId=None processDefinitionName='Подтверждение активации режима выкупа' processDefinitionKey='bp-offer-activation' processDefinitionVersion=7 processDefinitionId='bp-offer-activation:7:b99a97ae-17e5-11ee-971d-7ef2aaea4619' businessKey='bpms/offersreg/1/1/RUTEST48KTEP/5623' startTime=datetime.datetime(2024, 7, 18, 8, 10, 8, 440000) endTime=None removalTime=None durationInMillis=None startUserId=None startActivityId='StartEvent_1' deleteReason=None tenantId=None state=<ProcessInstanceState.ACTIVE: 'ACTIVE'>

Если в процессе поллинга процесс не обнаружился за промежуток времени max_time, то порождается исключение TimeoutError и автотест завершается на этом.

5. Мониторинг активностей по bpmn-схеме

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

  • определенные вычисления в gateway/boundary/events;

  • сервис-таски (service-tasks);

  • пользовательские задачи (user-tasks);

  • взаимодействие с базами данных/шинами/почтовыми ящиками/сайтами и прочее.

На примере нашего БП по активации выкупа попробуем определить, что создалась пользовательская задача «Выполнить сверку ЕКБД с ASTS» (ЕКБД – Единая Клиентская База Данных, ASTS — Automated Securities Trading System).

Для начала нужно вычислить id данного шага на схеме в camunda. Загрузим bpmn-файл данного БП в camunda modeler и выделим необходимый шаг (см. рисунок 3):

Рисунок 3. Выделенный элемент (пользовательская таска “Выполнить сверку ЕКБД с ASTS) в camunda modeler”

Рисунок 3. Выделенный элемент (пользовательская таска “Выполнить сверку ЕКБД с ASTS) в camunda modeler”

В списке атрибутов находим id = compare-offers-manual. Это и будет тот идентификатор, который необходимо найти в списке активностей нашего тестируемого БП.

Для поиска активности используем функцию с поллингом (waitforprocess_activity):

def wait_for_process_activity(     self,     process_instance_id: str,     *,     activity_name: Optional[str] = None,     activity_id: Optional[str] = None,     max_time: int = 30,     sleep_time: int = 2, ) -> None:     moex_asserts.assert_true(         expr=(activity_name or activity_id) and not (activity_name and activity_id),         msg='Должен быть указан один из аргументов [activity_name, activity_id]',     )     kwargs = (         {'activity_name': activity_name}         if activity_name else {'activity_id': activity_id}     )     with allure.step(         f'Проверка того, что процесс "{self.pretty_name}" '         f'с id "{process_instance_id}" дошел до активности '         f'"{activity_name or activity_id}"',     ):         poll_by_time_first_success(             fn=lambda: self.find_process_activity(                 process_instance_id=process_instance_id,                 **kwargs,             ),             checker=lambda a: a is not None,             max_time=max_time,             sleep_time=sleep_time,             msg=(                 f'Процесс "{self.pretty_name}" с id "{process_instance_id}" '                 f'не дошел до активности "{activity_id or activity_name}"'             ),         )

В которой на каждой итерации получаем список активностей (find_process_activity):

def find_process_activity(     self,     process_instance_id: str,     *,     activity_name: Optional[str] = None,     activity_id: Optional[str] = None, ) -> Optional[CamundaActivity]:     moex_asserts.assert_true(         expr=(activity_name or activity_id) and not (activity_name and activity_id),         msg='Должен быть указан один из аргументов [activity_name, activity_id]',     )     with allure.step(         f'Поиск активности "{activity_name or activity_id}" '         f'процесса "{self.pretty_name}" с id "{process_instance_id}"',     ):         for activity in self.get_process_activities(process_instance_id):             if activity_name:                 if activity.activityName == activity_name:                     return activity             elif activity_id:                 if activity.activityId == activity_id:                     return activity

Внутри find_process_activity() вызывается get_process_activities() (внутри используется camunda api — /process-instance/{process_instance_id}/activity-instances) и ищем среди них наш активити по activity_id/activity_name:

def get_process_activities(self, process_instance_id: str) -> List[CamundaActivity]:     with allure.step(         f'Поиск активностей процесса "{self.pretty_name}" '         f'с id "{process_instance_id}"',     ):         base_activity = self.camunda.get_process_instance_activities(             process_instance_id,         )         child_activities = self.__get_process_activities(base_activity)         return child_activities

Соответственно, необходимо передать в аргументах wait_for_process_activity лишь:

  • process_instance_id=<id_процесса> (вычисляется из функции поиска старта процесса);

  • activity_id=’ compare-offers-manual’ (или activity_name).

Как только процесс дойдет до этого шага в указанные max_time (обычно 60 секунд), то поллинг это отловит, произведет проверку и, если все успешно, передаст управление следующей функции в тесте. Иначе получим TimeoutError.

После того, как тест нашел activity с activity_id=’ compare-offers-manual’ в camunda, необходимо вычислить id пользовательской задачи (user-tasks), чтобы потом с этим идентификатором найти задачу уже в Менеджере Задач платформы ЦУП.

Для вычисления id пользовательской задачи в camunda разработали функцию wait_for_process_user_task():

def wait_for_process_user_task(     self,     process_instance_id: str,     *,     activity_name: Optional[str] = None,     activity_id: Optional[str] = None,     max_time: int = 30,     sleep_time: int = 2,     post_await_time: int = 3, ) -> CamundaTask:     moex_asserts.assert_true(         expr=(activity_name or activity_id) and not (activity_name and activity_id),         msg='Должен быть указан один из аргументов [activity_name, activity_id]',     )     kwargs = (         {'activity_name': activity_name}         if activity_name else {'activity_id': activity_id}     )     with allure.step(         f'Проверка того, что по процессу "{self.pretty_name}" с id '         f'"{process_instance_id}" была создана пользовательская задача по джобе'         f'"{activity_id or activity_name}"',     ):         task = poll_by_time_first_success(             fn=lambda: self.find_process_user_task(                 process_instance_id=process_instance_id,                 **kwargs,             ),             checker=lambda a: a is not None,             max_time=max_time,             sleep_time=sleep_time,             msg=(                 f'По процессу {self.pretty_name} с id "{process_instance_id}" '                 'не была создана пользовательская задача по джобе '                 f'"{activity_id or activity_name}"'             ),         )         time.sleep(post_await_time)         return task

Как и в поиске необходимой активности, необходимо передать идентификаторы процесса и активити. Внутри поллинга используется функция find_process_user_task():

def find_process_user_task(     self,     process_instance_id: str,     *,     activity_name: Optional[str] = None,     activity_id: Optional[str] = None, ) -> Optional[CamundaTask]:     moex_asserts.assert_true(         expr=(activity_name or activity_id) and not (activity_name and activity_id),         msg='Должен быть указан один из аргументов [activity_name, activity_id]',     )     with allure.step(         'Поиск пользовательской задачи по джобе '         f'"{activity_id or activity_name}" процесса '         f'"{self.pretty_name}" с id "{process_instance_id}"',     ):         if not activity_id:             activity_id = self.find_process_activity(                 process_instance_id=process_instance_id,                 activity_name=activity_name,             ).activityId         tasks = self.camunda.get_process_instance_tasks(             process_instance_id=process_instance_id,             activity_id=activity_id,         )         return tasks[0] if tasks else None

Внутри функции используется POST-метод /task. В случае успеха (нашлась таска в нужном процессе и с нужным активити) возвращается список объектов датакласса CamundaTask со всеми полями:

id='00977769-44c4-11ef-b669-b2d4f57bb43f' name='Добавить "TEST-H9XQ8" (RUTEST48KTEP) на режим "Выкуп: Адресные заявки"' assignee=None created='2024-07-18T08:10:08.598+0300' due='2024-07-18T23:59:59.647+0300' followUp=None, delegationState=None description='5623' executionId='007f5c67-44c4-11ef-b669-b2d4f57bb43f' owner=None parentTaskId=None priority=50 processDefinitionId='bp-offer-activation:7:b99a97ae-17e5-11ee-971d-7ef2aaea4619' processInstanceId='007f5c67-44c4-11ef-b669-b2d4f57bb43f' caseExecutionId=None caseDefinitionId=None caseInstanceId=None taskDefinitionKey='compare-offers-manual' suspended=False formKey=None camundaFormRef=None tenantId=None

Из данного объекта нас интересует id=’00977769-44c4-11ef-b669-b2d4f57bb43f’ это и есть идентификатор задачи, который будет такой же и в ЦУП. Далее необходимо будет отправить запрос на получение активных задач в ЦУП в Менеджер Задач, найти её в выдаче и далее выполнить над этой задачей необходимые действия («Выполнить» или «Отклонить»).

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

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

Для получения информации об уже завершенном процессе достаточно передать process_instance_id в функцию get_historic_process_instance():

def get_historic_process_instance(     self,     process_instance_id: str, ) -> Optional[CamundaHistoricProcessInstance]:     url = f'{self.__url_prefix}/history/process-instance/{process_instance_id}'     resp = self.__get(url=url)     return CamundaHistoricProcessInstance(**resp) if resp else None

Если же при этом нужно найти в уже завершенном процессе какой-то активити, то можно использовать get_process_instance_historic_activities():

def get_process_instance_historic_activities(     self,     process_instance_id: str, ) -> List[CamundaHistoricActivity]:     url = f'{self.__url_prefix}/history/activity-instance'     resp = self.__get(url=url, params={'processInstanceId': process_instance_id})     return [CamundaHistoricActivity(**a) for a in resp]

и в списке всех активити найти нужный.

В случае если нужно найти переменную в завершенном процессе, то можно использовать get_historic_process_instance_variables():

def get_historic_process_instance_variables(     self,     process_instance_id: str, ) -> Optional[Dict[str, ProcessInstanceHistoricVariable]]:     url = f'{self.__url_prefix}/history/variable-instance'     params = {         'processInstanceId': process_instance_id,         'deserializeValues': 'false',     }     resp = self.__get(url=url, params=params)     return {         variable['name']: ProcessInstanceHistoricVariable(**variable)         for variable in resp     } if resp else None

6. Взаимодействие с таймерами

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

  • наступление определенной даты и времени;

  • поступление сигнала от внешней системы.

Чтобы проверить, что БП встал на таймере также по camunda modeler, находим id этого шага и ищем этот идентификатор в списке активити по процессу (более детально в предыдущей главе). Часто бывает так, что необходимо не просто найти активный таймер, но и произвести взаимодействие с ним (симуляция наступления времени), если таймер очень долгий (> 30 сек).

Для взаимодействия с таймерами разработана функция pass_timer():

def pass_timer(     self,     *,     process_instance: Union[ProcessInstance, CamundaProcessInstance],     timer_id: Optional[str] = None,     job_type: Optional[str] = None, ) -> None:     if isinstance(process_instance, ProcessInstance):         process_instance_id = process_instance.processInstanceId     elif isinstance(process_instance, CamundaHistoricProcessInstance):         process_instance_id = process_instance.id     else:         process_instance_id = process_instance.id     timer_id = timer_id or self.TIMER_ID     job_type = job_type or self.TIMER_JOB_TYPE     with allure.step(         f'Проброс таймера с параметрами timer_id={timer_id} '         f'и job_type={job_type} для процесса "{self.pretty_name}" '         f'с id "{process_instance_id}"',     ):         timer_job = self.get_timer(             process_instance=process_instance,             timer_id=timer_id,             job_type=job_type,         )         self.camunda.execute_job_by_id(timer_job.id)         time.sleep(self.TIMER_AWAIT_TIME)

В качестве аргументов необходимо передать:

  • id процесса;

  • timer_id (id активити по схеме в camunda);

  • job_type (по дефолту используется ‘timer-intermediate-transition’).

Так как таймер является весьма специфичным активити, то была разработана отдельная функция get_timer(), который, помимо стандартных process_instance и timer_id (по сути activity_id), есть еще и job_type, который может варьироваться от типа реализации таймера
(timer-intermediate-transition или timer-transition):

def get_timer(     self,     *,     process_instance: Union[ProcessInstance, CamundaProcessInstance],     timer_id: Optional[str] = None,     job_type: Optional[str] = None, ) -> Optional[CamundaJob]:     if isinstance(process_instance, ProcessInstance):         process_instance_id = process_instance.processInstanceId         process_definition_id = process_instance.processDefinitionId     elif isinstance(process_instance, CamundaHistoricProcessInstance):         process_instance_id = process_instance.id         process_definition_id = process_instance.processDefinitionId     else:         process_instance_id = process_instance.id         process_definition_id = process_instance.definitionId     timer_id = timer_id or self.TIMER_ID     job_type = job_type or self.TIMER_JOB_TYPE     with allure.step(         f'Получение таймера с параметрами timer_id={timer_id} '         f'и job_type={job_type} для процесса "{self.pretty_name}" '         f'с id "{process_instance_id}"',     ):         job_definition = self.camunda.get_job_definitions(             params={                 'activityIdIn': timer_id,                 'processDefinitionId': process_definition_id,                 'jobType': job_type,             },         )[0]         timer_job = self.camunda.get_jobs(             params={                 'jobDefinitionId': job_definition.id,                 'processInstanceId': process_instance_id,             },         )         return timer_job[0] if timer_job else None

Как нашли таймер, то исполняем его execute_job_by_id() — внутри зашит camunda-метод ‘/job/{job_id}/execute’.

7. Идентификация завершенного БП по camunda, удаление инстансов процессов до и после автотестирования

Важным шагом в тестировании БП является проверка, что процесс завершился без ошибок. Для этого разработана функция wait_completed_process():

def wait_completed_process(     self,     process_instance_id: str,     *,     max_time: int = 30,     sleep_time: int = 2, ) -> None:     with allure.step(         f'Проверка того, что процесс "{self.pretty_name}" '         f'с id "{process_instance_id}" завершен',     ):         poll_by_time_first_success(             fn=lambda: self.__get_process_instance_with_incidents_check(                 process_instance_id,             ),             checker=lambda p: p is None,             max_time=max_time,             sleep_time=sleep_time,             msg=(                 f'Процесс "{self.pretty_name}" с id "{process_instance_id}" '                 f'не был завершен за max_time = {max_time} секунд'             ),         )

Используется поллинг по методу __get_process_instance_with_incidents_check() проверяется:

  • что в процессе с указанным process_instance_id нет инцидентов (внутри используется GET метод /incident);

  • что процесс с указанным process_instance_id исчез из списка активных процессов, что является показателем успешного завершения процесса в camunda.

Если определяется, что в процессе обнаружился инцидент, то выбрасывается RuntimeError. Найденный инцидент добавляется в отчет о тестировании, чтобы автоматизатор смог сразу увидеть причину падения теста. На рисунке 4 представлен фрагмент отчета с инцидентом процесса:

Рисунок 4. Пример информации об инциденте БП в allure-отчете о тестировании

Рисунок 4. Пример информации об инциденте БП в allure-отчете о тестировании

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

Часто требуется перед тестом (setup) или после него (teardown) удалить активный процесс (классический пример — процесс выпал в инцидент и «висит» в camunda). Для удаления активных процессов разработана функция delete_process_instance_by_business_key() (удаление по business_key):

def delete_process_instance_by_business_key(     self,     business_key: str, ) -> None:     with allure.step(         f'Удаление процесса "{self.pretty_name}" '         f'с бизнес-ключом "{business_key}"',     ):         self.camunda.delete_process_instance(             process_instance_id=self.get_process_instance_by_business_key(                 business_key,             ).id,         )

В delete_process_instance() используется DELETE метод /process-instance/{process_instance_id}.

Есть аналогичные функции с параметрами, отличные от business_key. Также имеется в арсенале функция по удалению всех активных процессов внутри определенного process_definition.

8. Заключение

Все больше и больше команд в нашей компании подключаются к тестированию БП. Часть из них используют тот же самый подход, что и мы, в том числе через использование модулей, которые рассмотрели в данной статье. Надеюсь, что кому‑то материал поможет и даст отправную точку, чтобы начать тестирование БП через camunda. По крайней мере 5 лет назад, когда мы только начинали выстраивать автотестирование БП (не e2e), подобных материалов с подходами в тестировании camunda не встречали.

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

Спасибо всем, кто дочитал статью до конца. Если остались вопросы, пишите их в комментариях — с радостью ответим! До новых встреч!


ссылка на оригинал статьи https://habr.com/ru/articles/829532/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *