Всем привет, меня зовут Сергей Прощаев и в этой статье покажу, как убрать форму входа из UI‑автотестов: получить токен по API, положить его в браузер до старта приложения и перестать платить минутами прогона за один и тот же логин.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.


Ситуация знакомая почти каждому, кто трогал e2e. У команды 180 UI‑тестов, и каждый начинается одинаково: открыть /login, ввести логин и пароль, нажать кнопку, дождаться редиректа.

По секундомеру — 6–9 секунд на тест, то есть 18–27 минут за прогон только на авторизацию. Дальше хуже: на форме входа рейт‑лимит в 10 попыток в минуту, и в восемь потоков мы в него упираемся. Потом на препроде включают капчу‑заглушку. Потом приезжает SSO, и логин становится не одной страницей, а тремя доменами подряд. Формально тесты падают «из‑за авторизации» — фактически из‑за сценария, который не относится к предмету проверки.

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

Подготовка состояния занимает 0,4–0,7 секунды вместо 6–9, а вызов /api/auth/login перестаёт быть частью каждого теста: в одном JVM он выполняется не чаще, чем нужно каждой учётке с учётом срока жизни токена. Каждый шаг закрывает один риск, и после каждого написано, как убедиться, что он сработал.

Рис. 1. Логин мимо формы входа: токен получаем по API и отдаём браузеру напрямую
Рис. 1. Логин мимо формы входа: токен получаем по API и отдаём браузеру напрямую

Исходные условия

Чтобы разговор был предметным, зафиксирую стенд. Версии — те, на которых собирались примеры; Selenium с тех пор успел выпустить 4.47.

  • Java 21+, Maven. На Java 25 используемые здесь API принципиально не меняются.

  • Selenium 4.46.0 (11 июля 2026) и Selenide 7.17.0 (12 июля 2026).

  • REST Assured 6.0.1 для API‑вызовов, JUnit 6.1.0 как раннер, Allure для отчётов.

  • Приложение: SPA на React плюс backend на Spring. Access‑токен — JWT с временем жизни 15 минут, лежит в localStorage. Refresh‑токен — в httpOnly‑cookie.

  • Браузеры поднимаются в контейнерах, прогон идёт в 8 потоков. Максимальная длительность одного теста — 5 минут.

  • Тестовых ролей три: обычный пользователь, оператор поддержки, администратор.

Три оговорки.

  • Первая терминологическая: под authenticated state дальше понимается набор клиентских данных, достаточный, чтобы браузер стартовал аутентифицированным, — cookie, localStorage, sessionStorage. Токен здесь частный случай.

  • Вторая: мы воспроизводим модель хранения, которую выбрало приложение, а не рекомендуем держать access‑токен в localStorage.

  • Третья: refresh‑состояние в браузер намеренно не переносим, тест короче TTL токена; что делать, если это не так, — в блоке ограничений.

Шаг 1. Отделить аутентификацию от теста

Закрываем риск: логин размазан по 180 файлам, и любая правка формы входа роняет весь прогон целиком.

Первое, что я делаю, — перестаю считать логин частью теста. Логин — предусловие, такое же как «в базе есть заказ № 42», и готовиться должно самым дешёвым способом, а не самым похожим на поведение живого пользователя.

Практически: из тестов исчезают строки loginPage.login(user, password), вместо них появляется фикстура, которая приводит браузер в состояние «пользователь роли X уже вошёл».

// (Java)
@ExtendWith(AuthExtension.class)
class OrdersTest {
    @Test
    @AsUser(Role.SUPPORT)
    void operatorSeesCancelButtonOnPaidOrder() {
        open("/orders/42");
        $("[data-testid=cancel-order]").shouldBe(visible);
    }
}

Как проверить, что шаг сработал: в тестовых классах не должно остаться ни одного обращения к странице логина — проверяется грепом по loginPage и по пути /login. Если находки есть, вы перенесли проблему, а не решили её.

Шаг 2. Получить токен по API и не утащить секреты в отчёт

Закрываем риск: предусловие готовится через DOM, то есть медленно и с зависимостью от вёрстки.

Сам вызов скучный: один HTTP‑запрос вместо четырёх взаимодействий с DOM. Интереснее то, что вокруг него.

// (Java)
public final class AuthApi {
    private static final RequestSpecification SPEC = new RequestSpecBuilder()
            .setBaseUri(Env.baseUrl())
            .setContentType(ContentType.JSON)
            .addFilter(new MaskedAllureFilter())   // не AllureRestAssured!
            .build();

