С первого взгляда кажется, что для параллельного запуска автотестов достаточно просто нажать кнопку «Вкл.». Однако не всё так просто — различные параллельные действия пользователя приводят к логическим конфликтам: дублирующиеся записи в базах данных, несогласованность одновременно выполняемых операций, невозможность гарантированно воспроизвести некоторые сценарии, и многое другое. 

Меня зовут Марина Скалина, я QA‑автоматизатор в СберТехе, в команде Platform V Kintsugi — это графическая консоль для сопровождения PostgreSQL и всех СУБД, основанных на PostgreSQL (как наша же СУБД Pangolin). Поговорим о том, что стоит учесть, если вашей команде тестирования необходимо перейти на параллельный прогон.

Примечание: здесь и далее в статье я остановлюсь на конкретных примерах на Python, так как автотесты в нашей команде разрабатываются именно на нём. Но все описанные действия можно реализовать и в других стеках, например, уже встроенный в Go t.Parallel() является аналогом pytest‑xdist.

Первое, о чём стоит подумать: а в чём будет заключаться распараллеливание? В своей практике я встречала два подхода.

Масштабирование тестового окружения как единого целого

Рассмотрим этот подход на примере использования Docker‑окружения для тестирования. Для микросервисных приложений удобно использовать docker‑compose: он запускает контейнер для каждого из сервисов, учитывая необходимый порядок их поднятия, порты для открытия и объём для хранения данных. По умолчанию Compose разворачивает приложение под единой сетью, к которой подключается каждый из контейнеров. В таком случае они доступны другим контейнерам в этой сети.

К примеру, если файл compose.yaml будет выглядеть так:

services:
  my_app_ui:
    build: .
    ports:
      - "8000:8000"
  db:
    image: postgres
    ports:
      - "5432:5432" 

...то при выполнении команды docker compose up в рамках одной сети «default» будет поднято два контейнера, которые смогут находить друг друга по именам my_app_ui и db и получать IP‑адреса. Так, код приложения сможет подключиться к базе данных по URL‑адресу postgres://db:5432 и использовать её.

Как должна поменяться команда запуска, чтобы на одном хосте работало несколько копий приложения независимо друг от друга? Для этого нужно всего лишь добавить следующую опцию: --network  — она задаст имя сети, под которой будут находиться контейнеры приложения:

docker compose --network=replica_1 up

...здесь приложение будет запущено под сетью «replica_1».

Если же после этого также выполнить команду:

docker compose --network=replica_2 up

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

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

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

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

Управление потоками при запуске автотестов в рамках единого окружения

В этом случае внедряют опцию для распараллеливания запуска автотестов. Например, при работе с тестами на Python и использованием Pytest удобно внедрить плагин pytest‑xdist, который запускает тесты, распределив их между несколькими рабочими процессами. Для этого необходимо добавить опцию ‑n или ‑numprocesses. Если в качестве значения указать auto, то pytest‑xdist распределит тесты по стольким процессам, сколько физических процессорных ядер есть у компьютера.

К примеру, если использовать команду pytest ‑n 2, то тесты будут запущены в два потока. В нашей команде какое‑то время использовали коэффициент 4, однако новые автотесты так быстро расширялись, что однажды нас перестало устраивать более чем 1,5 часа для прогона тестов интерфейса по ветке, и мы подняли коэффициент до 8 — теперь в большинстве случаев мы стали укладываться в 50 минут, что позволило нам быстрее тестировать новые pull request'ы. Замечу, что хотя максимальное значение параметра ‑n в pytest‑xdist не имеет жёсткого программного ограничения в самом плагине, на практике оно упирается в ресурсы системы: если поставить слишком большое значение, то тесты начнут выполняться медленнее или система зависнет, поэтому важно подобрать оптимальное значение.

Если тесты независимы друг от друга, то на этом можно ставить «Выполнено» в задаче по распараллеливанию. Однако в таком случае бывает сложно разграничить тесты при написании так, чтобы они не вызывали конфликтов из‑за одновременного запуска. Причины могут быть разные, рассмотрим их далее.

