Всем привет, меня зовут Сергей Прощаев и в этой статье покажу, как убрать форму входа из 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 он выполняется не чаще, чем нужно каждой учётке с учётом срока жизни токена. Каждый шаг закрывает один риск, и после каждого написано, как убедиться, что он сработал.

Исходные условия
Чтобы разговор был предметным, зафиксирую стенд. Версии — те, на которых собирались примеры; 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. Он атомарен для конкретного ключа, поэтому потоки не побегут за токеном одновременно. Но внутри блокирующий сетевой вызов, а javadocConcurrentHashMapпросит держать вычисление коротким. Это компромисс ради читаемости, и у него есть цена: если auth‑сервис отдаёт 503, каждый поток сходит за своей ошибкой, и «один запрос на ключ» превращается в восемь. Строже — single‑flight черезCompletableFuture: первый вызывающий стартует аутентификацию и кладёт в кэш future, остальные ждут её.
Сколько заводить учёток — вопрос не стиля, а того, меняют ли тесты серверное состояние. Read‑only сценарии живут на одной identity на роль, всё пишущее требует своей учётки на поток: support-01 … support-08. К тому же выводу приходит документация Playwright, объясняя, когда состояние можно переиспользовать между параллельными тестами.
Как проверить, что шаг сработал: повесьте счётчик на
AuthApi.login. Число вызовов должно перестать зависеть от числа тестов: на каждую identity приходится одна аутентификация плюс повторные по мере устаревания токена — на TTL 15 минут и запасе 5,5 это около одного вызова на учётку в десять минут прогона. Если видите десятки за пару минут, кэш пересоздаётся: обычно виноват нестатический холдер внутри JUnit‑расширения, умирающий вместе с тестовым классом.
Шаг 4. Выбрать, куда класть состояние
Закрываем риск: токен кладётся не туда, откуда фронт его читает, и приложение молча остаётся неавторизованным.
Ответ зависит от того, откуда приложение читает сессию.
Где приложение хранит сессию | Чем кладём | Подводный камень |
|---|---|---|
Обычная cookie |
| Браузер должен уже находиться на нужном домене |
httpOnly‑cookie | Драйвер или BiDi‑модуль | Из JavaScript её не поставить принципиально |
| Preload‑скрипт WebDriver BiDi | Нужен правильный origin, |
| Preload‑скрипт WebDriver BiDi | Привязан к browsing context, учитывайте его жизненный цикл |
IndexedDB | Отдельный механизм | В Selenium Java нет готового эквивалента |
Как проверить, что шаг сработал: откройте приложение в обычном браузере, залогиньтесь руками и посмотрите 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 — весь путь от вызова теста до первой проверки: что происходит при попадании в кэш, что при промахе.

Главное из схемы: сетевой вызов к auth‑сервису перестаёт приходиться на каждый тест, всё остальное — локальные операции с браузером. Отсюда устойчивость к рейт‑лимитам и к морганию тестового identity‑сервиса.
Второй частый вопрос — какой способ подкладывания выбрать.
На рис. 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». Записаться
Полный список демо-уроков на начало сентября смотрите в дайджесте.

