Spoiler: речь пойдет в том числе и о параллелизации.

Итак, что у нас есть: куча автотестов, написанных на C#, Selenium и xUnit. Наша задача — уменьшить время их выполнения.
Если конкретнее, на одном из проектов есть автотесты, которые писались на протяжении пары лет одним из тестировщиков. Сейчас время выполнения всех тестов превышает 16 часов, так что они практически не используются.
Однажды мне предложили попробовать написать автотест для этого проекта, чтобы понять, будет ли мой подход эффективнее. Я использовал базовый Page Object Pattern и немного модифицированные (ради стабильности) методы Selenium. В результате получился выигрыш в ~10–15%. То есть смысла переписывать тесты особо не было.
Это стало отправной точкой. С тех пор прошло около 10 месяцев: я сменил несколько проектов, и на последнем количество тестов перевалило за сотню, а время выполнения превысило час. В целом ничего критичного — приходишь утром, запускаешь тесты и… спокойно идешь делать кофе или чай. Возвращаешься, проверяешь статусы задач, почту, составляешь план на день и т. д.
Круто? Да. Вот только при росте количества тестов очевидно вырастет и время выполнения. Ещё пара сотен тестов — и я буду успевать сгонять за кофе на другой конец города и обратно. Так что нужно искать решение.

Прежде чем приступать к решению проблемы, было бы неплохо её четко сформулировать.
Итак, проблема:
Тесты выполняются долго, и при увеличении их количества растёт общее время выполнения. Это мешает программистам, тестировщикам и аналитикам вовремя получать информацию о стабильности текущей ветки. То есть проблема не в самом времени выполнения тестов, а в задержке получения информации заинтересованными сторонами.
Что это нам даёт?
Например, классическое решение — вынести тесты на отдельный стенд и запускать их по ночам, чтобы каждое утро получать результаты по текущей ветке. Стандартный подход, но в моём случае планируется контейнеризация тестов и их дальнейшее использование в CI/CD-процессах в течение рабочего дня, так что этот вариант не подходит.
Подход: «Ты прошлый — всегда глупее, чем ты настоящий»
Не долго думая, я решил воспользоваться тактикой проверки старого кода. Так что я полез разбираться с классами, отвечающими за работу с Selenium, — теми, что когда-то писал сам или брал у коллег.

Чуть больше подробностей:
Я практически не использую фикстуры. Также у меня есть основной класс Browser, настраиваемый через appsettings, который создаётся в тесте и управляет отдельным окном браузера. По сути, это обёртка для стандартного driver, в которой определены стабильные методы поиска и взаимодействия с веб-элементами в наших проектах. Изначально он достался мне «по наследству», и я таскал его из проекта в проект, постоянно улучшая.
Первым делом я добавил в него логирование времени, чтобы каждое ключевое действие (например, клик) фиксировалось. Прогнав тесты несколько раз, я получил результаты: какие методы используются чаще всего и сколько времени они занимают. Дальше — как в плохих полицейских сериалах: ходишь между «подозреваемыми» и пытаешься выяснить, кто съел лишнее время.
И вот вам пример такого «вредителя»: 500 мс просто так (почему и зачем — остаётся загадкой).

Заодно нашел не использующиеся методы. Короче там поправил, тут оптимизировал, ещё были моменты с эмпирическим выставлением задержек, как например тут:

Зачем он там нужен? Видимо разделяет задержкой клики. Пытался выяснить, почему 300 мс, а не, скажем, 150, но так и не понял. Затем просто уменьшил задержки в нескольких местах, прогнал тесты пару раз — всё работает.
Суммарный выигрыш от оптимизации составил ~25%. Неплохо: на кофе осталось 40 минут, но до идеала ещё далеко. В целом, было полезно пересмотреть старые методы, которые так или иначе используются в работе, хотя после этого появилось дикое желание всё переписать.
Пока вносил правки, отметил ещё несколько мест, где можно выиграть время, но оставил их на потом. Например, код, который проверяет отсутствие элемента на странице, — это тот же самый код, что ищет его присутствие, просто обёрнутый в try-catch
. Из-за этого он долго думает, прежде чем вернуть отрицательный результат.

