Привет, Хабр! Меня зовут Сергей Радченко, и мы с командой профессионально занимаемся тестированием уже несколько лет. Сегодня я посчитал количество автотестов, которые мы подготовили для веб-интерфейсов, десктопных приложений, API, систем двухфакторной авторизации и так далее (их оказалось более 5000). И мне захотелось рассказать о нашем опыте создания экосистемы для автоматизированного тестирования. В этом посте вы найдете описание полезных для комплексного тестирования фреймворков, а также исходный код некоторых дополнительных методов, которые мы дописали самостоятельно, чтобы написание тестов происходило быстрее, и тестирование приносило больше пользы. 

Иногда кажется, что тесты — это очень просто. Нередко в инструментах инфраструктурного мониторинга предусмотрена возможность отслеживания доступности веб ресурсов по простым http-запросам: ответ пришел — значит сервис доступен, нет ответа или код ответа отличается от "200" — значит недоступен. Но таким способом не получается проверить что-то более специфичное, например, корректность работы системы авторизации или обнаружить проблемы с синхронной подгрузкой данных, ну и удостовериться, что все бизнес услуги предоставляются пользователям корректно. 

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

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

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

Тесты проще писать на Python. Это факт

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

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

Быстрый старт

Чтобы быстрее начинать работу по проекту, мы создали один базовый класс Python со всеми необходимыми методами и постоянно дополняем его новыми. Далее для тестирования конкретного сайта создаем классы-наследники. В них выносим общие данные, элементы и метод. Благодаря этому приступить к началу тестирования получается достаточно быстро.

Простота входа

Обучить человека базовым навыкам программирования на Python несложно. Существует масса курсов и обучающих программ. Но в дополнение к ним мы также написали инструкции по развертыванию окружения и структуры проекта тестов. Как показала практика, с помощью всего этого багажа знаний даже незнакомые с программированием и автоматизацией сотрудники могут приступить к выполнению боевых задач в первый же день своего общения с тестовым окружением. А писать новые тесты они начинают уже на первой неделе.

Работа с разными окружениями

Учитывая, что тестированием мы занимаемся давно, у нас есть старый код, который написан на Python 2.7. Основная часть тестов была подготовлена для версии Python 3. Все они должны работать вместе на одной рабочей машине автоматизатора тестирования. Виртуальные окружения Python позволяют развернуть готовое окружения за несколько минут с использованием нескольких команд в консоли. Также несколько минут уходит на установку всех необходимых пакетов ещё одной консольной командой. 

Браузерные тесты удобно запускать через Selenium

Когда речь идет о фреймворке для браузерных тестов, мы делаем выбор в пользу Selenium.  Несколько лет назад, это был единственный крупный проект для автоматизации тестирования через web интерфейс. Сейчас уже появились новые решения, но зато экосистема Selenium остается одной из самых зрелых. 

Исторически Selenium использует свой закрытый протокол для взаимодействия с браузером: запросы упаковываются в JSON и передаются через http. Но сейчас же во все основные браузеры внедрен протокол, позволяющий обращаться к ним напрямую: Remote Debugging Protocol для Firefox, Chrome DevTools Protocol для chromium. С помощью этих протоколов можно управлять браузерами на более низком уровне, например, отслеживать запросы. Эти возможности, должны появиться в Selenium версии 4, которая сейчас находится в разработке (очень ждём…). 

Например, библиотека Selenium для Python включает в себя класс ActionChains. Он позволяет создавать цепочки действий с клавиатурой и мышью. Например, вы можете передвигать курсор на элемент разворачивающий список или меняющий свое состояние при наведении. Также можно имитировать нажатия на клавиши, что иногда требуется в некоторых поля ввода, когда стандартный метод send_keys из Selenium не работает.