При одновременном запуске тесты продолжают пользоваться одной тестовой средой, а значит, одними и теми же базами. В некоторых случаях «лишние» объекты, появившиеся в таблицах баз данных от параллельно идущих тестов, не влияют на результат текущего теста. Однако, например, если вы проверяете количество записей, объектов или уникальные объекты, то это может привести к ошибкам и сбоям тестов. 

Предположим, что в нашем приложении можно задать пользователю email. Впоследствии адрес можно изменить, но добавление второго адреса для того же пользователя не предполагается. У нас есть два теста, один из которых добавляет адрес, а другой изменяет его, предварительно в настройке теста уже добавляя email конкретного пользователя в базу данных (в ней хранятся адреса всех пользователей) и удаляя после завершения теста. При одновременном запуске тестов запрос на добавление почтового адреса может на самом деле не добавить его, а изменить ранее присвоенный, так как для пользователя в базе данных запись с адресом уже имеется (если вдруг тест на изменение начнётся чуть раньше). В таком случае тест окажется некорректным, так как основная его проверка по добавлению не будет выполнена.

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

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

Допустим, наши объекты представляют собой подключения к СУБД на разных хостах и портах. При их создании мы даём им названия. Также допустим, что ранее при последовательном запуске тестов мы использовали объект с названием test_object. Постоянное имя тестового объекта — вообще не самая хорошая практика в тестировании, но особо заметно это становится при распараллеливании. Если в одном из потоков идёт тест на редактирование имени данного объекта, и во время такого теста имя изменится на test_object_changed, то все параллельно идущие тесты при обращении к этому объекту по прежнему имени просто не смогут его найти. Поэтому для каждого пользователя тестовый объект должен иметь уникальное имя.

Или другая ситуация: пусть для каждого объекта мы можем включить или выключить сбор некоторых метрик. Допустим, два теста проверяют, что метрика собирается, если опция включена, и наоборот. Если тесты будут проводиться на одном и том же объекте, то успешным будет только один из них — тот, что настроит сбор последним.

Добавление объектов для каждого пользователя, очевидно, увеличивает их общее количество в приложении в конкретный момент времени. Ведь каждый из пользователей может создавать не один, а те же три, пять и более объектов. Это сказывается на том, как объекты отображаются на странице, в какой-то момент они могут перестать быть «видны» в интерфейсе. В таких случаях могут быть разные ситуации: например, список из объектов можно листать, или же объекты можно просматривать постранично (есть и другие варианты в зависимости от реализации). Внезапно мы можем получить ошибку о том, что автотест не нашёл на странице объект, хотя на самом деле этот объект существует, но чтобы его «увидеть», нужно до него долистать. 

Хороший выход в такой ситуации — использовать поиск по списку или таблице или выбирать отображение бОльшего количества объектов на странице, если такие функции есть в интерфейсе. В ином случае придётся добавлять шаги по «поиску» объекта тем же перелистыванием страниц в таблице. Но стоит помнить, что не всегда они будут нужны, потому что общее количество объектов может меняться от случая к случаю.

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

Давайте отойдём от факторов, которые зависят от перехода к нескольким тестовым пользователям и рассмотрим другую типичную ситуацию. Предположим, что для объектов у нас могут возникать какие‑то уведомления, если, скажем, они вдруг стали нарушать некоторые условия. Например, метрики наших объектов (тех же СУБД на разных хостах и портах) пересекли установленные для них пороги, и мы ожидаем увидеть в интерфейсе счётчик таких уведомлений (по скольким метрикам произошли нарушения?). И, например, описание нарушений (по каким именно метрикам произошло нарушение, в какой момент времени?). Допустим, мы получаем уведомление, если вдруг на каком‑то из хостов сильно выросло потребление памяти, или слишком упала скорость передачи данных. Для каждого их этих случаев у нас есть отдельный тест интерфейса, который проверяет счётчик нарушений и их содержимое (больше или меньше порогового значения). Представим, что если эти два теста запустятся параллельно, то у хоста счётчик нарушений будет показывать «2» вместо «1» в обоих тестах, а также мы увидим два уведомления о нарушении. 

