Ставка на API-слой в автотестах: как разгрузить UI и ускорить обратную связь

от автора

Дисклеймер

Это не серебряная пуля и не универсальная догма.

API-first подход — один из возможных способов оптимизировать автотесты. Он сработал в нашем контексте, потому что значительная часть бизнес-логики была доступна через API, а UI-тесты начали дублировать проверки, которые дешевле и надежнее выполнять ниже.

Мы не призываем отказаться от UI-тестирования. UI по-прежнему нужен. Вопрос не в том, чтобы заменить все браузерные тесты API-вызовами, а в том, чтобы правильно распределить ответственность:

  • API проверяет бизнес-логику, валидацию, права, статусы, интеграции;

  • UI проверяет пользовательское взаимодействие и отображение;

  • E2E проверяет несколько критичных сквозных процессов.

Что вас ждет в статье

  • Анализ проблем UI-heavy подхода.

  • Практическая реализация API-first архитектуры на Java, REST Assured и JUnit 5.

  • Примеры из нескольких реальных фреймворков автотестов.

  • Паттерны: API clients, builders, response wrappers, steps, providers, base-классы.

  • Как API помогает разгружать UI без потери покрытия.

  • Как параметризация API-тестов помогает быстро наращивать проверки.

  • Как параллелизация API и UI-тестов сократила время CI.

  • Ограничения подхода и компромиссы.

Кому будет полезен материал

Материал будет полезен QA Automation инженерам, которые строят или поддерживают Java-фреймворки автотестов.

Особенно если вы сталкивались с ситуациями, когда:

  • UI-тестов становится слишком много;

  • прогон в CI занимает слишком долго;

  • падение UI-теста сложно диагностировать;

  • бизнес-валидация проверяется через длинные браузерные сценарии;

  • тестовые данные сложно готовить через интерфейс;

  • команда хочет ускорить обратную связь, но не потерять качество.

Введение: что такое API-first подход в тестировании

API-first тестирование — это стратегия, при которой основная масса автоматизированных проверок выполняется на уровне программного интерфейса, а не через пользовательский интерфейс.

В нашем случае это не означало “все тестируем через API”. Мы использовали более прагматичное распределение:

  • 80% тестов — API-уровень: бизнес-логика, валидация, интеграции, права, статусы;

  • 15% тестов — UI-уровень: критические пользовательские сценарии, формы, отображение;

  • 5% тестов — E2E: сквозные бизнес-процессы.

Это распределение не нужно воспринимать как математический закон. Для нас оно стало ориентиром: если сценарий можно надежно проверить на API-уровне, не нужно тащить его в UI только потому, что так “виднее”.

Боль UI-heavy подхода

Когда команда только начинает автоматизацию, UI-тесты кажутся самым очевидным вариантом: они повторяют действия пользователя и хорошо воспринимаются бизнесом. Но со временем появляются типичные проблемы.

Первая — скорость. UI-тесту нужно открыть браузер, авторизоваться, дождаться загрузки страниц, найти элементы и дождаться реакции интерфейса. Если таких сценариев много, CI начинает тормозить.

Вторая — хрупкость. UI-тест зависит не только от бизнес-логики, но и от локаторов, верстки, сетевых задержек, состояния браузера, анимаций и фронтенд-ошибок.

Третья — диагностика. Если API-тест вернул 400 вместо 201, причина обычно ближе: request, response, contract, validation. Если падает UI-тест, приходится разбираться, что именно сломалось: данные, backend, frontend, авторизация, локатор, ожидание или окружение.

Четвертая — дублирование. Через UI часто начинают проверять то, что уже можно надежно проверить через API: обязательные поля, длину строк, права доступа, статусы или даты.

Именно поэтому мы сместили основную нагрузку на API.

Как мы распределили ответственность

В одном наборе API покрывает CRUD-операции, структуру данных, лимиты, внутренние endpoints и работу со статусами. UI при этом проверяет навигацию, доступность ключевых элементов и базовые сценарии взаимодействия.

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

В третьем наборе подход смешанный: API используется для создания, обновления, архивирования, работы с вложениями, правами доступа и списками. UI-тесты оставлены для сценариев с формами, редактированием, отображением данных и пользовательским просмотром. E2E-сценарии строятся точечно, часто с подготовкой данных через API.

Общая архитектура API-слоя

Во всех трех фреймворках структура похожая:

api/  assertions/  clients/  filter/  models/  provider/  requests/  response/  steps/  tests/