Изначально за основу для разработки тестов был взят wrapper (https://pypi.org/project/seleniumwrapper/). Мы предполагали, что его методов будет достаточно для написания любых сценариев. Но в процессе работы возможностей wrapper стало не хватать: во многих местах появилось дублирование кода, было много провалов, которые приходилось обходить костылями. Для решения этих проблем мы начали добавлять свои методы такие как: 

  • переключение между вкладками,

  • ожидание исчезновения элемента со страницы, 

  • имитация нажатий клавиш и использование ActionChains, 

  • ожидание скачанного файла

  • и так далее

Вот некоторые примеры этих методов в коде:

class Page(SeleniumWrapper):
    def switch_to_window(self, title_name, close_others=False):
        windows = self.window_handles
        windows_title = {}
        for window in windows:
            self.switch_to.window(window)
            windows_title[self.title] = window

        for window in windows_title:
            if re.search(title_name, window):
                self.switch_to.window(windows_title[window])
                break

        if close_others:
            self.close_all_other_windows()

    def get_current_window_index(self):
        return self.window_handles.index(self.current_window_handle)

    def switch_to_next_window(self):
        current_index = self.get_current_window_index()
        self.switch_to.window(self.window_handles[current_index + 1])

    def switch_to_previous_window(self):
        current_index = self.get_current_window_index()
        self.switch_to.window(self.window_handles[current_index - 1])

    def close_all_other_windows(self):
        windows = self.window_handles
        current_window = self.current_window_handle
        for window in windows:
            if window != current_window:
                self.switch_to.window(window)
                self.close()
        self.switch_to.window(current_window)

    def is_xpath_present(self, xpath, eager=False, timeout=1, presleep=0):
        time.sleep(presleep)
        self.silent = True
        elements = self.xpath(xpath, eager=eager, timeout=timeout)
        self.silent = False
        if eager:
            return elements

        if not elements:
            return []

        if elements.is_displayed():
            return True

        return []

   @_wait_until
    def wait_for_not_displayed(self, xpath):мента.
        """
        assert not bool(self.is_xpath_present(xpath)), f'Элемент {xpath} не исчез'
        return True

   def press_keys(self, keys):
        """
        Ввод символов нажатиями на клавиши
        :param keys: Строка символов для ввода
        """
        for key in keys:
            ActionChains(self).key_down(key).key_up(key).perform()
            time.sleep(0.01)

    def press_backspaces(self, count=20):
        keys = [Keys.BACKSPACE for _ in range(count)]
        self.press_keys(keys)

    def get_downloaded_file_names(self):
        if settings.run_local:
            return []

        response = requests.get(self.download_url)
        result = re.findall(r'>(.*?)</a>', response.text)
        print(f'Downloaded response: {response.text}')
        print(f'Downloaded result: {result}')
        return result

    @_wait_until
    def wait_for_downloaded_file(self, filename):
        assert not settings.run_local, 'Can not check downloaded file on local running'
        assert filename in self.get_downloaded_file_names(), f"Файл {filename} не скачан"
        return True

    @_wait_until
    def wait_for_downloaded_file_by_substring(self, filename_part):
        assert not settings.run_local, 'Can not check downloaded file on local running'
        matches = [filename for filename in self.get_downloaded_file_names() if filename_part in filename]
        assert len(matches) > 0, f'Нет файла содержащего `{filename_part}` в названии'
        return True

К тому же в базовой библиотеке есть только основные методы, которые плохо работают с динамическими страницами. Часто нужно применять интеллектуальные ожидания для элементов страницы: видимости, изменения атрибутов, изменения текста, внутреннего id элемента, доступности для взаимодействия и так далее.

Это очень важно в реальных тестах. Например, чтобы начать вводить в поле ввода данные, нужно сперва дождаться появления элемента на странице, затем проверить его видимость, доступность для ввода, и только после этого начать вводить данные. Если использовать чистый Selenium, то придется в каждом тесте прописывать эти действия, удобнее вынести это всё в отдельный метод и просто его использовать. 

А вот и исходный код метода:

  def send_keys(self, xpath, value, name="", timeout=None, wait_interactable=True, postsleep=0):
    	timeout = timeout or self.timeout
    	element = self.xpath(xpath, name, timeout=timeout)
    	if wait_interactable:
        	    self.can_click(xpath, timeout=timeout)
    	try:
        	    element.send_keys(value)
    	except WebDriverException:
        	    message = f"В поле ввода `{name or xpath}`не удалось ввести значение `{value}` за `{timeout}` секунд"
        	    raise WebDriverException(message)
    	time.sleep(postsleep)

   def click(self, xpath_or_dict="", name="", timeout=None, presleep=0, postsleep=0, ignored_exceptions=None):
        xpath, name = Page.get_xpath_and_name(xpath_or_dict, name)
        timeout = timeout or self.timeout
        ignored_exceptions = ignored_exceptions or tuple()

        # Сначала ждем появления элемента
        element = self.xpath(xpath_or_dict, timeout=timeout)

        time.sleep(presleep)

        errors = list()
        end_time = time.time() + timeout
        while time.time() < end_time:
            try:
                element.click(timeout=timeout / 10)
                break
            except ignored_exceptions:
                continue
            except InvalidSessionIdException:
                message = "Потеряно соединение с браузером. Возможно браузер был закрыт или аварийно завершил работу"
                raise InvalidSessionIdException(message)
            except StaleElementReferenceException as e:
                element = self.xpath(xpath_or_dict, timeout=timeout)
                errors.append(e)
                continue
            except WebDriverException as e:
                errors.append(e)
                continue
        else:
            if errors:
                message = f"Не удалось выполнить клик по элементу `{name or xpath}` в течение {timeout} секунд(ы)\n{errors}"
                raise TimeoutException(message)
        time.sleep(postsleep)

Кстати, на Хабре уже есть статьи про Selenium. И если вы хотите почитать про этот фреймворк подробнее, можно посмотреть материалы по этим ссылкам: (https://habr.com/ru/post/248559/  https://habr.com/ru/post/329256/  https://habr.com/ru/post/208638/  https://habr.com/ru/post/152653/  https://habr.com/ru/post/327184/  https://habr.com/ru/post/322742/)

Распознавание изображений с PyAutoGUI

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

С одной стороны, чтобы прогнать тест, нужно всего лишь сделать скриншот и использовать его. Но с другой стороны изменение шрифта, размеров элементов требует новых и новых скриншотов. 

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

class RecognitionClient(abc.ABC):
    def __init__(self, project_dir):
        self.project_dir = Path(project_dir)
        self.process = None
        self.wait_time = 25   

    def _find_image_path(self, img_file):
        parent_img_path = self.project_dir.parent.joinpath('img', img_file)
        if parent_img_path.is_file():
            return str(parent_img_path)

        img_path = self.project_dir.joinpath('img', img_file)
        if img_path.is_file():
            return str(img_path)

        return ImageNotRecognized(f'Not found image file: {img_path}')

   @sleeper
    def find_one_of(self, *imgs, wait_time=None, confidence=0.9, raise_errors=True):
        """
        Поиск координат одного скрина из списка.
        """
        wait_time = wait_time or self.wait_time
        timeout = time.time() + wait_time
        while time.time() < timeout:
            for img in imgs:
                position = self.find_screen(img, wait_time=0.1, raise_errors=False, confidence=confidence)
                if position:
                    return position
        else:
            if raise_errors:
                for img in imgs:
                    img_path = self._find_image_path(img)
                    with open(img_path, "rb") as image_file:
                        img_obj = image_file.read()
                        allure.attach(img, img_obj, type=AttachmentType.PNG)
                raise ImageNotRecognized(f"Images not recognized: {', '.join(map(str, imgs))}")

Запуск тестов с PyTest

PyTest — действительно мощный фреймворк для тестирования. Он имеет массу возможностей и расширений в виде пакетов. С его помощью можно реализовать автоматический перезапуск тестов, визуализацию отчетов и многое другое.

Для нас PyTest — это способ избежать необходимости писать кучу однотипного кода для каждого теста, потому что обычно дополнительный код в этой экосистеме или полностью отсутствует, или связан с объявлением новой фикстуры.

К тому же в PyTest есть несколько дополнительных пакетов, которые расширяют его функциональность. Мы используем Allure и Rerunfailures.

Пакет allure проводит сбор результатов тестов для последующей генерации отчетов. Он позволяет получать данные о прохождении тестов без глубоких модификаций кода. Из полученной информации можно сгенерировать отчет с понятной структурой, историей запуска тестов и перезапусков при провалах.

пример — скриншот отчета

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

Первым решением было оборачивание кода внутри теста в блок try. В итоге это привело к увеличению количества строк кода теста и повсеместному дублированию. Тогда мы решили сделать свой класс наследник StepContext. Сначала решение показалось удобным, но внутри класса получилось много лишних строк и условных блоков. На текущий момент мы перешли к варианту использования декоратора contextmanager из библиотеки contextlib и реализовали обращение к глобальному объекту браузера. А поскольку декоратор преобразует генератор в дескриптор его можно использовать в блоке with.

Описание метода

@contextmanager
def allure_step(name, wait_time: int = None):
    from web.register import pages

    with allure.step(name):
        try:
            if wait_time:
                pages.browser.wait_time = wait_time
        	yield
        	pages.browser.wait_time = settings.wait_time
        	make_screen('screen')
        except Exception:
            make_screen('error')
        	raise

Применение метода

from utils.allure import allure_step

def test_1(pages):

    with allure_step('Шаг 1. Загрузка главной страницы', 50):
        pages.main_page.open()

Кроме этого мы используем библиотеку Rerunfailures, которая обеспечивает удобный перезапуск тестов при провале. Этот набор методов позволяет настраивать количество перезапусков, интервал ожидания перед перезапуском после провала. В общем — полезная штука для тестировщика, у которого действительно много работы.

Изоляция тестов с Selenoid

Тесты необходимо запускать изолированно друг от друга, чтобы результат выполнения предыдущего никак не влиял на следующий. Тесты надо прогонять на разных браузерах различных версий. И для этого идеально подходит Selenoid в связке с Selenoid GGR и Selenoid UI.

Когда у нас идут большие проекты тестирования, на одной машине мы разворачиваем Selenoid GGR, а на нескольких других — ноды. Все запросы изначально отправляются в GGR, который создает сессию на свободной ноде, и мы можем использовать её для тестирования. 

Для каждого теста поднимается свой контейнер с нужной версией браузера, а после завершения теста, контейнер гасится. Когда возникает необходимость посмотреть, как работает тест или проверить работу ресурса через браузер на сервере, можно использовать Selenoid UI, создать сессию и работать с окружением через VNC как с обычным браузером. 

Запуск тестов через Jenkins

Вообще Jenkins создавался (и используется) для CI/CD. Но мы применяем его как систему для запуска тестов, используя те же плюсы, которые эта платформа дает при деплое приложений. 

Для каждого теста или группы тестов создается отдельный проект. В его настройках мы прописываем периодичность запуска тестов, выбираем необходимые параметры через переменные окружения и создаем скрипт запуска теста. Скрипт определяет, на каком окружении и какой тест нужно запускать. После этого Jenkins прогоняет тест,  специальный скрипт собирает метрики и отправляет их в систему мониторинга.

На стенде, где установлен Jenkins, может быть создано несколько окружений Python и размещены разные репозитории с тестами. Тесты обычно берутся с локального диска стенда, а не из Git, потому что одновременно работают десятки проектов, и каждый раз делать Pull оказывается накладно с точки зрения ресурсов. Впрочем, сами тесты могут не меняться месяцами, а иногда и годами, так что это не очень страшно. )

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

Создание проектов мы автоматизировали через отдельный скрипт. На основе шаблона скрипт создает проект почти со всеми необходимыми настройками, чтобы свести к минимуму ввод дополнительных параметров. 

Заключение

Надеюсь, эта подборка тестовых фреймворков и методов оказалась полезной. В следующем посте я расскажу о практике написания некоторых тестов на Python. А если у вас есть другой опыт работы с тестовыми окружениями, пожалуйста, поделитесь им в комментариях. 

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


  1. Gummio_7
    21.09.2021 09:53
    +1

    А зачем вам так много тестов? Вы для своих разработок это делаете или для кого-то?


    1. RadST Автор
      21.09.2021 11:09
      +1

      Спасибо за вопрос. Совсем немного пишем для себя, решаем некоторые задачки по автоматизации, по мониторингу, в компании ООО "Хоппер ИТ". Основная масса, конечно, для Заказчиков. Применение автотестов для мониторинга - очень востребовано. У многих Заказчиков есть свои команды, которые пишут тесты, мы им помогаем, проводим аудит, показываем лучшие практики, но где-то мы с нуля организовываем автоматизированное тестирование.


  1. barbadian
    21.09.2021 10:06
    +1

    Как у вас реализована архитектура тестового фреймворка? Как в тестах используются его возможности?


    1. RadST Автор
      21.09.2021 11:05
      +1

      Архитектура основана на принципе Page Object. Разработчик сначала описывает ключевые элементы страниц через специальные классы, а затем закидывает их в класс-страницу, где можно реализовать необходимые методы для работы со страницей.

      Такой подход позволяет гибко управлять тестами, легко их поддерживать и быстро изменять логику методов, если на странице что-то поменялось.


      1. barbadian
        21.09.2021 13:32

        Нет, я имел в виду схему распределения объектов/методов по папкам/пакетам для разных автотестов - веба, api, 2F и т.д. И как в тестах к ним обращаетесь - через импорт нужных методов в каждом тесте, через инстанс класса фреймворка в фикстуре pytest или как-то еще?


        1. RadST Автор
          21.09.2021 15:14

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


  1. barbadian
    21.09.2021 13:31

    del


  1. kentastik
    22.09.2021 11:00
    +1

    Playwright пробовали?


    1. RadST Автор
      22.09.2021 12:32

      Пробовали. В планах на него перейти.