Привет, читатели Хабра!
Меня зовут Николай Усов, я работаю в отделе тестирования «Цифровой индустриальной платформы». В нашей команде в качестве системы управления тестированием программных продуктов используется 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‑бота «Всё, о чём должен знать разработчик Телеграм‑ботов» и планировщика задач «Как сделать вашего телеграм‑бота лучше? Конечно, добавить ему аналитику». Рекомендую в качестве дополнительного чтения к этой статье. Ну и буду рад ответить на ваши вопросы и получить ваши советы в комментариях.