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 или туториал от начала и до конца, как выстроить процесс автотестирования бизнес‑процессов, а лишь рассмотрение базовых возможностей в наших процессах. В наших модулях множество функций и описать их все в одной статье проблематично, да и смысла в этом не особо много. Очень много специфики в покрываемых нами автотестами БП.

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

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