Дисклеймер
Это не серебряная пуля и не универсальная догма.
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 остаются сценарными.
Тест в таком стиле читается как последовательность бизнес-действий:
-
Собрать request.
-
Вызвать client.
-
Проверить статус.
-
Десериализовать response.
-
Выполнить step-проверки.
-
При необходимости проверить состояние через 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/