Мы же хотим проверить эти ситуации изолированно друг от друга.

Также в UI‑тестах встречаются действия, связанные с буфером обмена. К примеру, копирование названия объекта или идентификатора пользователя. Что будет, если подобные тесты запустить параллельно? Мы получим ситуацию, когда действие в одном тесте будет перебивать действие с буфером обмена в другом тесте, и, таким образом, перекрывать сохранённую информацию. 

Чтобы не допустить этого, логично предположить, что нам было бы полезно выделить группу таких тестов по нарушениям или связанных с буфером обмена, и запускать их в рамках одного потока, последовательно. Для этого можно обратиться к документации плагина pytest‑xdist и воспользоваться оттуда таким инструментом, как маркер xdist_group. Обозначим все тесты, связанные с нарушениями, следующим образом:

@pytest.mark.xdist_group(name="violations")

Этот маркер можно проставлять как для каждого теста отдельно (например, тесты с использованием буфером обмена могут быть логически не связаны друг с другом и находиться в разных файлах), так, скажем, и для класса тестов, объединённых общей тематикой (те же тесты на нарушения порогов).

К команде запуска тестов добавим опцию ‑dist loadgroup: она укажет на то, что распределять тесты нужно с учётом маркеров xdist_group (подробнее про эту и другие опции распределения можно посмотреть тут).

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

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

Но у xdist есть несколько ограничений, о которых стоит упомянуть.

  1. Стоит быть внимательными при работе с параметризованными тестами — в них при использовании xdist нельзя указывать неупорядоченные переменные вроде set: @pytest.mark.parametrize('param', {'(', '[', '..', ':', ';', '<'}) — при попытке распределения возникнет ошибка «Different tests were collected between gw0 and gw1. The difference is:». Вместо сетов можно использовать списки.

  2. Аналогичная ошибка возникнет, если в качестве параметра передать случайное значение: @pytest.mark.parametrize('param', [random.randint(1,9), random.random()]).

  3. Если в проекте используется библиотека pytest‑order для выстраивания тестов в определённом удобном или необходимом порядке, то при внедрении xdist её использование, скорее всего, придётся пересмотреть. Причина в том, что при использовании pytest‑xdist планирование тестов происходит в неупорядоченном виде, и порядок, заданный с помощью pytest‑order, обычно не сохраняется. Исключение: если при запуске используется параметр ‑dist=loadfile (подробнее — здесь), но для удобного разбиения тестов, напомню, используется ‑dist loadgroup и маркеры xdist_group.

Такое разбиение на группы ещё больше поможет сократить падающие и флакующие тесты.

Однако всё ещё есть риск поймать проблему из‑за распараллеливания. Каким же образом? Пользователи разные, по группам распределили — что ещё может пойти не так? 

Бывают ситуации, когда для тех же сквозных тестов интерфейса требуется поменять стандартную конфигурацию сервиса. В целом над параметрами сервисов для автотестов следует подумать заранее, чтобы минимизировать такие тесты, где параметры всё‑таки придется менять. В идеале, было бы здорово задать параметры так, чтобы совсем избежать перезапусков. Однако в некоторых случаях бывает удобно их подкрутить для некоторых тестов. Примером тому может служить параметр, который отвечает за частоту обновления информации на каком‑нибудь дашборде или виджете. Чтобы в самом тесте не ждать, допустим, пять минут, которые могут быть значением по умолчанию, то логично было бы поменять его на значение меньше — пусть 10 секунд. Здесь бывают разные ситуации: в одном случае новые значения параметров применяются сразу, в других для применения нового значения необходим перезапуск сервиса. И вот тут кроется основная проблема: пока сервис перезапускается для теста в одном из потоков, он не работает, а значит, тесты в других потоках будут падать из‑за его недоступности. 

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

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


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

Спасибо за внимание!

P. S. Вообще, исторически так повелось, что мы в Kintsugi много пишем на Хабре о тестировании. Например, наш лид написал несколько руководств про работу с Chrome DevTools. Если интересно читать руководства для QA, то приходите в наше сообщество. Там мы делимся разным полезным и публикуем вакансии.

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