Это разделение оказалось важнее, чем кажется.

clients отвечают только за HTTP-вызовы. requests собирают валидные request body. models и response описывают DTO. provider хранит данные для параметризованных тестов. steps содержит переиспользуемые проверки. tests остаются сценарными.

Тест в таком стиле читается как последовательность бизнес-действий:

  1. Собрать request.

  2. Вызвать client.

  3. Проверить статус.

  4. Десериализовать response.

  5. Выполнить step-проверки.

  6. При необходимости проверить состояние через GET/list или DB helper.

Базовый API-клиент

Первое, что мы вынесли в общий слой, — настройку REST Assured, base URL, авторизацию, логирование и masking filter.

Упрощенный и обезличенный пример базового клиента:

public class BaseClient {    protected String baseUrl;    protected String accessToken;    protected RequestSpecification baseRequestSpec;    protected ResponseSpecification baseResponseSpec;    public BaseClient() {        baseUrl = ConfigService.get("service_url");        baseRequestSpec = new RequestSpecBuilder()                .setBaseUri(baseUrl)                .setRelaxedHTTPSValidation()                .setContentType(ContentType.JSON)                .addFilter(new MaskingFilter())                .log(LogDetail.ALL)                .build();        baseResponseSpec = new ResponseSpecBuilder()                .log(LogDetail.ALL)                .build();        this.accessToken = given()                .filter(new MaskingFilter())                .relaxedHTTPSValidation()                .formParam("username", ConfigService.get("keycloak.username"))                .formParam("password", ConfigService.get("keycloak.password"))                .formParam("client_id", ConfigService.get("client_id"))                .formParam("client_secret", ConfigService.get("client_secret"))                .formParam("grant_type", ConfigService.get("grant_type"))                .contentType("application/x-www-form-urlencoded")                .post(ConfigService.get("keycloak_url"))                .then()                .statusCode(200)                .extract()                .body()                .jsonPath()                .getString("access_token");    }}

Что это дает:

  • тесты не знают, как получать токен;

  • base URL и секреты не размазаны по тестам;

  • все клиенты используют единый request spec;

  • чувствительные данные маскируются в логах;

  • при изменении авторизации правится один слой, а не десятки тестов.

В одном из фреймворков дополнительно используется кеширование access token. Это снижает количество повторных запросов за токеном при большом количестве API-классов.

private static final Object TOKEN_LOCK = new Object();private static volatile String CACHED_ACCESS_TOKEN = null;private String getAccessToken(JasyptConfig config) {    if (CACHED_ACCESS_TOKEN == null) {        synchronized (TOKEN_LOCK) {            if (CACHED_ACCESS_TOKEN == null) {                CACHED_ACCESS_TOKEN = given()                        .filter(new MaskingFilter())                        .relaxedHTTPSValidation()                        .formParam("username", config.get("username"))                        .formParam("password", config.get("password"))                        .formParam("client_id", config.get("client_id"))                        .formParam("client_secret", config.get("client_secret"))                        .formParam("grant_type", config.get("grant_type"))                        .contentType("application/x-www-form-urlencoded")                        .post(config.get("keycloak_url"))                        .then()                        .statusCode(200)                        .extract()                        .body()                        .jsonPath()                        .getString("access_token");            }        }    }    return CACHED_ACCESS_TOKEN;}

Это небольшой, но полезный инфраструктурный прием. Он не меняет тестовую стратегию сам по себе, но помогает большому API-набору работать стабильнее и быстрее.

Единый стиль response-проверок

Следующий слой — обертка над REST Assured response.

@RequiredArgsConstructorpublic class AssertableResponse {    private final ValidatableResponse response;    public <T> T as(Class<T> clazz) {        return response.extract().body().as(clazz);    }    public <T> List<T> asList(Class<T> clazz) {        return response.extract().body().jsonPath().getList("", clazz);    }    public AssertableResponse should(Condition condition) {        condition.check(response);        return this;    }    public Response asResponse() {        return response.extract().response();    }}

Снаружи это дает короткий и единообразный синтаксис:

ToggleResponse response = toggleControllerClient.createToggle(request)        .should(hasStatusCode(HttpStatus.SC_CREATED))        .as(ToggleResponse.class);

Важный момент: в этих фреймворках я не использовал JSON Schema validation как основной механизм проверки контракта. Контракт проверяется через:

  • статус-коды;

  • DTO-десериализацию;

  • обязательные поля;

  • сравнение request/response;

  • бизнес-проверки в steps.