    public static AuthState login(String username, String password) {
        Response response = given().spec(SPEC)
                .body(Map.of("login", username, "password", password))
                .when().post("/api/auth/login")
                .then().statusCode(200)
                .extract().response();

        return new AuthState(
                response.path("accessToken"),
                response.path("refreshToken"),
                // точка отсчёта — момент получения ответа; если контракт позволяет,
                // надёжнее брать claim exp из самого JWT
                Instant.now().plusSeconds(response.path("expiresIn"))
        );
    }
}

Обратите внимание на фильтр. Стандартный AllureRestAssured прикладывает к отчёту тело запроса и ответа целиком, а на эндпоинте авторизации это пароль и оба токена — то есть валидный доступ в систему, положенный своими руками в артефакты CI, которые лежат месяцами. Для FinTech это не стилистическая мелочь, а инцидент.

// (Java)
// Иллюстрация принципа, а не готовая реализация:
// mask() должен покрывать JSON-поля в разных нотациях,
// заголовки Authorization, Cookie, Set-Cookie и текст исключений.
public final class MaskedAllureFilter implements Filter {

    private static final Set<String> SECRETS =
            Set.of("password", "accessToken", "refreshToken");

    @Override
    public Response filter(FilterableRequestSpecification request,
                           FilterableResponseSpecification spec,
                           FilterContext context) {
        Response response = context.next(request, spec);
        Allure.addAttachment("auth request", mask(request.getBody(), SECRETS));
        Allure.addAttachment("auth response",
                "status: " + response.statusCode() + System.lineSeparator()
                        + mask(response.asString(), SECRETS));
        return response;
    }
}

На бизнес‑эндпоинтах обычный AllureRestAssured остаётся. И фильтр — не единственное место утечки: токен уезжает в артефакты ещё через сообщения исключений, консоль CI и сетевые логи видеозаписи. Маскирование — сквозная задача, а не одна строчка в спецификации.

Как проверить, что шаг сработал: запустите тест‑заглушку, которая дёргает AuthApi.login. В отчёте должен появиться шаг с кодом ответа и expiresIn, а на месте пароля и токенов — <masked>. Само expiresIn должно быть положительным и попадать в ожидаемый диапазон с запасом на длительность теста. Если оно приходит нулём или отсутствует, дальше идти нельзя: кэш из шага 3 сочтёт такой токен вечно протухшим.

Шаг 3. Кэшировать состояние по тестовой identity, а не по роли

Закрываем риск: рейт‑лимит на /login, протухший посреди теста токен и гонки между потоками за одну учётку.

Тут главная развилка. Очевидное решение — кэшировать по роли: три роли, три токена, красиво.

Я так делал и считаю это ошибкой. Роль — атрибут пользователя, а не пользователь: Map<Role, Token> означает, что восемь потоков работают под одной учёткой. Пока тесты только читают, всё хорошо. Как только один меняет профиль, настройки или корзину, появляются гонки на серверном состоянии, взаимные логауты и падения, которые выглядят как UI‑флак, но к UI отношения не имеют. Воспроизводится такое раз из двадцати.

Поэтому ключ кэша — тестовая identity, то есть конкретная учётка:

// (Java)
public record TestIdentity(Role role, String username) {}
public final class AuthStateCache {
    private static final Map<TestIdentity, AuthState> CACHE = new ConcurrentHashMap<>();
    private static final Duration MAX_TEST_DURATION = Duration.ofMinutes(5);
    private static final Duration NETWORK_MARGIN = Duration.ofSeconds(30);
    public static AuthState stateFor(TestIdentity identity) {
        return CACHE.compute(identity, (key, cached) -> outlivesLongestTest(cached)
                ? cached
                : AuthApi.login(key.username(), Credentials.passwordFor(key)));
    }
    private static boolean outlivesLongestTest(AuthState state) {
        Instant needValidUntil = Instant.now()
                .plus(MAX_TEST_DURATION)
                .plus(NETWORK_MARGIN);
        return state != null && needValidUntil.isBefore(state.expiresAt());
    }
}

Два момента, на которых я обжигался.

  • Первый — запас по времени жизни. Фиксированные «две минуты про запас» ничего не гарантируют: токен, которому осталось жить 2 минуты 1 секунду, протухнет посреди пятиминутного сценария, и вы получите 401 в середине теста. Запас считается от максимальной длительности теста плюс сетевые задержки, а не от красивого числа. Помню, как мы полдня ловили «случайные» падения в конце ночного прогона — кэш отдавал токен, которому оставалось жить восемь секунд.

