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

В этой статье рассмотрим, как масштабировать UI‑автотесты с помощью Java.

Если у вас десяток тестов, проблем не возникнет, они бегают шустро и всем довольны. Но представьте абстрактный проект интернет‑банка или маркетплейса, функциональность растёт, количество автотестов идёт на сотни (а то и тысячи). Последовательный запуск такой тестовой свиты может занять довольно большое время. Каждый релиз начинает тормозиться ожиданием результатов автотестов.

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

Параллельный запуск

Первое, с чего начинается ускорение, включаем параллельный запуск. Идея простая: вместо последовательного выполнения прогоняем несколько тестов одновременно, в разных потоках или на разных машинах. В Java‑автотестах это можно настроить разными способами. Если используете JUnit 5, достаточно прописать настройки в junit-platform.properties или pom.xml, например, включить многопоточность и задать количество потоков. Ниже небольшой фрагмент для JUnit 5, который включает параллельное выполнение тестов:

// Фрагмент pom.xml для JUnit 5
<properties>
    <configurationParameters>
        junit.jupiter.execution.parallel.enabled = true
        junit.jupiter.execution.parallel.mode.default = concurrent
        junit.jupiter.execution.parallel.config.strategy = dynamic
    </configurationParameters>
</properties>

Конфиг говорит JUnit запускать тесты параллельно и автоматически подстраивать число потоков под ресурсы системы. В результате, если у нас, скажем, 8 ядер на агенте CI, JUnit может гнать 8 тестов одновременно. Альтернатива тестовый фреймворк TestNG. В нём параллельность включается либо через аннотацию в коде, либо через XML‑сьюту (параметр parallel + thread-count). Суть одинакова: мы заставляем тесты работать параллельно, экономя время.

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

Инфраструктура: Selenium Grid, Selenoid и куча браузеров

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

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

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

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

Настройка Selenoid не такая уж страшная, как кажется. Достаточно docker‑compose файла, где описаны сервис Selenoid и опционально веб‑интерфейс Selenoid UI. Последний, кстати, весьма удобен, показывает текущие запущенные сессии, позволяет подключиться через VNC и посмотреть, что происходит на экране браузера в реальном времени.

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

Как выглядит запуск теста на Selenoid? Приведу небольшой пример кода с использованием Selenide, обёртки над Selenium. Настроим Selenide на удалённый запуск и откроем браузер:

import com.codeborne.selenide.Configuration;
import static com.codeborne.selenide.Selenide.*;

@BeforeAll
public static void setup() {
    // Указываем удалённый Selenium/Selenoid сервер
    Configuration.remote = "http://localhost:4444/wd/hub";
    Configuration.browser = "chrome";
    Configuration.browserVersion = "112.0";
    Configuration.browserSize = "1920x1080";
}

@Test
void testLogin() {
    open("https://myapp.example/login");
    $("#email").setValue("user@example.com");
    $("#password").setValue("pa$$w0rd");
    $("#login-button").click();
    $("h1.dashboard").shouldHave(text("Welcome, user"));
}

setup() мы направили Selenide на удалённый адрес (где крутится Selenoid или Selenium Grid) и указали желаемый браузер. Каждый такой тест пойдёт на Selenoid, получит свой контейнер с Chrome 112, отработает и завершится. Параллельно может бежать хоть десяток таких тестов — Selenoid запустит 10 контейнеров Chrome. В самом тесте код тривиальный, открыть страницу логина, ввести данные, кликнуть и проверить результат. Selenide сам найдёт элементы по #id, подождёт появления текста и тому подобное, что избавляет от рутины ожиданий.

Если не хочется использовать Selenoid, альтернативой может быть облачный сервис (BrowserStack, Sauce Labs и прочие). Принцип тот же, вы шлёте тесты в облако, где за вас крутятся сотни браузеров. Но за удобство придётся платить, да и задержки сети иногда мешают.

Стоит отметить, что при параллельном запуске важно не перегрузить систему. Запуск 100 браузеров на одной машине с 8 ГБ RAM гиблое дело. Можно эмпирически подобрать оптимальное количество потоков на один агент CI, чтобы тесты шли максимально быстро, но без падений из‑за нехватки памяти или CPU. Иногда лучше распределить тесты по нескольким машинам вместо того, чтобы гнать всё на одном железе.

Архитектура тестов

Параллельно гонять тесты — полбеды. Если сами тесты написаны кое как, увеличить их количество с 10 до 100 значит утонуть в поддержке. Поэтому правильная архитектура автотестов — залог масштабируемости. Главный помощник здесь — паттерн Page Object. С первого дня автоматизации стоит организовать код так, чтобы каждый экран или страница приложения — это отдельный класс с методами для работы с UI. Например, класс LoginPage должен хранить локаторы полей ввода и кнопку логина, а также метод login(email, pass), ко��орый инкапсулирует шаги ввода и клика. Благодаря этому любое изменение UI правится в одном месте.

Приведу упрощённый пример Page Object на базе Selenide для страницы логина и теста, который её использует:

public class LoginPage {
    // Локаторы (SelenideElement автоматически находит элемент при обращении)
    private SelenideElement emailInput = $("#loginEmail");
    private SelenideElement passwordInput = $("#loginPassword");
    private SelenideElement loginButton = $("#authButton");