Schema validation можно добавить отдельно, но в текущей архитектуре основной акцент сделан не на JSON schema, а на читаемых Java-моделях и step-проверках.

Providers: как масштабировать негативные API-проверки

Один из сильных аргументов в пользу API-слоя — параметризация. В UI тоже можно сделать @ParameterizedTest, но каждый кейс все равно проходит через браузер: открыть форму, заполнить поля, дождаться валидации, проверить сообщение. Если вариантов много, такой набор быстро становится дорогим и менее стабильным.

На API-уровне это проще: мы берем валидный request, точечно меняем одно поле и проверяем response. Часто новый кейс — это всего одна строка в provider.

В одном из модулей для проверки обязательного поля title используется provider с несколькими вариантами невалидных значений:

public class NegativeTestsProviderContent extends ProviderConstants {    public static Stream<Arguments> invalidTitleProvider() {        return Stream.of(                // Пустая строка                Arguments.of("", "newsContents[0].content.title: не должно быть пустым"),                // Null значение                Arguments.of(null, "newsContents[0].content.title: не должно быть пустым"),                // Строка c пробелами                Arguments.of("   ", "newsContents[0].content.title: не должно быть пустым"),                // Строка более 255 символов                Arguments.of(                        INVALID_TITLE_LENGTH,                        "newsContents[0].content.title: размер должен находиться в диапазоне от 0 до 255"                )        );    }}

Сам тест при этом остается коротким:

@ParameterizedTest@MethodSource("ru.example.news.api.provider.NegativeTestsProviderContent#invalidTitleProvider")public void creatingNewsWithInvalidTitle(        String invalidTitle,        String expectedError) {    NewsRequest request = CreateNewsRequestBuilder.buildDefaultNewsRequest();    request.getNewsContents().get(0).getContent().setTitle(invalidTitle);    ErrorResponse errorResponse = adminClient.createNews(request)            .should(hasStatusCode(HttpStatus.SC_BAD_REQUEST))            .as(ErrorResponse.class);    Assertions.assertNotNull(errorResponse.getRequestId());    Assertions.assertEquals(expectedError, errorResponse.getErrors().get(0));}

Что здесь важно:

  • CreateNewsRequestBuilder.buildDefaultNewsRequest() дает валидный базовый request;

  • тест меняет только title, поэтому причина ошибки изолирована;

  • один тестовый метод покрывает пустую строку, null, строку из пробелов и превышение длины;

  • чтобы добавить новый кейс, не нужно писать новый UI-сценарий;

  • проверка выполняется на уровне API, где валидация фактически и должна сработать.

Если проверять эти кейсы через UI, каждый прогон будет включать открытие страницы, заполнение формы, ожидания, работу с DOM и проверку текста ошибки. На API-уровне это всего четыре HTTP-запроса с понятной диагностикой по request/response.

Этот подход особенно хорошо работает для:

  • обязательных полей;

  • граничных длин строк;

  • null и пустых значений;

  • невалидных enum/status;

  • отсутствующих прав;

  • некорректных дат;

  • конфликтов уникальности;

  • проверки auth/header scenarios.

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

Например, когда typed DTO не позволяет передать невалидный тип, тест использует raw body через Map<String, Object>, а значения берет из provider-а.

@ParameterizedTest@MethodSource(        "ru.example.feature.api.provider.ToggleNegativeTestsProvider#invalidEnabledProvider")public void saveToggleWithInvalidEnabled(        Object invalidEnabled,        List<String> expectedErrors) {    var request = buildToggleBody(            ToggleDataGenerator.generateName(),            ToggleDataGenerator.generateCyrillicDescription("Тест включения"),            invalidEnabled,            null,            null    );    ErrorResponse errorResponse = toggleControllerClient.createToggle(request)            .should(hasStatusCode(HttpStatus.SC_BAD_REQUEST))            .as(ErrorResponse.class);    SaveToggleSteps.assertErrorResponseErrors(errorResponse, expectedErrors);}

Этот пример показывает важную деталь: API-first подход не ограничивается happy path. Наоборот, API-слой удобен именно для негативных и граничных проверок, потому что можно точно сформировать некорректное тело запроса.

В UI такой сценарий часто даже невозможно честно воспроизвести. Если поле enabled на фронтенде — checkbox, пользователь не сможет передать туда строку. Но backend все равно должен корректно обработать некорректный тип, потому что API может вызываться не только из браузера.

Почему связка builders + providers + steps масштабируется

В связке builders + providers + steps появляется удобная модель масштабирования API-тестов.

Builder отвечает за валидную базу:

NewsRequest request = CreateNewsRequestBuilder.buildDefaultNewsRequest();

Provider отвечает за набор вариантов:

Arguments.of("", "title: не должно быть пустым");Arguments.of(null, "title: не должно быть пустым");Arguments.of("   ", "title: не должно быть пустым");

Test отвечает за сценарий:

request.getNewsContents().get(0).getContent().setTitle(invalidTitle);ErrorResponse errorResponse = adminClient.createNews(request)        .should(hasStatusCode(HttpStatus.SC_BAD_REQUEST))        .as(ErrorResponse.class);

Steps отвечают за переиспользуемые проверки:

SaveToggleSteps.assertErrorResponseErrors(errorResponse, expectedErrors);

В результате новый кейс добавляется не новым тестовым классом и не новым UI-сценарием, а расширением набора данных или step-проверки.

Это снижает стоимость поддержки. Когда меняется текст ошибки, правится provider. Когда меняется структура response, правится DTO или step. Когда меняется endpoint, правится client. Тесты остаются читаемыми и не превращаются в набор технических деталей.

Пример API-first сценария: создание записи настроек без UI

В одном из фреймворков есть API-тест создания конфигурационной записи. Тест создает сущность, проверяет response, а затем убеждается, что запись появилась в общем списке.

@Test@Tag("SMOKE")public void createTabAndVerifyInList() {    TabRequest request = CreateTabRequestBuilder.buildDefaultTabRequest();    CreateTabResponse createResponse = tabControllerClient.createTab(request)            .should(hasStatusCode(HttpStatus.SC_CREATED))            .as(CreateTabResponse.class);    createdTabIds.add(createResponse.getId());    TabSteps.assertCreateResponseValid(createResponse);    GetTabResponse getTabResponse = getTabFromList(createResponse.getId());    TabSteps.assertTabValid(getTabResponse, createResponse.getId());}

Почему это хороший API-first сценарий:

  • не нужен браузер;

  • проверяется бизнес-операция создания сущности;

  • есть follow-up проверка состояния через список;

  • cleanup выполняется через базовый класс;

  • ассерты вынесены в TabSteps;

  • покрывается критичная конфигурационная логика.

Request builder отделяет тест от деталей сборки тела:

public class CreateTabRequestBuilder {    public static TabRequest buildDefaultTabRequest() {        return TabRequest.builder()                .title(DataGenerator.generateTitle())                .subtitle(DataGenerator.generateSubtitle())                .number(DataGenerator.generateNumber())                .path(DataGenerator.generatePath())                .alfaViewStatus(DataGenerator.getRandomEnum(AlfaViewStatus.class))                .underwritingStatus(DataGenerator.getRandomEnum(UnderwritingStatus.class))                .paymentStatus(DataGenerator.getRandomEnum(PaymentStatus.class))                .contractType(DataGenerator.getRandomEnum(ContractType.class))                .build();    }    public static TabRequest buildRequiredTabRequest() {        return TabRequest.builder()                .title(DataGenerator.generateTitle())                .path(DataGenerator.generatePath())                .build();    }}

Тесты не собирают JSON вручную. Они берут валидную базу и меняют только то поле, которое важно для сценария.

Steps: сложные проверки не должны жить в тесте

Еще один важный элемент API-first архитектуры — steps. В тесте не должно быть длинной простыни ассертов. Тест должен описывать сценарий, а подробная проверка структуры должна жить в отдельном step-классе.

Хороший пример — проверка структуры меню в одном из фреймворков. Сам API-тест короткий:

@Test@Tag("SMOKE")public void getMenuVerifyFields() {    MenuResponse[] menuResponses = menuControllerClient.getMenu()            .should(hasStatusCode(HttpStatus.SC_OK))            .as(MenuResponse[].class);    TabMenuSteps.assertMenuResponseValid(menuResponses);}

Вся сложная проверка дерева меню вынесена в TabMenuSteps:

@Step("Проверка структуры и полей меню")public static void assertMenuResponseValid(MenuResponse[] menu) {    assertNotNull(menu, "menu не должен быть null");      assertTrue(menu.length > 0, "menu не должен быть пустым");        List<MenuResponse> menuList = Arrays.asList(menu);      assertMenuItemsNotEmpty(menuList);      assertAll("Проверка структуры меню",          () -> {              MenuResponse auto = findById(menuList, AUTO_ID);              assertAll("Проверка раздела Авто",                  () -> assertMenuItemEquals(auto, AUTO_ID, AUTO_TITLE),                  () -> assertMenuListNotEmpty(auto.getMenuList(), FIELD_NAME_AUTO),                  () -> assertMenuItemsNotEmpty(auto.getMenuList()),                  () -> assertMenuItemEquals(findById(auto.getMenuList(), BLUECARD_ID), BLUECARD_ID, BLUECARD_TITLE),                  () -> assertMenuItemEquals(findById(auto.getMenuList(), GAP_ID), GAP_ID, GAP_TITLE),                  () -> assertMenuItemEquals(findById(auto.getMenuList(), OSAGO_ID), OSAGO_ID, OSAGO_TITLE),                  () -> assertMenuItemEquals(findById(auto.getMenuList(), CASCO_ID), CASCO_ID, CASCO_TITLE)              );          },            () -> {              MenuResponse health = findById(menuList, HEALTH_ID);              assertAll("Проверка раздела Здоровье",                  () -> assertMenuItemEquals(health, HEALTH_ID, HEALTH_TITLE),                  () -> assertMenuListNotEmpty(health.getMenuList(), FIELD_NAME_HEALTH),                  () -> assertMenuItemsNotEmpty(health.getMenuList()),                  () -> {                      MenuResponse medicine = findById(health.getMenuList(), MEDICICNE_ID);                      assertAll("Проверка подраздела Медицина",                          () -> assertMenuItemEquals(medicine, MEDICICNE_ID, MEDICICNE_TITLE),                          () -> assertMenuListNotEmpty(medicine.getMenuList(), FIELD_NAME_MEDICICNE),                          () -> assertMenuItemsNotEmpty(medicine.getMenuList()),                          () -> assertMenuItemEquals(findById(medicine.getMenuList(), ANTIMATE_ID), ANTIMATE_ID, ANTIMATE_TITLE),                          () -> assertMenuItemEquals(findById(medicine.getMenuList(), NS_ID), NS_ID, NS_TITLE)                      );                  }              );          },          () -> {              MenuResponse journal = findById(menuList, JOURNAL_ID);              assertAll("Проверка раздела Поиск",                  () -> assertMenuItemEquals(journal, JOURNAL_ID, JOURNAL_TITLE),                  () -> assertMenuListNullOrEmpty(journal.getMenuList(), FIELD_NAME_JOURNAL)              );          },          () -> {              MenuResponse widget = findById(menuList, WIDGET_ID);              assertAll("Проверка раздела Виджеты",                  () -> assertMenuItemEquals(widget, WIDGET_ID, WIDGET_TITLE),                  () -> assertMenuListNullOrEmpty(widget.getMenuList(), FIELD_NAME_WIDGET)              );          }      );  }

Почему такую проверку выгоднее делать на API-уровне?

Потому что здесь проверяется структура данных меню:

  • корневые элементы;

  • вложенные элементы;

  • id;

  • title;

  • наличие или отсутствие подменю;

  • обязательные поля;

  • ожидаемая иерархия.

Если проверять это только через UI, тесту пришлось бы:

  • открыть приложение;

  • дождаться загрузки меню;

  • раскрыть несколько уровней;

  • найти элементы в DOM;

  • учитывать состояние viewport;

  • бороться с hover/click/animation;

  • поддерживать локаторы;

  • отличать проблему данных от проблемы отображения.

API-тест отвечает на более точный вопрос: “backend вернул правильную структуру меню?”. UI-тест после этого может быть проще: “пользователь видит меню и может открыть нужный раздел?”.

Это и есть нормальное разделение ответственности. API проверяет структуру и бизнес-данные, UI проверяет отображение и взаимодействие.

Пример API-first сценария: негативные проверки без браузера

Для такого типа сценариев API-слой особенно удобен. Например, нужно проверить отсутствие токена или служебного header-а. Через UI это был бы тяжелый и неестественный сценарий. Через API — два коротких теста.

Клиент поддерживает параметры withAuth и withGatewayUser:

public class ToggleControllerClient extends BaseClient {    private static final String USER_HEADER_VALUE = "{\"roles\":[<role-id>]}";    public AssertableResponse createToggle(Object body) {        return createToggle(body, true, true);    }    public AssertableResponse createToggle(            Object body,            boolean withAuth,            boolean withGatewayUser    ) {        RequestSpecification spec = given()                .spec(baseRequestSpec)                .body(body);        if (withAuth) {            spec.auth().oauth2(accessToken);        }        if (withGatewayUser) {            spec.header("x-gateway-user", USER_HEADER_VALUE);        }        return new AssertableResponse(                spec.post("/feature-api/toggles")                        .then()                        .spec(baseResponseSpec)        );    }}

Сами тесты остаются сценарными:

@Testpublic void saveToggleWithoutGatewayHeader() {    ToggleModel request = ToggleRequestBuilder.buildRequiredToggleRequest();    toggleControllerClient.createToggle(request, true, false)            .should(hasStatusCode(HttpStatus.SC_FORBIDDEN));}@Testpublic void saveToggleWithoutAccessToken() {    ToggleModel request = ToggleRequestBuilder.buildRequiredToggleRequest();    toggleControllerClient.createToggle(request, false, true)            .should(hasStatusCode(HttpStatus.SC_UNAUTHORIZED));}

Это хороший пример того, как API-слой позволяет проверять технические и бизнес-ограничения без лишней UI-обвязки.

Проверка состояния через повторный GET/list

Для позитивных сценариев мы старались не ограничиваться только response-ом операции создания. Если бизнес-логика требует, после POST выполняется GET или получение списка.

@Testpublic void saveToggleWithPeriodDates() {    ToggleModel request = ToggleRequestBuilder.buildDefaultToggleRequest();    ToggleResponse saveResponse = toggleControllerClient.createToggle(request)            .should(hasStatusCode(HttpStatus.SC_CREATED))            .as(ToggleResponse.class);    SaveToggleSteps.validateRequiredFields(saveResponse);    SaveToggleSteps.assertRequiredFieldsMatchRequest(saveResponse, request);    SaveToggleSteps.assertPeriodMatchesRequest(saveResponse, request);    ToggleResponse toggleFromGet = getToggleFromList(saveResponse.getId());    GetToggleSteps.validateRequiredFields(toggleFromGet);    GetToggleSteps.assertRequiredFieldsMatchSavedResponse(toggleFromGet, saveResponse);    GetToggleSteps.assertPeriodMatchesSavedResponse(toggleFromGet, saveResponse);}

Это важный момент. API-first не должен превращаться в “проверили 201 и успокоились”. Если объект должен сохраниться и возвращаться в списке, это нужно проверять.

Raw body только там, где DTO мешает негативному тесту

Основной путь — typed DTO. Но иногда нужно отправить заведомо невалидный тип. Например, поле enabled должно быть boolean, а мы хотим передать туда строку или число.

Для таких случаев в базовом API-классе есть helper, который собирает raw body через Map.

protected Map<String, Object> buildToggleBody(        String name,        String description,        Object enabled,        String start,        String end) {    Map<String, Object> period = new LinkedHashMap<>();    period.put("start", start);    period.put("end", end);    Map<String, Object> body = new LinkedHashMap<>();    body.put("name", name);    body.put("description", description);    body.put("enabled", enabled);    body.put("period", period);    return body;}

Это компромисс: мы не отказываемся от моделей, но оставляем техническую возможность проверить валидацию типов.

API как способ разгрузить UI на сложных сценариях

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

Если каждую комбинацию проверять через браузер, UI-набор быстро становится тяжелым. Поэтому API используется в двух ролях:

  • основной слой для проверки бизнес-логики;

  • быстрый setup для UI/E2E.

Builder фреймворка скрывает сложную вложенную структуру request-а:

public class CreateNewsRequestBuilder {    public static NewsRequest buildDefaultNewsRequest() {        return NewsRequest.builder()                .newsContents(List.of(buildDefaultNewsContent()))                .build();    }    public static NewsContent buildDefaultNewsContent() {        return NewsContent.builder()                .content(buildDefaultContent())                .attachments(Collections.emptyList())                .channel("<channel>")                .permissions(buildDefaultPermissions())                .build();    }    public static Content buildDefaultContent() {        return Content.builder()                .author(generateFullName(20, 100))                .title(generateRandomText(10, 100))                .previewText(generateRandomText(2, 100))                .fullText(generateFullText(150, 250))                .build();    }    public static Permissions buildDefaultPermissions() {        return Permissions.builder()                .USER_GROUP(List.of("<value>"))                .FILIAL(List.of("<value>"))                .CHANNEL_SALE(List.of("<value>"))                .build();    }}

API setup для UI-сценариев

Для UI-сценариев в одном из фреймворков есть helper, который создает тестовые данные через API, а потом открывает их в интерфейсе.

public String createNewsWithAttachmentsViaApiAndOpenInAdminEditor() {    try {        List<File> files = RandomFileUtil.getRandomFilesFromResources("test-files", 1);        List<Attachments> attachments = new ArrayList<>();        for (File file : files) {            attachments.add(adminClient.uploadFileAndGetAttachment(file));        }        NewsRequest request =                CreateNewsRequestBuilder.buildNewsRequestWithAttachments(attachments);        String title = request.getNewsContents()                .get(0)                .getContent()                .getTitle();        adminClient.createNews(request)                .should(hasStatusCode(201))                .as(CreateNewsAdminResponse.class);        adminNewsListPage.openNewsByTitle(title);        adminEditNewsPage.checkingTheTextNewsEditing();        return title;    } catch (Exception e) {        throw new RuntimeException("Failed to create news with attachments via API", e);    }}

Это один из самых полезных приемов.

Мы не заставляем UI-тест каждый раз создавать сложную сущность через форму, загружать файлы, выбирать права и проходить весь путь подготовки. API делает setup быстрее и стабильнее. UI затем проверяет то, что относится именно к интерфейсу: отображение, открытие, редактирование и доступность элементов.

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

UI-тесты остаются, но точечно

Пример UI-теста создания сущности через административный интерфейс:

@Testpublic void creatingANewsWithRequiredFields() {    adminNewsListPage.clickAddNewsButton();    adminCreateNewsPage.checkingTheTextOfTheNewsContent();    String generatedTitle = DataGenerator.generateRandomText(25, 100);    adminCreateNewsPage.enterTitle(generatedTitle);    adminCreateNewsPage.enterPreviewText(DataGenerator.generateRandomText(10, 100));    adminCreateNewsPage.enterFullText(DataGenerator.generateRandomText(100, 200));    adminCreateNewsPage.selectTheCheckboxUserGroup();    adminCreateNewsPage.selectTheCheckboxFilial();    adminCreateNewsPage.selectTheCheckboxChannelSale();    adminCreateNewsPage.clickOnThePublishNowButton();    adminEditNewsPage.checkingTheTextNewsEditing();    adminEditNewsPage.clickOnTheCloseButton();    adminNewsListPage.checkNewsTitleInList(generatedTitle);    adminNewsListPage.checkNewsStatus(generatedTitle, "ACTIVE");}

Такой тест нужен. Он проверяет пользовательский путь:

  • открытие формы;

  • ввод данных;

  • выбор чекбоксов;

  • публикацию;

  • переход на экран редактирования;

  • отображение записи в списке.

Но если нужно проверить 20 вариантов валидации title или прав доступа, это уже зона API. UI должен подтвердить, что форма работает, а не становиться основным механизмом проверки всех правил.

Параллелизация CI: что реально ускорило автотесты

Самый заметный эффект был в одном из крупных наборов, где много интеграционных API-тестов и отдельный набор UI/E2E. После настройки параллельных профилей время прогона автотестов в CI сократилось примерно с 16 минут до 7–8 минут.

Что сделали:

  • API-тесты запускаются отдельным профилем;

  • для API включена параллельность методов в 2 потока;

  • классы API идут контролируемо, чтобы не ломать зависимые тестовые данные;

  • UI/E2E запускаются отдельным профилем;

  • UI-классы распараллелены, но логин защищен lock-ом;

  • cleanup вынесен в JUnit extensions и DB helpers.

Упрощенный пример parallel-api профиля:

<profile>    <id>parallel-api</id>    <properties>        <tag>API</tag>    </properties>    <build>        <plugins>            <plugin>                <groupId>org.apache.maven.plugins</groupId>                <artifactId>maven-surefire-plugin</artifactId>                <configuration>                    <groups>${tag}</groups>                    <forkCount>1</forkCount>                    <reuseForks>true</reuseForks>                    <systemPropertyVariables>                        <junit.jupiter.execution.parallel.enabled>true</junit.jupiter.execution.parallel.enabled>                        <junit.jupiter.execution.parallel.mode.default>concurrent</junit.jupiter.execution.parallel.mode.default>                        <junit.jupiter.execution.parallel.mode.classes.default>same_thread</junit.jupiter.execution.parallel.mode.classes.default>                        <junit.jupiter.execution.parallel.config.strategy>fixed</junit.jupiter.execution.parallel.config.strategy>                        <junit.jupiter.execution.parallel.config.fixed.parallelism>2</junit.jupiter.execution.parallel.config.fixed.parallelism>                        <junit.jupiter.testclass.order.default>                            ru.example.tests.junit.ExecutionOrderClassOrderer                        </junit.jupiter.testclass.order.default>                    </systemPropertyVariables>                </configuration>            </plugin>        </plugins>    </build></profile>

Почему классы идут same_thread, а методы concurrent? Потому что в одном из фреймворков есть общие тестовые данные, cleanup и порядок классов. Полностью хаотичная параллельность дала бы нестабильность. Поэтому мы выбрали компромисс: ускоряем методы внутри контролируемой структуры.

Для UI-профиля параллелизация другая: классы могут идти конкурентно, но авторизация защищена глобальным lock-ом, чтобы браузерные сессии не конфликтовали в общей инфраструктуре. Дополнительно используется @TestInstance(TestInstance.Lifecycle.PER_CLASS), чтобы один экземпляр тестового класса переиспользовался для всех тестовых методов, а токен авторизации не получался заново для каждого теста.

Что еще ускорило обратную связь

Параллелизация — только часть истории. Основное ускорение появилось из-за того, что мы перестали проверять бизнес-логику через браузер там, где это не нужно.

Что помогло:

  • API-клиенты скрыли HTTP-детали и сделали добавление сценариев дешевле.

  • Builders позволили быстро собирать валидные request body.

  • Providers позволили масштабировать негативные API-проверки без раздувания UI-набора.

  • Steps вынесли повторяемые ассерты из тестов.

  • Follow-up GET/list проверки позволили надежно проверять состояние без UI.

  • API setup для UI сократил подготовительные действия в браузере.

  • DB cleanup уменьшил зависимость тестов друг от друга.

  • Token cache в большом API-наборе снизил лишние auth-вызовы.

  • Разделение профилей API и UI позволило запускать нужный слой отдельно.

Главное: мы не удалили проверки. Мы перенесли их туда, где они дешевле и стабильнее.

Например, проверка “нельзя создать сущность без обязательного поля” может быть API-тестом. UI при этом оставляет один сценарий: форма показывает ошибку пользователю. Это разные риски, и проверять их лучше на разных слоях.

API-first работает не только потому, что HTTP быстрее браузера. Он работает потому, что архитектура тестов позволяет дешево добавлять новые проверки: builder дает валидную базу, provider масштабирует входные данные, client скрывает HTTP, а steps удерживают ассерты в одном месте.

Ограничения подхода

API-first не решает все проблемы.

Во-первых, API-тест не проверит, что пользователь реально может нажать кнопку. Для этого нужен UI.

Во-вторых, API-тесты требуют поддержки клиентов, DTO и builders. Если контракт меняется, тестовый код тоже нужно обновлять.

В-третьих, нужен доступ к API и понимание домена. Проверять только статус-код недостаточно. Нужны бизнес-ассерты.

В-четвертых, DB helpers требуют аккуратности. Cleanup должен быть ограничен тестовыми данными, иначе можно повредить окружение.

В-пятых, E2E все равно нужны. Но их должно быть немного: только критичные сквозные процессы, где важно проверить связку нескольких частей системы.

Вывод

API-first в автотестах — это не про отказ от UI. Это про зрелое распределение проверок.

В нашем случае правило 80/15/5 помогло разгрузить UI-слой:

  • API забрал бизнес-логику, валидации, права и интеграционные проверки;

  • UI остался для пользовательских сценариев и отображения;

  • E2E остался для нескольких критичных сквозных процессов.

На практике это дало более быструю обратную связь: в одном из крупных наборов время прогона автотестов в CI сократилось примерно с 16 минут до 7–8 минут за счет API-first распределения и параллельных профилей запуска.

Самое важное — не само число минут, а принцип. Тестовая стратегия должна отвечать на вопрос: на каком слое дешевле, надежнее и понятнее проверить конкретный риск?

Если риск в бизнес-логике — чаще всего это API. Если риск в пользовательском взаимодействии — UI. Если риск в сквозной связке систем — E2E.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1046313/