Ещё одно упрощение видно в коде: кэш аутентификации не должен знать, сколько длится тест. В боевом фреймворке требуемое время жизни передают снаружи — stateFor(identity, requiredLifetime) — и считают на уровне фикстуры.

  • Второй момент — compute. Он атомарен для конкретного ключа, поэтому потоки не побегут за токеном одновременно. Но внутри блокирующий сетевой вызов, а javadoc ConcurrentHashMap просит держать вычисление коротким. Это компромисс ради читаемости, и у него есть цена: если auth‑сервис отдаёт 503, каждый поток сходит за своей ошибкой, и «один запрос на ключ» превращается в восемь. Строже — single‑flight через CompletableFuture: первый вызывающий стартует аутентификацию и кладёт в кэш future, остальные ждут её.

Сколько заводить учёток — вопрос не стиля, а того, меняют ли тесты серверное состояние. Read‑only сценарии живут на одной identity на роль, всё пишущее требует своей учётки на поток: support-01support-08. К тому же выводу приходит документация Playwright, объясняя, когда состояние можно переиспользовать между параллельными тестами.

Как проверить, что шаг сработал: повесьте счётчик на AuthApi.login. Число вызовов должно перестать зависеть от числа тестов: на каждую identity приходится одна аутентификация плюс повторные по мере устаревания токена — на TTL 15 минут и запасе 5,5 это около одного вызова на учётку в десять минут прогона. Если видите десятки за пару минут, кэш пересоздаётся: обычно виноват нестатический холдер внутри JUnit‑расширения, умирающий вместе с тестовым классом.

Шаг 4. Выбрать, куда класть состояние

Закрываем риск: токен кладётся не туда, откуда фронт его читает, и приложение молча остаётся неавторизованным.

Ответ зависит от того, откуда приложение читает сессию.

Где приложение хранит сессию

Чем кладём

Подводный камень

Обычная cookie

cookies().add() в Selenide 7.17

Браузер должен уже находиться на нужном домене

httpOnly‑cookie

Драйвер или BiDi‑модуль Storage

Из JavaScript её не поставить принципиально

localStorage

Preload‑скрипт WebDriver BiDi

Нужен правильный origin, executeScript после open() опаздывает

sessionStorage

Preload‑скрипт WebDriver BiDi

Привязан к browsing context, учитывайте его жизненный цикл

IndexedDB

Отдельный механизм

В Selenium Java нет готового эквивалента storageState

Как проверить, что шаг сработал: откройте приложение в обычном браузере, залогиньтесь руками и посмотрите DevTools → Application. Вы увидите конкретное имя ключа или cookie и его формат — выпишите его в константу фреймворка, подбирать по памяти не нужно.

Шаг 5. Cookie: правило домена, о которое спотыкаются все

Закрываем риск: InvalidCookieDomainException на пустом драйвере и httpOnly‑cookie, которая не устанавливается, из‑за чего приложение остаётся неавторизованным.

WebDriver не даст поставить cookie для домена, на котором браузер сейчас не находится, а свежий драйвер стоит на about:blank. Отсюда классический приём: сначала открываем самую лёгкую страницу домена, потом ставим cookie, потом идём куда собирались.

// (Java)
open("/favicon.ico");                 // страница домена весом в пару килобайт
cookies().add("session", stateFor(identity).accessToken());
open("/orders/42");                   // приложение стартует уже с сессией

Метод cookies() — новый API из Selenide 7.17.0. Раньше приходилось использовать разрозненные низкоуровневые методы вроде clearBrowserCookies(); теперь cookie живут в одном ряду с localStorage() и sessionStorage(), а старые методы объявлены deprecated.

Если cookie помечена httpOnly, JavaScript до неё не дотянется — это её смысл. Ставим через драйвер:

// (Java)
WebDriver driver = WebDriverRunner.getWebDriver();
// текущий URL уже должен находиться на нужном host
driver.manage().addCookie(new Cookie.Builder("refresh", refreshToken)
        .path("/")
        .isHttpOnly(true)
        .isSecure(true)
        .build());   // Domain не указываем: cookie остаётся host-only