Параллельность (Часть 1)
Одно из самых очевидных решений для ускорения — параллельный запуск. Для выполнения тестов использовался xUnit, в котором есть встроенные инструменты параллелизации. Казалось бы, всё просто:
Итоговое время = (Суммарное время тестов) / (Количество потоков).
Добавил в файл xunit.runner.json настройки параллельности, указал 8 потоков и… Запустилось 8 окон браузера, но все действия выполнялись только в одном.
Как оказалось, проблема была в групповой политике Chrome UserDataDir, установленной администраторами. Она мешала работе ChromeDriver, не позволяя создавать более одной сессии DevTools, необходимых для параллельного запуска тестов. После отмены этой политики окна начали работать корректно.
И вот, наконец, долгожданный запуск... и половина тестов упала. Большинство ошибок было связано с тем, что Selenium терял окна, в которые нужно было кликать. Процесс останавливался с ошибками, сообщая о потере фрейма или веб-элемента. Что удивительно, подобные ошибки возникали и при однопоточном запуске, но значительно реже. Кроме того, наблюдалась почти прямая зависимость между количеством потоков и частотой ложных срабатываний.
Начались недели экспериментов. В свободное время каждый тестировщик пытался исправить проблемы с параллельностью в своих тестах. Однако результатов не было: на личных ПК всё работало, а в корпоративной среде и на тестовых стендах постоянно возникали ошибки. Мы перепробовали различные настройки и библиотеки. Частично ситуацию улучшил Xunit.Priority, хотя изначально я добавлял его с совсем другой целью. Но на тот момент стабильную параллельность реализовать не удалось.

Асинхронность
Что мне не нравится в асинхронности C# — стоит ей появиться в одном месте, и она каскадом распространяется по всему коду. Если хотя бы один метод в тесте становится асинхронным, приходится переделывать всё остальное. Однако у этого подхода есть и неоспоримые преимущества.
Я провёл эксперимент: написал тест по PageObject Pattern с асинхронными классами элементов и такой же тест, но в стандартном последовательном варианте. Ожидал примерно одинакового результата, поскольку действия теста в любом случае выполняются последовательно. Казалось, разница должна быть только в использовании await
, но оказалось, что асинхронная версия работает в 1,5–2 раза быстрее. Основной выигрыш был в проверках состояний элементов (доступность, кликабельность). Кроме того, ожидания Selenium хорошо сочетаются с асинхронностью.
Я переписал все основные элементы (кнопки, поля и т. д.) на асинхронные версии, стараясь минимально затронуть существующие тесты. В результате время выполнения сократилось до 20 минут — вдвое быстрее исходного варианта.
Честно говоря, до конца не понимаю, почему разница такая значительная. Некоторые тесты, которые раньше выполнялись за 48 секунд, после перехода на асинхронные элементы стали занимать всего 17 секунд. Я пытался анализировать логи выполнения, но пришёл к простому выводу: оно просто работает быстрее.
На кофе осталось 20 минут, это нормально, если бы я на этом остановился, то мог бы каждый день запускать тесты и успевать выпить свой спасительный напиток пока они ходят по тестовому стенду. Но это не так..
Параллельность (Часть 2)
После перевода большей части тестовых объектов на асинхронные версии проблема с параллельным запуском решилась. Selenium перестал терять фреймы с нужными элементами. Видимо, в нашем случае параллельность xUnit и асинхронность C# должны работать в связке.
С помощью assembly-атрибутов xUnit я настроил выполнение тестов в нужном количестве потоков. Также, если вы делаете параллельные тесты, не забудьте разделить их на группы и настроить стратегию параллельного выполнения. Например, я оставил последовательное выполнение тестов внутри одного класса, но пометил атрибутами, какие тесты можно запускать параллельно, а какие — нет.
Зачем это нужно?
Чтобы избежать ложных срабатываний, если тесты зависят от общих данных или базы.
Чтобы упростить запуск отдельных категорий тестов.
Я также немного доработал систему: если в последней "волне" выполнялся класс с 7 долгими тестами, это могло увеличить общее время на пару минут, поэтому я распределил их по разным классам. Так же пометил атрибутами тесты, которые параллельно нельзя запускать. Например, если тест чистит или инициирует чувствительные данные в базе.
Итог:
Тесты запускаются в 6–8 потоков
Общее время выполнения — ~3 минуты
Всё стабильно работает даже в Headless-режиме (Chrome и Edge)
Хэппи-энд. Для тестов, но не для меня. Какой-нибудь спидранер наверняка успеет выпить кофейка за 3 минуты, но я так не умею.
Заключение
Если вы используете C# + Selenium + xUnit/nUnit/jUnit, то параллельность (как на уровне тестов, так и методов) может значительно ускорить выполнение. Но применяйте этот подход осторожно — "побочные эффекты" могут сократить время, отведённое на чай, кофе и печеньки.

Комментарии (2)
Email-forge-dev
15.08.2025 09:37Это ты еще не охотился за "сникерсами" на пиндосовских сайтах. Где за пару секунд надо успеть заполнить кучу форм, после появления кроссовок в стоке. Вот там оптимизация рулит, и использование Селениума для запуска блоков JavaScript, вместо навигации и прочей ерести через сам Селениум
AlenaStavrova
Кажется, у вас ошибка в спойлере ("парализация")