Привет, читатели Хабра!

Меня зовут Николай Усов, я работаю в отделе тестирования «Цифровой индустриальной платформы». В нашей команде в качестве системы управления тестированием программных продуктов используется Test IT. Система в целом нам нравится, претензий к функционалу почти совсем нет. Однако инструментарий Test IT не всегда позволяет настроить работу тестировщиков так, как удобно. Например, тот, кто с ней работал, знает, что при большом количестве тестов может быть затруднительным поддержание соответствия между автоматизированными и ручными тест-кейсами, если их слишком много. Плюс могут потребоваться иные методы расчета успешности автотестов или более простой интерфейс для удаленного просмотра статистики по прогонам. В этой статье я расскажу, как с помощью telegram-бота, работающего в связке с Test IT, мы сделали жизнь тестировщиков немного приятнее, решив следующие задачи:

  1. Поддержание соответствия между автоматизированными и ручными тест-кейсами.

  2. Отслеживание стабильности через процент успешности автотестов.

  3. Вывод статистики по пройденным автотестам.

  4. Запуск автотестов с телефона.

Отступление

Решение этих задач базируется на следующем стеке технологий:

  • 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). Однако там не хватает инструментов для разделения по модулям, чтобы прослеживать структуру проекта, хотя в разделе с тест-кейсами данная структура присутствует.

Рис. 1. Раздел с автотестами в Test IT
Рис. 1. Раздел с автотестами в Test IT

Проблему с недостающей нам структурой проекта в автотестах мы просто решили с помощью telegram-бота. Как выглядит меню проекта в интерфейсе бота, показано на рис. 2. То есть мы предусмотрели разделение проекта на модули, и каждому модулю соответствует свой набор автотестов.

Рис. 2. Меню проекта в интерфейсе telegram-бота
Рис. 2. Меню проекта в интерфейсе telegram-бота

Вызов меню происходит за счет команды /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.

Рис. 3. Всплывающий результат подписки
Рис. 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:

stab\_calc = 100\% - M/N * 100\%, где

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)

Рис. 4. Расчет процента стабильности в Test IT
Рис. 4. Расчет процента стабильности в Test IT

Однако нам этого способа расчета стабильности в Test IT оказалось недостаточно.

Рис 5. Пример расчета стабильности в Test IT
Рис 5. Пример расчета стабильности в Test IT

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

  • более точно находить нестабильные автотесты;

  • исследовать причины того, почему они постоянно падают;

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

Формула подсчета процента успешности автотеста для отслеживания стабильности:

calc\_stab = passed\_count/( passed\_count + failed\_count) * 100\%, где

passed_count — количество успешных прогонов автотеста,

failed_count — количество провальных прогонов автотеста.

Этот подход к отслеживанию стабильности мы реализовали с помощью telegram-бота.

Рис. 6. Меню стабильности автотестов в telegram-боте
Рис. 6. Меню стабильности автотестов в 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).

Рис. 7. Меню администратора расчета стабильности в telegram-боте
Рис. 7. Меню администратора расчета стабильности в telegram-боте

Действия по расчету стабильности:

  1. Выгрузка всех автотестов в БД из Test IT.

  2. Выгрузка всех test-runs (прогоны) для каждого автотеста в БД из Test IT (POST/api/v2/autotests/{id_}/testResults/search).

  3. Расчет стабильности автотестов (процент успешности), используя БД в качестве быстрого поиска и выгрузки требуемых данных.

Выгрузка из 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).

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

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