Domain здесь не задан сознательно: он расширяет область действия cookie на все поддомены, а воспроизводить в тестах более широкий scope, чем в проде, — плохая привычка. Cookie с префиксом __Host- атрибут Domain иметь вообще не может и обязана быть Secure с Path=/.

Есть и второй путь — BiDi‑модуль Storage с командой setCookie, которая работает через storage‑домен и не завязана на driver.manage().addCookie(). Держу его про запас: в 4.46 все текущие BiDi‑классы Java переведены в beta перед дальнейшими изменениями API, а фикстуры живут годами, и переписывать их каждый минорный релиз я не хочу.

Как проверить, что шаг сработал: сразу после установки добавьте cookies().shouldHave(cookie("session")) и сверьте хост: если cookie поставлена на app.internal, а тест ходит на www.app.internal, браузер её просто не отдаст, и вы получите форму логина без единой ошибки в логе.

Шаг 6. localStorage: кладём токен до того, как приложение проснётся

Закрываем риск: SPA стартует с пустым хранилищем, не находит токен и уезжает на /login.

Наивный вариант — открыть приложение, выполнить executeScript с localStorage.setItem и перезагрузить страницу: три действия вместо одного и лишний рендер SPA. Правильнее вклиниться раньше — preload‑скрипт BiDi регистрируется на создание нового документа и позволяет записать localStorage до того, как приложение обратится к нему в startup‑коде.

// (Java)
// Значение не склеиваем с кодом руками: сериализуем в корректный JSON-литерал
String tokenLiteral = new ObjectMapper().writeValueAsString(accessToken);
Script script = new Script(WebDriverRunner.getWebDriver());
String scriptId = script.addPreloadScript(
        "() => { localStorage.setItem('access_token', " + tokenLiteral + "); }"
);
open("/orders/42");
script.removePreloadScript(scriptId);

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

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

Как проверить, что шаг сработал: после open() добавьте $("[data-testid=login-form]").shouldNot(exist). Оговорюсь честно: этот ассерт фиксирует состояние на момент проверки и само мелькание не детектирует — если форма появилась на 100 мс и исчезла, он её не увидит. Чтобы поймать мелькание, нужны видео прогона, MutationObserver или трассировка навигаций. Я смотрю видео и убеждаюсь, что перехода на /login в нём нет вовсе.

Шаг 7. Проверить, что всё сработало

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

«Ну тесты же зелёные» проверкой не считается. Делаю две: первая валидирует сами credentials, вторая — что состояние доехало до браузера.

// (Java)
// 1. Токен принят бэкендом
given().spec(SPEC)
        .header("Authorization", "Bearer " + accessToken)
        .when().get("/api/me")
        .then().statusCode(200)
        .body("role", equalTo(identity.role().name()));
// 2. Приложение стартовало авторизованным
open("/orders/42");
$("[data-testid=user-menu]").shouldBe(visible, Duration.ofSeconds(4));
$("[data-testid=login-form]").shouldNot(exist);

Вторая нужна потому, что первая проходит и при неработающей схеме: токен валиден, но фронт читает его из другого ключа. На одном из сервисов именно это и произошло — бэкенд отвечал 200, а SPA показывала форму входа, потому что ключ назывался auth.accessToken, а не access_token.

Оговорка про сам /api/me: это дешёвая sanity check. Она доказывает, что конкретный эндпоинт принял токен, но ничего не говорит про нужные scope, authority и бизнес‑ограничения. Тесту, которому нужен доступ в админку, проверять надо доступ в админку, а не наличие профиля.

Теперь про цифры. Замеры сняты на одном CI‑прогоне из 180 тестов; приведено время подготовки состояния без самого UI‑сценария. Подготовка сократилась с 6–9 секунд до 0,4–0,7: остаётся переход на лёгкую страницу домена и запись в хранилище. Вызовов /api/auth/login вместо ста восьмидесяти стало единицы. Если прогон разложен на несколько CI‑воркеров, каждый аутентифицируется отдельно, и считать надо по воркерам.

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

Как это выглядит целиком

На рис. 2 — весь путь от вызова теста до первой проверки: что происходит при попадании в кэш, что при промахе.

Рис. 2. Схема принципиальная: путь от вызова теста до авторизованного экрана
Рис. 2. Схема принципиальная: путь от вызова теста до авторизованного экрана

Главное из схемы: сетевой вызов к auth‑сервису перестаёт приходиться на каждый тест, всё остальное — локальные операции с браузером. Отсюда устойчивость к рейт‑лимитам и к морганию тестового identity‑сервиса.

