Привет, читатели Хабра!
Меня зовут Николай Усов, я работаю в отделе тестирования «Цифровой индустриальной платформы». В нашей команде в качестве системы управления тестированием программных продуктов используется Test IT. Система в целом нам нравится, претензий к функционалу почти совсем нет. Однако инструментарий Test IT не всегда позволяет настроить работу тестировщиков так, как удобно. Например, тот, кто с ней работал, знает, что при большом количестве тестов может быть затруднительным поддержание соответствия между автоматизированными и ручными тест-кейсами, если их слишком много. Плюс могут потребоваться иные методы расчета успешности автотестов или более простой интерфейс для удаленного просмотра статистики по прогонам. В этой статье я расскажу, как с помощью telegram-бота, работающего в связке с Test IT, мы сделали жизнь тестировщиков немного приятнее, решив следующие задачи:
Поддержание соответствия между автоматизированными и ручными тест-кейсами.
Отслеживание стабильности через процент успешности автотестов.
Отступление
Решение этих задач базируется на следующем стеке технологий:
Test IT TMS 4.3.0
Python 3.8
MongoDB 6.0.3
Если вам интересно, почему выбор пал именно на них, то разверните.
Обоснование выбора технологий
Test IT Test Management System [https://testit.software/]. Эта система управления тестированием программных продуктов. В компании широко используется и помогает оптимизировать работу тестировщиков. На нее никто не жалуется.
Python. Автоматизированные тесты у нас пишутся на этом языке программирования, так что другие языки не рассматривались. Telegram-бот становится более производительным, если использовать асинхронный подход к написанию кода, поэтому мы взяли следующие основные библиотеки:
MongoDB [https://www.mongodb.com/]. Это документоориентированная система управления базами данных. Данная СУБД была выбрана из-за возможности хранения сложных по структуре объектов в виде документов. Можно практически не обрабатывать тело ответа от Test IT, чтобы сохранить данные в коллекцию. Плюс поиск и выгрузка документов из БД производится быстро и легко.
1. Поддержание соответствия между автоматизированными и ручными тест-кейсами
Небольшое пояснение для тех, кто не работал с Test IT. В системе есть два раздела: автотесты и ручные тесты. Ручные тестировщики заводят тест-кейсы в Test IT, после чего автотестировщики пишут по ним автоматизированные тесты (дальше по тексту будем использовать автотесты) в своей IDE (среде разработки). Написанные автотесты линкуются с ручными тест-кейсами в Test IT. В момент линковки создаются автотесты в Test IT.
Поддержание соответствия автотестов с тест-кейсами (дальше по тексту будем использовать неподтвержденные автотесты) может быть затруднительным процессом, если их слишком много. В Test IT, конечно, есть раздел с автотестами, где можно отфильтровать и отследить неподтвержденные автотесты (см. рис. 1). Однако там не хватает инструментов для разделения по модулям, чтобы прослеживать структуру проекта, хотя в разделе с тест-кейсами данная структура присутствует.

Проблему с недостающей нам структурой проекта в автотестах мы просто решили с помощью telegram-бота. Как выглядит меню проекта в интерфейсе бота, показано на рис. 2. То есть мы предусмотрели разделение проекта на модули, и каждому модулю соответствует свой набор автотестов.
Вызов меню происходит за счет команды /unapproved_autotests. В БД заранее созданы пустые коллекции, которые обновляются с вызовом команды /start и запуском скрипта по расписанию, но об этом чуть ниже.
Обработчик для команды /unapproved_autotests собирает данные по автотестам и создает меню:
Обработчик для команды /unapproved_autotests собирает данные по автотестам и создает меню:
@dp.message_handler(commands=["unapproved_autotests"]) async def unapproved_autotests_command_handler(message: types.Message): """ Обработчик команды /unapproved_autotests. \n :param message: объект, представляющий сообщение пользователя. """ localization = await data_base.get_user_localization(user_id=message.chat.id) wait_msg = await message.answer(text=MESSAGES.get('get_autotests').get(localization)) # сохраняем тип состояния - unapproved (неподтвержденные автотесты) await data_base.save_selected_autotests_type( user_id=message.chat.id, autotests_type='unapproved') # получает наименования модулей {“module_id": {“name”: module_name, “localization”:localization}} all_modules = await data_base.get_modules_names(report_mode="key_id") # получает автотесты, которые нужно актуализировать all_unapproved_autotests = await testit_client.get_autotests_for_actualize( modules=all_modules, message=message, localization=localization) # обновляет БД неактуализированными автотестами await data_base.update_unapproved_autotests( user_id=message.chat.id, autotests_dict=all_unapproved_autotests) await bot.delete_message( chat_id=message.chat.id, message_id=wait_msg.message_id) # перенаправляет на метод создания меню await send_modules_menu(message=message)
В методе testit_client.get_autotests_for_actualize происходит сбор и распределение по модулям неподтвержденных автотестов (часть метода):
for autotest in all_autotests: if mode == "unapproved" and autotest.get('mustBeApproved'): autotests_dict = await self.add_autotests_dict( autotests_dict=autotests_dict, modules_names=modules_names, autotest=autotest)
Формирование основного меню, которое разделено на модули:
async def send_modules_menu(message: types.Message): """ Отправляет меню со списком модулей. \n :param message: объект, представляющий сообщение пользователя. """ # получает наименования модулей {“module_id": {“name”: module_name, “localization”:localization}} modules_names = await data_base.get_modules_names(report_mode="key_id") # составляем блоки для меню markup = types.InlineKeyboardMarkup() buttons = [] localization = await data_base.get_user_localization(user_id=message.chat.id) autotests_type = await data_base.get_selected_autotests_type(user_id=message.chat.id) for module_id in modules_names.keys(): buttons.append( types.InlineKeyboardButton( text=modules_names[module_id].get("localization").get(localization), callback_data=f'module#{modules_names[module_id].get(" name")}#{autotests_type}')) if len(buttons) == 2: markup.add(*buttons) buttons = [] markup.add( types.InlineKeyboardButton( text=MESSAGES.get('exit_btn').get(localization), callback_data='exit_button')) await message.answer(text=MESSAGES.get('select_module').get(localization), reply_markup=markup)
Поддержание соответствия шагов между автотестами и их тест-кейсами организовано дополнительным действием — подпиской на изменение. Подписка на изменение предоставляет пользователю информацию об изменении тест-кейса(-ов): в чате по расписанию всплывает меню с неподтвержденными автотестами за прошлый день. Выглядит это так, как на рис. 3.
Если автотест нужно посмотреть более детально, то жмем на кнопку «Открыть меню с автотестами» и переходим в меню с модулями (как на рис. 2).
В модулях распределены автотесты, полученные по подписке.
async def schedule_subscription(): """ Подписка по расписанию. """ aioschedule.every().day.at("21:15").do( job_func=send_last_unapproved_autotests_on_a_schedule, bot=bot) while True: await aioschedule.run_pending() await asyncio.sleep(1) def run_bot(): """ Запускает бота. """ asyncio.run_coroutine_threadsafe(coro=main(), loop=loop) loop.run_forever() def run_scheduled_delivery(): """ Запускает подписку. """ asyncio.run_coroutine_threadsafe(coro=schedule_subscription(), loop=loop) if __name__ == "__main__": try: thread1 = Thread(target=run_bot) thread2 = Thread(target=run_scheduled_delivery) thread1.start() thread2.start() except (KeyboardInterrupt, SystemExit) as ex: logger.error(f'Stop session: {ex}')
Подписка реализована через библиотеку aioschedule, которая находится в точке входа в программу. Для задания интервала выполнения функции используется метод aioschedule.every(). А чтобы проверять и выполнять запланированные задачи, применяется aioschedule.run_pending() в цикле. Данный запуск с UI telegram-бота не контролируется.
Ниже приведен метод для сбора информации об изменении тест-кейса(-ов). В первую очередь через API Test IT ищем измененные тест-кейсы и смотрим их дату создания. Далее мы считываем номера измененных тест-кейсов, созданных за прошедший день, и по этим номерам ищем соответствующие им автотесты.
async def send_last_unapproved_autotests_on_a_schedule(bot: Bot): """ Отправляет список неподтвержденных автотестов всем подписчикам по расписанию за прошлый день. \n :param bot: объект, представляющий бота. """ # получаем список пользователей, которые подписались на рассылку subscribers_ids = await data_base.get_subscribers_ids() if subscribers_ids is None: return # получаем все автотесты, у которых изменился тест-кейс за прошедший день last_unapproved_autotests = await testit_client.get_last_unapproved_autotests() modules = await data_base.get_modules_names(report_mode="key_id") # оповещаем всех подписчиков for id_ in subscribers_ids: localization = await data_base.get_user_localization(user_id=id_) # подсчет для вывода статистики autotests_count = {modules[module_id]["localization"][localization]: len(autotests) for module_id, autotests in last_unapproved_autotests.items() if len(autotests) > 0} # меню с модулями, если имеются автотесты за прошлый день if autotests_count: keyboard = InlineKeyboardMarkup() keyboard.add( types.InlineKeyboardButton( text=MESSAGES.get("open_modules_with_last_unapproved_autotests").get( localization), callback_data='last_unapproved_autotests')) await bot.send_message( chat_id=id_, text=MESSAGES.get("last_unapproved_description_with_autotests").get( localization).format(autotests_count), reply_markup=keyboard, parse_mode='Markdown') else: await bot.send_message( chat_id=id_, text=MESSAGES.get("last_unapproved_description_without_autotests").get( localization))
API-запросы к Test IT, задействованные при выгрузке данных:
GET/api/v2/autoTests/{autotest_id}/workItems/changed/id — получить список идентификаторов тест-кейсов, которые необходимо подтвердить.
GET/api/v2/workItems/{id_} — получить тест-кейс по идентификатору.
POST/api/v2/autoTests/search — получить список всех автотестов.
2. Отслеживание стабильности через процент успешности автотестов
Показатель стабильности необходим для того, чтобы понимать, как себя ведет автотест. Однако рассчитать его можно по-разному. Например, Test IT считает процент стабильности через число переходов между статусами автотеста, то есть как часто происходит смена статусов с «провален» на «успешен» и наоборот по результатам множества прогонов.
Формула подсчета процента стабильности автотеста в Test IT:
N — число всех переходов между статусами автотеста (100 последних прохождений или все, если их меньше 100),
M — число переходов между статусами автотеста, при которых статус изменился.
Примеры:
No result - "-"
Passed - "100%"
Passed Passed - "100%" (0/1), где 0 = M, 1 = N
Passed Failed - "0%" (1/1)
Passed Failed Passed - "0%" (2/2)
Passed Failed Passed Failed - "0%" (3/3)
Passed Failed Passed Failed Failed - "25%" (3/4)

Однако нам этого способа расчета стабильности в Test IT оказалось недостаточно.
Как видно из рис. 5, тест постоянно падает, его стабильность — 100%. Это правильно. Но есть еще один подход к определению стабильности — исходя из процента успешности автотеста, когда определяется доля успешных прогонов автотеста из общего их числа. И этот показатель нам очень нужен, потому что с его помощью мы сможем:
более точно находить нестабильные автотесты;
исследовать причины того, почему они постоянно падают;
принимать меры для того, чтобы повысить стабильность, либо отказаться от теста.
Формула подсчета процента успешности автотеста для отслеживания стабильности:
passed_count — количество успешных прогонов автотеста,
failed_count — количество провальных прогонов автотеста.
Этот подход к отслеживанию стабильности мы реализовали с помощью telegram-бота.
Как видно из рис. 6, используется машина состояний, которая задает min/max границы стабильности автотестов. Ниже приведен код обработчиков для ввода min/max значений стабильности (в процентах).
Ввод минимального процента:
@dp.message_handler(lambda message: message.text.isdigit(), state=user_states.min_percent) async def input_min_percent_handler(message: types.Message): """ Обработчик ввода минимального процента. \n :param message: объект, представляющий сообщение пользователя. """ localization = await data_base.get_user_localization(user_id=message.chat.id) # считываем текст ввода _min_percent = int(message.text) if 0 <= _min_percent <= 100: # переходим к следующему состоянию FSM await user_states.next() await data_base.save_min_percent( user_id=message.chat.id, min_percent=_min_percent) await message.answer(text=MESSAGES.get('input_max_percent').get(localization)) else: return await message.answer(text=MESSAGES.get('retry_input').get(localization))
Ввод максимального процента:
@dp.message_handler(lambda message: message.text.isdigit(), state=user_states.max_percent) async def input_max_percent_handler(message: types.Message): """ Обработчик ввода максимального процента. \n :param message: объект, представляющий сообщение пользователя. """ localization = await data_base.get_user_localization(user_id=message.chat.id) _max_percent = int(message.text) if 0 <= _max_percent <= 100: # выгружаем из БД значение минимального процента _min_percent = await data_base.get_min_percent(user_id=message.chat.id) if _max_percent >= _min_percent: # заканчиваем режим состояний FSM (всего два состояния) await user_states.next() await data_base.save_max_percent( user_id=message.chat.id, max_percent=_max_percent) wait_msg = await message.answer(text=MESSAGES.get( 'get_autotests').get(localization)) # сохраняем в БД тип состояния - 'stability' await data_base.save_selected_autotests_type( user_id=message.chat.id, autotests_type='stability') all_modules = await data_base.get_modules_names(report_mode="key_id") # получаем автотесты, которые нужно актуализировать all_stability_autotests = await testit_client.get_autotests_for_actualize( modules=all_modules, mode="stability", min_percent=_min_percent, max_percent=_max_percent, message=message, localization=localization) await data_base.update_stability_autotests( user_id=message.chat.id, autotests_dict=all_stability_autotests) await bot.delete_message( chat_id=message.chat.id, message_id=wait_msg.message_id) await send_modules_menu(message=message) else: return await message.answer(text=MESSAGES.get( 'max_percent_error').get(localization)) else: return await message.answer(text=MESSAGES.get('retry_input').get(localization))
Расчет стабильности проводится либо вручную (с помощью окна администратора — см. рис. 7), либо по расписанию (реализация точно такая же, как при подписке на изменения — см. главу 1).

Действия по расчету стабильности:
Выгрузка всех автотестов в БД из Test IT.
Выгрузка всех test-runs (прогоны) для каждого автотеста в БД из Test IT (POST/api/v2/autotests/{id_}/testResults/search).
Расчет стабильности автотестов (процент успешности), используя БД в качестве быстрого поиска и выгрузки требуемых данных.
Выгрузка из Test IT test-runs для каждого автотеста производится партиями (batch), чтобы не нагружать сервис:
async def add_entities_for_autotest_to_data_base_by_batch( func, entities: list = None, batch: int = 5, **kwargs): """ Добавляет сущности в БД партиями для определенного автотеста.\n :param func: метод добавления сущностей в БД. :param entities: список сущностей. :param batch: группа сущностей, чтобы разбить большой массив на части. :param kwargs: остальные параметры. """ flag = True while flag: if not entities: flag = False # проходимся по партиям data = entities[:batch] entities = entities[batch:] # создаем таски и ждем их выполнения data_ = [] for entity in data: data_.append(asyncio.create_task(func(entity, **kwargs))) await asyncio.gather(*data_)
3. Вывод статистики по пройденным автотестам в telegram-боте
Test IT отличается богатым функционалом и большим количеством окон, что делает несколько неудобным удаленный просмотр статистики по ночному прогону, например, если под рукой нет ноутбука. Чтобы от этого неудобства себя избавить, мы реализовали в telegram-боте быструю UI-форму для отслеживания статистики прохождения автотестов (см. рис.8).
Для быстрого поиска тест-плана используется машина состояний (FSM — Finite State Machine).
@dp.callback_query_handler(lambda call: call.data == "test_plan_search_btn") async def test_plan_search_callback(call: CallbackQuery): """ Обработчик нажатия на кнопку поиска тест-плана. \n :param call: объект, представляющий входящий callback-запрос. """ localization = await data_base.get_user_localization(user_id=call.message.chat.id) await bot.delete_message(chat_id=call.message.chat.id, message_id=call.message.message_id) # устанавливаем машину состояний FSM await search_state.search.set() await call.message.answer(MESSAGES.get('input_name').get(localization).format("тест-плана")) @dp.message_handler(state=search_state.search) async def test_plan_search_fsm(message: types.Message): """ Обработчик ввода имени тест-плана. \n :param message: объект, представляющий сообщение пользователя. """ test_plan_name = message.text # останавливаем машину состояний (одно состояние) await search_state.next() # сохраняем состояние тест-плана в режиме поиска await data_base.save_test_plan_action( user_id=message.chat.id, action='test_plans_results', text_search=test_plan_name) # передаем в меню тест-плана параметр page=1 – пагинация начинается со страницы 1 await send_test_plans(message=message, page=1)
Тут все просто: первым обработчиком запускаем машину состояний при нажатии на кнопку «Поиск тест-плана», вторым — завершаем работу машины состояний и отправляем введенное наименование тест-плана на вывод в меню.
4. Запуск автотестов с телефона
Хорошее дополнение к просмотру статистики в телефоне — запуск автотестов с того же телефона.
Небольшое отступление. В проекте настроен запуск автотестов по тест-плану, с использованием pipeline GitLab. По имеющейся конфигурации на стенде (прописано в Test IT) и созданному test-run будут запущены автотесты. В конце запуска будет линк результатов пройденных автотестов с этим тест-планом.
В Test IT предварительно должен быть создан тест-план с набором автотестов. Запуск автотестов в telegram-боте проводится с помощью запроса из API Test IT — POST/api/TestRuns. Данный запрос создает в разделе «Запуски автотестов» пустой test-run. В данный test-run помещается (из основных параметров) идентификатор тест-плана (с которым мы будем линковать результат пройденных автотестов) и набор автотестов из этого тест-плана. Набор автотестов выгружаем из запроса API Test IT — GET/api/v2/testPlans/{test_plan_id}/testSuites.
После запуска test-run запускается настроенный pipeline в GitLab. В pipeline срабатывает команда:
pytest -v -m <марки автотестов> <путь до автотестов в проекте> --testit --tmsUrl= --tmsPrivateToken= --tmsAdapterMode=0 --tmsConfigurationId= --tmsProjectId= --tmsTestRunId= --numprocesses= --dist loadgroup.
Немного о параметрах:
--tmsConfigurationId — идентификатор конфигурации стенда;
--tmsProjectId — идентификатор проекта в Test IT;
--tmsAdapterMode — режим 0, запуск с параметром tmsTestRunId;
--tmsTestRunId — идентификатор test-run.
Автотесты, написанные с марками, связаны напрямую с Test IT. Данные марки позволяют, с помощью tmsTestRunId, запускать нужные автотесты.
Команда pytest направляет результаты в нужный тест-план:
async def run_test_plan(message: types.Message, test_plan_id: str): """ Запускает тестовый план. \n :param message: объект, представляющий сообщение пользователя. :param test_plan_id: идентификатор тестового плана. """ localization = await data_base.get_user_localization(user_id=message.chat.id) test_suites_ids = [] points_for_run = [] # получаем тестовый набор test_suites = await testit_client.get_test_suites(test_plan_id=test_plan_id) for test_suite in test_suites: test_suites_ids.append(test_suite.get('id')) # получаем идентификаторы автотестов. points_data = await testit_client.post_points_with_last_result( test_suites_ids=test_suites_ids) for _, points in points_data.items(): for point in points: points_for_run.append(point.get('id')) test_run_data = { 'autoTests': [], 'build': '1', 'entityTypeName': 'TestRuns', 'isAutomated': True, 'name': None, 'projectId': Config.testit_project_id, 'runByUserId': None, 'stateName': 'NotStarted', 'stoppedByUserId': None, 'testPlanId': test_plan_id, 'testPointIds': points_for_run } try: await testit_client.post_test_runs(data=test_run_data) await message.answer(text=MESSAGES.get('successfully_launched_test_plan').get( localization)) except Exception: await message.answer(text=MESSAGES.get('unsuccessfully_launched_test_plan').get( localization))
Заключение
В итоге мы реализовали полезный функционал для автотестировщиков. Однако на достигнутом останавливаться не собираемся. В наших планах:
реализовать расчет стабильности автотестов для отдельных релизов, для тест-планов, для групп тест-планов;
исправить технологию общения с сервером Telegram на Webhook (в данный момент работает на Long Polling);
расширить функционал вывода информации;
подключить разделение ролей пользователей;
обновить Python до 3.11.0.
Кстати, на Хабре есть пара отличных материалов, которые помогут в создании telegram‑бота «Всё, о чём должен знать разработчик Телеграм‑ботов» и планировщика задач «Как сделать вашего телеграм‑бота лучше? Конечно, добавить ему аналитику». Рекомендую в качестве дополнительного чтения к этой статье. Ну и буду рад ответить на ваши вопросы и получить ваши советы в комментариях.