    // Действие "войти в систему"
    public void loginAs(String email, String password) {
        emailInput.setValue(email);
        passwordInput.setValue(password);
        loginButton.click();
    }
}

// Использование в тесте
@Test
void profileShouldBeAccessibleAfterLogin() {
    // Открываем страницу и сразу получаем объект страницы
    LoginPage loginPage = open("https://myapp.example/login", LoginPage.class);
    loginPage.loginAs("user@test.io", "topsecret");
    // Проверяем, что переход на профиль состоялся
    $("h1.profile").shouldHave(text("Профиль пользователя"));
}

LoginPage инкапсулирует логику работы со страницей входа. Тесту не важно, куда кликать и что вводить, он вызывает метод loginAs, и вся магия происходит под капотом. Если разработчики решат переименовать #authButton в #submitLogin, тестам всё равно — мы поменяем это один раз в LoginPage. Без Page Object через пару месяцев проект автотестов превращается в запутанный клубок скриптов.

Более крутые команды идут дальше и бьют страницы на мелкие компоненты, это вариация паттерна, иногда её называют Page Element. Смысл в том, чтобы переиспользовать части страниц. Например, если у нас на многих страницах есть таблица с данными или меню навигации, разумно вынести их в отдельные классы‑компоненты и включать в Page Object страниц. Это даёт ещё больше модульности. Но в целом для начала Page Object более чем достаточно.

Тестовые данные

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

Например, многие подготовительные шаги выносим в API, через REST‑запрос создаём в системе нужный объект, вместо того чтобы накликивать его через браузер. Если нужно протестировать, скажем, редактирование пользователя через UI, сначала одним API‑вызовом создадим этого пользователя, а уж потом в браузере зайдём и отредактируем.

Другой тест, который зависит от наличия такого пользователя, вообще может сразу воспользоваться API, раз уж UI‑функциональность создания проверена отдельно.

Конечно, API есть не всегда, и тогда приходится готовить данные через интерфейс. Здесь помогает комбинировать: можно написать отдельный утилитный класс, который один раз создаёт все нужные данные (например, заводит десяток тестовых пользователей) через UI или SQL‑скрипт, а затем тесты будут ими пользоваться. Главное не плодить сотни однотипных действий в каждом тесте.

Ещё момент: параллельные тесты не должны драться за одни и те же данные. Если два потока пытаются редактировать одного и того же пользователя, получим конфликты и ложные падения. Поэтому мы заранее продумываем изолированные тестовые данные. Либо каждый тест работает под своим тестовым пользователем, либо разделяем сущности по префиксам, либо вообще генерируем уникальные данные на ходу.

Flaky-тесты

Чем больше тестов и выше параллельность, тем чаще вы встретитесь с так называемыми flaky tests. Это тесты‑мигалки, которые то падают, то проходят при повторном запуске, без каких‑либо изменений в коде. От таких нестабильных тестов страдает доверие к автотестам, если suite горит красным, но все привыкли, что а, там пара флейков, можно игнорировать, толку мало. Нужно объявлять flaky‑тестам войну. Если тест ведёт се��я нестабильно, надо разобраться, проблема в самом тестовом коде (не дождались элемента, некорректно очистили данные и так далее) или всё‑таки ловим баг в продукте. UI‑автотесты по природе своей сложнее сделать устойчивыми, слишком много зависимостей — сеть, браузер, асинхронные загрузки. Но это не повод махнуть рукой.

flaky — сигнал к действию. Упало без причины? Идём и делаем тест устойчивее: добавляем умное ожидание, разбиваем длинный сценарий на несколько, либо улучшаем логи и отладочную информацию. Лишь в крайнем случае можно применить хитрость, автоматически перезапускать упавший тест. Например, интегрировать в CI скрипт, который при падении теста даст ему ещё 2–3 попытки. Иногда такой ретрай помогает сгладить помехи среды. В Gradle/JUnit это решается парой строк.

Кстати, о причинах. Часто флейки лезут из‑за нехватки ожиданий в UI‑тесте. Простой пример: тест кликает кнопку «Сохранить» и сразу пытается найти всплывшее сообщение «Сохранено».

Отчётность

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

Если параллельный запуск распределён по разным машинам, Allure результаты все равно можно собрать воедино. Достаточно сконфигурировать CI, чтобы он сохранял результаты тестов (файлы allure-results) со всех агентов, а потом прогнать команду генерации отчёта по общей папке. Аналоги Allure тоже существуют (например, ReportPortal), но Allure по опыту самый простой в внедрении и покрывает 99% потребностей в анализе автотестов.


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

Всё, о чём шла речь — параллельный запуск, Selenoid, архитектура и отчётность — уже входит в рабочий минимум для Java-автоматизатора. На Java QA Engineer. Professional эти вещи собирают в стройный инженерный подход: от UI/API-автотестов и многопоточности до паттернов, Cucumber и CI на Jenkins/Docker, чтобы вы умели не только писать тесты, но и строить масштабируемую тестовую инфраструктуру.

Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.

Если хотите понять формат обучения — записывайтесь на бесплатные демо-уроки от преподавателей курса:

  • 4 декабря: UI и API тестирование с java и playwright. Записаться

  • 11 декабря: Docker и docker-compose и их использование в автотестировании. Записаться