Второй частый вопрос — какой способ подкладывания выбрать.

На рис. 3 план действий в виде дерева решений.

Рис. 3. План действий: как выбрать способ подкладывания состояния
Рис. 3. План действий: как выбрать способ подкладывания состояния

Вывод простой: способ определяется тем, где и как приложение хранит и восстанавливает authenticated state, а не выбранным фреймворком. Сначала DevTools, потом код.

Что делают команды, которые прошли этот путь раньше

Похожую модель современные фреймворки браузерного тестирования вшили прямо в API.

Playwright сделал storageState — сериализацию cookie и хранилищ в JSON‑файл, который подставляется в новый контекст браузера. В версии 1.51 добавили опцию setIndexedDB для приложений, которые держат токены в IndexedDB, например на Firebase Authentication. Рекомендуемый способ применения — вынести аутентификацию в отдельный setup‑проект.

Cypress пошёл тем же путём: cy.session() появился в 8.2 за экспериментальным флагом, в 10.9 добавили cacheAcrossSpecs, в 12.0 флаг убрали. Самое ценное там — опция validate: она проверяет восстановленную сессию, и если та не проходит валидацию, Cypress считает её невалидной и запускает setup заново. Без неё протухшее состояние молча ломает тесты, и вы отлаживаете не то место.

Отсюда практика для любого Java‑проекта: валидировать состояние перед использованием, а не верить кэшу на слово. И вторая: сохранённое состояние сессии — валидный доступ в систему, его место в .gitignore, а не в репозитории.

Где это решение не сработает

  • Тесты самой аутентификации. Вход, выход, неверный пароль, блокировка после N попыток, восстановление — только через UI, иначе вы перестали тестировать логин.

  • Внешний IdP и обязательная MFA. Простого username/password API там может не быть: RFC 9700 (BCP 240) запрещает Resource Owner Password Credentials grant в новых реализациях OAuth. Состояние тогда добывается иначе — тестовый tenant, сервисная identity, служебный endpoint или заранее сохранённое состояние браузера.

  • Тест длиннее TTL токена. Мы намеренно не переносим refresh‑состояние в браузер, поэтому обновить access‑токен приложение не сможет и по истечении TTL перейдёт в неавторизованное состояние. Если сценарии длинные, придётся класть в браузер и refresh‑состояние либо предусмотреть повторную аутентификацию в фикстуре.

  • Привязка сессии к отпечатку и CSRF. Если бэкенд связывает сессию с User‑Agent, IP или device fingerprint, он может отклонить полученный из Java‑клиента токен или не считать сессию валидной для браузера. А поставленная cookie ещё не означает, что пройдут POST и PUT: может требоваться отдельный CSRF‑токен.

  • Secure на HTTP‑стенде и партиционированные cookie. Флаг Secure требует HTTPS, localhost — специальное исключение. А если авторизация работает через сторонний iframe и Partitioned/CHIPS, добавление cookie потребует отдельной работы с партицией.

Чек‑лист перед тем, как считать фреймворк готовым

  • В тестах не осталось обращений к странице логина, кроме тестов на саму аутентификацию.

  • Состояние кэшируется по тестовой identity, а не по роли.

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

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

  • Пароли и токены маскируются в Allure, логах CI и любых артефактах.

  • Есть две проверки: бэкенд принял токен и фронт стартовал авторизованным.

  • Число auth‑запросов измеряется по JVM или воркеру, а не заявляется как глобальное.

Можно проверить себя и шире: пройти вступительный тест по автоматизации тестирования на Java продвинутого уровня. Результат покажет, какие темы уже освоены уверенно, а где знания стоит подтянуть.

Если все семь пунктов закрыты, аутентификация перестаёт быть источником флака и становится тем, чем должна быть, — дешёвым предусловием. А связка «состояние готовим по API, проверяем через UI» работает не только с логином: так же выносятся создание заказа, наполнение корзины и любое состояние, которое вы уже проверили один раз.

Когда тестовый контур растёт, одной оптимизацией логина уже не обойтись. Нужно понимать, что оставить в UI, что перенести в API и какие сетевые зависимости контролировать отдельно.

На бесплатных уроках разбираем, как выстраивать такие сценарии на практике — от связки UI и API до управления трафиком:

  • 3 сентября в 20:00. «UI и API тестирование с Java и Playwright». Записаться

  • 22 сентября в 20:00. «Автоматизация управления трафиком с mitmproxy». Записаться

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