Автоматизация API-тестирования на Java: от ручных проверок к надежному фреймворку

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

Проектирование фундамента: настройка стека Java, Maven и RestAssured

Проектирование фундамента: настройка стека Java, Maven и RestAssured

Представьте, что банк запускает новую версию системы управления потоком клиентов (aeqs-api-test). Разработчики обновили логику выдачи талонов, и вам нужно убедиться, что API работает корректно. В Postman вы нажимаете кнопку «Send» и проверяете статус 200. Завтра выйдет новый патч — и вы снова нажмете эту кнопку. Через месяц таких эндпоинтов станет пятьдесят, а релизы начнут выходить каждый день. Ручная проверка превратится в бесконечный цикл копирования токенов и пролистывания JSON-ответов глазами.

Чтобы разорвать этот цикл, тестировщики переносят логику проверок в код. Код не устает, выполняется за секунды и запускается автоматически при каждом обновлении системы. В этой статье мы заложим фундамент для такого автотестирования.

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

Переход от Postman к коду означает, что теперь вы сами собираете инструмент для тестирования из готовых блоков. Для работы с API на Java индустриальным стандартом стал следующий набор технологий:

  1. Java — базовый язык программирования, на котором мы будем писать логику.
  2. Maven — система сборки. Это ваш «завхоз». Вместо того чтобы вручную скачивать библиотеки из интернета, вы пишете список нужных инструментов в специальном файле, а Maven сам их находит, скачивает и подключает к проекту.
  3. JUnit — фреймворк для запуска тестов. Именно он понимает, какие куски кода являются тестами, запускает их по очереди и выдает зеленый или красный свет по итогу.
  4. RestAssured — библиотека для отправки HTTP-запросов и проверки ответов. Это наш программный аналог Postman.

Сборка фундамента: Maven

Любой современный Java-проект начинается с Maven (или его аналога Gradle). Сердце Maven-проекта — файл pom.xml (Project Object Model). В нем мы указываем зависимости (dependencies) — те самые библиотеки, которые нужны нам для работы.

Чтобы научить наш пустой Java-проект делать HTTP-запросы и запускать тесты, добавим в pom.xml две зависимости: RestAssured и JUnit.

<dependencies>
    <!-- Библиотека для работы с API -->
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <version>5.3.0</version>
        <scope>test</scope>
    </dependency>

    <!-- Фреймворк для запуска тестов -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Тег <scope>test</scope> говорит Maven, что эти библиотеки нужны только для тестирования и не должны попасть в итоговую сборку самого приложения (если бы мы писали само приложение, а не только тесты к нему).

Первый тест: от Postman к RestAssured

Давайте автоматизируем простую проверку: убедимся, что сервис управления очередью aeqs-api-test жив и возвращает статус 200 на запрос получения списка услуг.

В Postman вы бы выбрали метод GET, вставили URL http://aeqs-api-test.local/api/v1/services и нажали Send. RestAssured использует подход BDD (Behavior-Driven Development), который делит любой тест на три логических блока, читающихся как обычное предложение на английском: Given (Дано), When (Когда), Then (Тогда).

Вот как выглядит наш первый автотест на Java:

import static io.restassured.RestAssured.*;
import org.junit.jupiter.api.Test;

public class QueueServicesTest {

    @Test
    public void checkServicesEndpointIsAlive() {
        given()
            .baseUri("http://aeqs-api-test.local") // Подготовка (URL, заголовки, параметры)
        .when()
            .get("/api/v1/services")               // Действие (HTTP-метод и эндпоинт)
        .then()
            .statusCode(200);                      // Проверка (ожидаемый результат)
    }
}

Разберем анатомию этого кода:

  • Аннотация @Test перед методом — это сигнал для JUnit. Увидев ее, JUnit понимает: «Ага, этот метод нужно запустить как независимый тест».
  • В блоке given() мы собираем запрос. Это аналог вкладок Params, Authorization и Headers в Postman. Здесь мы указываем базовый адрес системы.
  • В блоке when() происходит само действие — отправка запроса. Это аналог кнопки Send. Мы указываем конкретный метод (GET) и путь (path).
  • В блоке then() мы валидируем ответ. Это аналог вкладки Tests в Postman. Если сервер вернет 404 или 500, RestAssured выбросит ошибку, и JUnit пометит тест как упавший (красный).

Использование given().when().then() делает код самодокументируемым. Любой член команды, даже не зная глубоко Java, сможет прочитать тест и понять, что именно он проверяет.

Мы заложили фундамент: проект собирается, запросы отправляются, а статусы ответов проверяются. Но в реальной жизни проверки редко ограничиваются статус-кодом. Банковская система возвращает сложные JSON-структуры с талонами, номерами окон и временем ожидания. О том, как элегантно извлекать эти данные и превращать их в Java-объекты для глубоких проверок, мы поговорим на следующем этапе.

Моделирование данных: использование POJO и Jackson для работы с JSON-ответами системы

Моделирование данных: использование POJO и Jackson для работы с JSON-ответами системы

Успешный статус-код 200 OK от сервера означает лишь то, что сервер принял запрос и не упал при его обработке. Если при запросе списка услуг в системе управления очередью aeqs-api-test сервер вернет пустой массив [] или сообщение об ошибке {"error": "База данных недоступна"} вместе со статусом 200, базовый тест покажет зеленый свет. Реальный же клиент при этом не сможет взять талон к операционисту. Чтобы тест действительно проверял бизнес-логику, необходимо заглянуть внутрь тела ответа (Response Body) и валидировать его содержимое.

Ограничения прямого чтения JSON

Представим, что эндпоинт /api/v1/services/1 возвращает информацию о конкретной банковской услуге в формате JSON:

{
  "id": 1,
  "name": "Оформление ипотеки",
  "averageProcessingTimeMin": 45,
  "isActive": true
}

Самый быстрый способ достать значение из этого ответа — использовать встроенный в RestAssured механизм JSONPath, который позволяет обращаться к полям по их строковым путям:

String serviceName = response.path("name");
int time = response.path("averageProcessingTimeMin");

Для разовой проверки одного поля этот подход работает отлично. Но при построении надежного фреймворка он быстро становится узким местом.

Критерий JSONPath (response.path("...")) Строгая типизация (Java Объекты)
Опечатки Компилятор не заметит ошибку в строке "averageProcesingTime". Тест упадет только во время выполнения. Опечатку в имени метода getTime() подсветит IDE до запуска.
Рефакторинг При изменении структуры ответа придется вручную искать и менять строки во всех тестах. Изменение структуры требует правки только в одном классе-модели.
Автодополнение Отсутствует. Инженер должен держать структуру JSON в голове. IDE предлагает доступные поля через точку (Code Completion).

Чтобы получить преимущества строгой типизации Java при работе с текстовым форматом JSON, применяется моделирование данных.

POJO как контракт данных

Для представления структуры JSON в коде используются POJO.

POJO (Plain Old Java Object) — «простой старый Java-объект». Это обычный класс, который не наследуется от специфичных фреймворков, не реализует сложных интерфейсов и содержит только поля данных, а также методы для доступа к ним (геттеры и сеттеры).

В контексте API-тестирования такие классы часто называют DTO (Data Transfer Object) — объект передачи данных. Создадим модель для нашей банковской услуги:

public class ServiceDto {
    private int id;
    private String name;
    private int averageProcessingTimeMin;
    private boolean isActive;

    // Пустой конструктор (обязателен для десериализации)
    public ServiceDto() {}

    // Геттеры
    public int getId() { return id; }
    public String getName() { return name; }
    public int getAverageProcessingTimeMin() { return averageProcessingTimeMin; }
    public boolean isActive() { return isActive; }

    // Сеттеры
    public void setId(int id) { this.id = id; }
    public void setName(String name) { this.name = name; }
    public void setAverageProcessingTimeMin(int averageProcessingTimeMin) {
        this.averageProcessingTimeMin = averageProcessingTimeMin;
    }
    public void setActive(boolean active) { isActive = active; }
}

Этот класс становится контрактом: мы ожидаем, что система вернет данные именно в таком формате.

Магия десериализации и Jackson

Процесс превращения текста (JSON) в готовый Java-объект (POJO) называется десериализацией. В экосистеме Java стандартом де-факто для этой задачи является библиотека Jackson. RestAssured использует её под капотом автоматически, если она добавлена в зависимости проекта.

Когда RestAssured получает JSON, он передает его в Jackson. Библиотека создает пустой объект ServiceDto (используя пустой конструктор), затем читает ключи из JSON и ищет в Java-классе соответствующие сеттеры или поля, чтобы заполнить их значениями.

API развиваются: разработчики могут добавить в ответ новое поле, например, "department": "Credit". По умолчанию Jackson выбросит ошибку UnrecognizedPropertyException, так как не найдет такого поля в нашем ServiceDto. В тестировании мы часто проверяем только часть полей, игнорируя остальные. Чтобы тесты не падали при расширении API, класс-модель защищают специальной аннотацией:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public class ServiceDto {
    // ... поля класса
}

Валидация ответа через объекты

Теперь соединим выполнение запроса и проверку данных. Метод as() в RestAssured запускает процесс десериализации, возвращая готовый объект:

import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;

public class ServiceApiTest {

    @Test
    public void checkMortgageServiceData() {
        // 1. Выполняем запрос и преобразуем ответ в объект ServiceDto
        ServiceDto service = given()
            .baseUri("http://aeqs-api-test.local")
        .when()
            .get("/api/v1/services/1")
        .then()
            .statusCode(200)
            .extract()
            .as(ServiceDto.class);

        // 2. Валидируем бизнес-данные стандартными средствами JUnit
        assertEquals(1, service.getId(), "ID услуги не совпадает");
        assertEquals("Оформление ипотеки", service.getName(), "Неверное название услуги");
        assertTrue(service.isActive(), "Услуга должна быть активна");
    }
}

Работа с массивами данных

Чаще всего клиентское приложение запрашивает не одну услугу, а весь список доступных опций для отображения на экране терминала. Эндпоинт /api/v1/services возвращает массив объектов [ {...}, {...} ].

Jackson легко справляется и с коллекциями. Достаточно указать, что мы ожидаем массив объектов — ServiceDto[].class:

@Test
public void checkAllServicesList() {
    ServiceDto[] services = given()
        .baseUri("http://aeqs-api-test.local")
    .when()
        .get("/api/v1/services")
    .then()
        .statusCode(200)
        .extract()
        .as(ServiceDto[].class);

    assertTrue(services.length > 0, "Список услуг пуст");

    // Теперь мы можем использовать всю мощь Java для работы с данными
    boolean hasCashOperations = false;
    for (ServiceDto s : services) {
        if (s.getName().equals("Кассовые операции")) {
            hasCashOperations = true;
            break;
        }
    }
    assertTrue(hasCashOperations, "В списке нет кассовых операций");
}

Переведя данные из текстового формата в объекты, мы получаем полный контроль над ними. Эти данные можно не только проверять, но и сохранять в память, чтобы передавать в следующие запросы — например, получить ID услуги «Кассовые операции» и использовать его для генерации нового талона в очередь.

Валидация бизнес-сценариев: построение цепочек запросов и проверка состояний очереди

Валидация бизнес-сценариев: построение цепочек запросов и проверка состояний очереди

Клиент банка не приходит в отделение ради того, чтобы просто создать запись в базе данных. Он нажимает кнопку на терминале, получает талон «Ипотека — А-12», садится в кресло, ждет вызова на табло, подходит к третьему окну и решает свой вопрос. Если мы будем тестировать API выдачи талонов изолированно от API вызова клиента, мы докажем лишь то, что эндпоинты не отдают пятисотые ошибки. Но мы не узнаем, работает ли сама очередь.

Мы уже научились извлекать данные из JSON-ответов и сохранять их в Java-объекты. Теперь эти изолированные объекты предстоит связать в единый поток, чтобы автоматизировать реальный пользовательский путь.

От статики к динамике: зачем нужны цепочки запросов

Протокол HTTP — это протокол без сохранения состояния (stateless). Каждый запрос существует сам по себе и ничего не знает о предыдущих. Однако бизнес-логика системы управления потоком клиентов aeqs-api-test построена именно на состоянии.

Цепочка запросов (Request chaining) — это паттерн тестирования, при котором данные, полученные в ответе одного API-вызова, сохраняются в переменные и используются как входные параметры (в URL, заголовках или теле) для последующих вызовов.

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

Проектирование сценария «Жизненный цикл клиента»

Прежде чем писать код, зафиксируем шаги, которые проходит клиент в системе. Сценарий должен проверять не только успешность действий (статус 200), но и корректную смену бизнес-статусов.

Шаг Действие (API) Ожидаемый результат Цель в контексте цепочки
1 POST /api/v1/tickets (создание талона) Талон создан, статус WAITING Получить уникальный id талона для дальнейшей работы
2 GET /api/v1/tickets/{id} (проверка талона) Возвращается талон со статусом WAITING Убедиться, что талон реально встал в очередь
3 POST /api/v1/operator/call (вызов к окну) Оператор вызывает талон id к окну №3 Инициировать смену состояния в системе
4 GET /api/v1/tickets/{id} (проверка талона) Возвращается талон со статусом IN_PROGRESS Подтвердить, что бизнес-логика отработала корректно

Передача состояния: Path-параметры в RestAssured

На втором и четвертом шагах нам нужно обратиться к конкретному талону по URL вида /api/v1/tickets/105. Жестко прописывать число 105 (хардкод) нельзя: при каждом запуске теста создается новый талон с новым ID.

RestAssured предоставляет элегантный механизм для работы с динамическими URL — метод pathParam. Он позволяет задать шаблон в URL с помощью фигурных скобок {имя_переменной} и передать значение для подстановки.

Сборка цепочки в коде

Для реализации сценария нам понадобится DTO, с которым мы работали ранее. Допустим, у нас есть класс TicketDto с полями id, ticketNumber и status.

Реализуем наш бизнес-сценарий в одном тестовом методе, последовательно передавая состояние:

import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class QueueLifecycleTest {

    @Test
    public void checkTicketLifecycleFromWaitingToInProgress() {
        // ШАГ 1: Создаем талон на услугу (например, serviceId = 1)
        // Используем строковую переменную для простоты тела запроса
        String requestBody = "{ \"serviceId\": 1 }";

        TicketDto createdTicket = given()
            .header("Content-Type", "application/json")
            .body(requestBody)
        .when()
            .post("/api/v1/tickets")
        .then()
            .statusCode(201)
            .extract().as(TicketDto.class); // Десериализуем ответ в POJO

        // Сохраняем ID созданного талона
        Long currentTicketId = createdTicket.getId();

        // Проверяем начальное состояние
        assertEquals("WAITING", createdTicket.getStatus(), "Талон должен быть в статусе ожидания");

        // ШАГ 2: Убеждаемся, что талон доступен по GET-запросу
        TicketDto waitingTicket = given()
            .pathParam("id", currentTicketId) // Подставляем ID в URL
        .when()
            .get("/api/v1/tickets/{id}")
        .then()
            .statusCode(200)
            .extract().as(TicketDto.class);

        assertEquals("WAITING", waitingTicket.getStatus());

        // ШАГ 3: Оператор вызывает талон к окну №3
        String callBody = "{ \"ticketId\": " + currentTicketId + ", \"windowNumber\": 3 }";

        given()
            .header("Content-Type", "application/json")
            .body(callBody)
        .when()
            .post("/api/v1/operator/call")
        .then()
            .statusCode(200);

        // ШАГ 4: Проверяем, что статус талона изменился
        TicketDto inProgressTicket = given()
            .pathParam("id", currentTicketId)
        .when()
            .get("/api/v1/tickets/{id}")
        .then()
            .statusCode(200)
            .extract().as(TicketDto.class);

        // Финальная валидация бизнес-логики
        assertEquals("IN_PROGRESS", inProgressTicket.getStatus(), "Статус талона не изменился после вызова");
    }
}

В этом тесте мы опираемся на возможности десериализации (сохранение ответа в TicketDto) и используем полученный currentTicketId как связующее звено (state) между четырьмя независимыми HTTP-запросами.

Цена длинных сценариев

Мы успешно автоматизировали сквозной бизнес-процесс. Тест выполняет свою задачу: он проверяет реальное поведение системы. Однако, посмотрите на код выше. Он стал громоздким.

В одном методе смешались:

  • Настройка HTTP-заголовков (Content-Type).
  • Маршрутизация (URL эндпоинтов).
  • Десериализация (extract().as()).
  • Сама суть теста (проверка смены статуса с WAITING на IN_PROGRESS).

Если завтра разработчики изменят базовый путь с /api/v1/ на /api/v2/, нам придется исправлять URL внутри каждого шага в каждом тесте. Это делает поддержку автоматизации болезненной. Чтобы тесты оставались надежными и легко читаемыми, техническую реализацию запросов необходимо отделить от бизнес-логики проверок.

Архитектурный паттерн API Steps: разделение логики тестов и реализации запросов

Архитектурный паттерн API Steps: разделение логики тестов и реализации запросов

Представьте, что вы написали идеальный сквозной тест жизненного цикла талона. Он работает, но занимает 80 строк кода, состоящих из сплошных given().header().body().when().post().then(). А теперь представьте, что разработчики добавили обязательный заголовок авторизации для всех эндпоинтов. Вам придется открыть каждый из пятидесяти написанных тестов и вручную добавить туда новую строку. Ваш фреймворк стал хрупким, а поддержка тестов начала отнимать больше времени, чем их написание.

Проблема кроется в смешении уровней абстракции. Тест одновременно описывает что мы проверяем (бизнес-логику) и как мы это делаем (техническую реализацию HTTP-запросов).

Чтобы разорвать эту зависимость, применяется архитектурный паттерн API Steps.

Разделение ответственности

Главный принцип надежного тестового фреймворка — каждый компонент должен отвечать только за свою задачу.

Тест должен читаться как инструкция для пользователя, описывающая шаги и ожидаемый результат, не погружая читателя в детали сетевого протокола.

Мы вводим новый слой абстракции — классы шагов (Steps). Это посредники между вашими тестами и библиотекой RestAssured.

Компонент Зона ответственности Что содержит внутри
Класс шагов (Steps) Техническая реализация взаимодействия с API HTTP-методы, URL, заголовки, сериализация, базовая проверка статус-кодов (например, 200 OK).
Тестовый класс (Tests) Бизнес-логика и сценарии использования Вызовы методов из классов шагов, передача тестовых данных, ассерты (проверки состояний объектов).

Проектирование класса шагов для системы очередей

Давайте вынесем логику работы с талонами из нашего E2E-теста в отдельный класс TicketSteps.

Методы в этом классе должны инкапсулировать всю работу с RestAssured и возвращать готовые POJO-объекты (наши DTO), чтобы тестовый класс вообще ничего не знал о том, как именно данные были получены.

import io.restassured.RestAssured;
import io.restassured.http.ContentType;

public class TicketSteps {

    private static final String TICKETS_ENDPOINT = "/api/v1/tickets";

    // Шаг создания талона
    public TicketDto createTicket(String serviceId) {
        String requestBody = "{ \"serviceId\": \"" + serviceId + "\" }";

        return RestAssured.given()
                .contentType(ContentType.JSON)
                .body(requestBody)
                .when()
                .post(TICKETS_ENDPOINT)
                .then()
                .statusCode(201) // Техническая проверка успешности запроса
                .extract()
                .as(TicketDto.class); // Возвращаем готовый Java-объект
    }

    // Шаг вызова талона оператором
    public TicketDto callTicket(String ticketId, String operatorId) {
        return RestAssured.given()
                .pathParam("id", ticketId)
                .queryParam("operator", operatorId)
                .when()
                .put(TICKETS_ENDPOINT + "/{id}/call")
                .then()
                .statusCode(200)
                .extract()
                .as(TicketDto.class);
    }
}

Обратите внимание: мы оставили внутри шагов statusCode(200) и statusCode(201). Это технические проверки. Если сервер ответил ошибкой 500, нет смысла пытаться парсить ответ и проверять бизнес-логику — тест должен упасть немедленно прямо на этом шаге.

Трансформация сквозного теста

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

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class TicketLifecycleTest {

    private final TicketSteps ticketSteps = new TicketSteps();

    @Test
    public void testTicketCanBeCalledByOperator() {
        // 1. Подготовка данных
        String serviceId = "MORTGAGE_01";
        String operatorId = "OP_777";

        // 2. Действие: Создание талона
        TicketDto newTicket = ticketSteps.createTicket(serviceId);

        // Проверка бизнес-логики после создания
        Assertions.assertEquals("WAITING", newTicket.getStatus(),
            "Новый талон должен быть в статусе ожидания");

        // 3. Действие: Вызов талона
        TicketDto calledTicket = ticketSteps.callTicket(newTicket.getId(), operatorId);

        // 4. Проверка финального состояния
        Assertions.assertEquals("IN_PROGRESS", calledTicket.getStatus(),
            "После вызова статус талона должен измениться");
        Assertions.assertEquals(operatorId, calledTicket.getOperatorId(),
            "Талон должен быть привязан к вызвавшему оператору");
    }
}

Сравните этот код с тем, что мы писали ранее. Тестовый метод превратился в чистую, читаемую историю. Мы создали свой собственный предметно-ориентированный язык (DSL) для тестирования системы aeqs-api-test.

Если завтра изменится базовый URL, добавится токен авторизации или поменяется структура JSON-запроса при создании талона, мы внесем изменения ровно в одном месте — внутри метода createTicket класса TicketSteps. Сам тестовый сценарий останется нетронутым, так как бизнес-смысл операции не изменился.

Построив такую архитектуру, мы решили проблему читаемости и поддержки кода. Однако в нашем тесте все еще жестко зашиты тестовые данные: идентификаторы услуг (MORTGAGE_01) и операторов (OP_777). Чтобы сделать тесты по-настоящему гибкими и независимыми от окружения, нам потребуется научиться динамически управлять тестовыми данными и параметризировать запуски.

Обеспечение стабильности: управление тестовыми данными и параметризация тестов

Обеспечение стабильности: управление тестовыми данными и параметризация тестов

В пятницу вечером вы запускаете набор автотестов — все проверки горят зеленым. В понедельник утром половина из них падает с ошибками. Код тестов не менялся, код приложения тоже. Начинается расследование, и выясняется: кто-то из разработчиков удалил из базы данных услугу с ID 12, а в ваших тестах было жестко прописано создание талона именно на эту услугу. Или другой вариант: тест пытался зарегистрировать клиента с номером телефона +79991234567, но база данных ответила ошибкой, потому что клиент с таким номером уже был создан при прошлом запуске.

Подобные ситуации порождают так называемые «мигающие» тесты (flaky tests) — они то проходят, то падают в зависимости от внешних условий. Доверие к таким тестам быстро стремится к нулю.

В прошлой главе мы вынесли логику запросов в паттерн API Steps, сделав код читаемым. Теперь наша задача — сделать его стабильным. Для этого нужно отвязать тесты от статических данных и научить их работать с динамическими наборами.

Ловушка жесткого кодирования

Хардкод (Hardcode) — антипаттерн разработки, при котором данные встраиваются непосредственно в исходный код программы, а не получаются из внешних источников или генерируются динамически.

Посмотрим на типичный тест начинающего автоматизатора системы aeqs-api-test:

@Test
void createTicketTest() {
    String serviceId = "MORTGAGE";
    String clientName = "Иван Иванов";
    String phone = "+79001112233";

    TicketDto ticket = ticketSteps.createTicket(serviceId, clientName, phone);
    assertEquals("WAITING", ticket.getStatus());
}

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

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

Динамическая генерация: библиотека DataFaker

Вместо того чтобы придумывать данные самостоятельно, мы можем поручить это специальным библиотекам. В экосистеме Java стандартом де-факто стала библиотека DataFaker (современный форк устаревшего JavaFaker).

Она умеет генерировать реалистичные, но случайные данные: имена, адреса, номера телефонов, СНИЛС и даже номера кредитных карт.

Добавим зависимость в pom.xml, создадим экземпляр генератора с русской локализацией и перепишем наш тест:

Faker faker = new Faker(new Locale("ru"));

@Test
void createTicketWithDynamicDataTest() {
    String serviceId = "MORTGAGE";
    String clientName = faker.name().fullName(); // Сгенерирует "Петр Сергеевич Смирнов"
    String phone = faker.phoneNumber().phoneNumber(); // Сгенерирует случайный номер

    TicketDto ticket = ticketSteps.createTicket(serviceId, clientName, phone);
    assertEquals("WAITING", ticket.getStatus());
}

Теперь при каждом запуске тест использует новые данные. Мы решили проблему уникальности, но осталась другая проблема: покрытие бизнес-логики. В банке есть не только ипотека, но и вклады, карты, кассовое обслуживание. Писать отдельный тест (копипастить код) под каждую услугу — значит плодить дублирование.

Параметризация: один сценарий, множество условий

Когда нам нужно проверить одну и ту же логику на разных наборах данных, применяется подход Data-Driven Testing (DDT). В JUnit 5 для этого существует мощный инструмент — параметризованные тесты.

Вместо аннотации @Test мы используем @ParameterizedTest, а источник данных указываем с помощью специальных аннотаций, например @ValueSource.

@ParameterizedTest
@ValueSource(strings = {"MORTGAGE", "DEPOSITS", "CARDS", "CASHIER"})
void shouldCreateTicketForDifferentServiceTypes(String serviceId) {
    String clientName = faker.name().fullName();
    String phone = faker.phoneNumber().phoneNumber();

    TicketDto ticket = ticketSteps.createTicket(serviceId, clientName, phone);

    assertEquals("WAITING", ticket.getStatus());
    assertEquals(serviceId, ticket.getServiceId());
}

Как это работает: JUnit возьмет массив строк из @ValueSource и запустит метод shouldCreateTicketForDifferentServiceTypes четыре раза, поочередно подставляя каждую строку в аргумент serviceId.

Продвинутая передача данных: @MethodSource

Аннотация @ValueSource удобна для передачи одного простого параметра (строки или числа). Но что, если для каждого типа услуги нам нужно передать ожидаемый приоритет в очереди? Ипотека обслуживается медленнее, поэтому ей выдается приоритет HIGH, а кассе — NORMAL.

Для передачи сложных наборов данных используется @MethodSource. Мы создаем отдельный метод, который возвращает поток аргументов, и указываем его имя в аннотации:

static Stream<Arguments> serviceAndPriorityProvider() {
    return Stream.of(
        Arguments.of("MORTGAGE", "HIGH"),
        Arguments.of("DEPOSITS", "NORMAL"),
        Arguments.of("CASHIER", "NORMAL")
    );
}

@ParameterizedTest
@MethodSource("serviceAndPriorityProvider")
void shouldAssignCorrectPriority(String serviceId, String expectedPriority) {
    TicketDto ticket = ticketSteps.createTicket(serviceId, faker.name().fullName(), "123");
    assertEquals(expectedPriority, ticket.getPriority());
}

Сравнение подходов к данным

Подход Суть Когда применять
Хардкод Статичные значения прямо в тесте ("Иван") Только для констант, которые никогда не меняются (например, код валюты RUB).
Генерация (Faker) Случайные реалистичные данные для каждого запуска Для полей, требующих уникальности (email, телефон, паспорт), или когда конкретное значение не влияет на логику.
Параметризация Прогон одного теста на заранее заданном массиве данных Для проверки граничных условий, разных типов пользователей или категорий услуг.

Изоляция тестовых прогонов

Важнейшее свойство параметризованных тестов в JUnit 5 — изоляция. Каждый набор данных воспринимается фреймворком как абсолютно независимый тест.

Если при прогоне массива {"MORTGAGE", "DEPOSITS", "CARDS"} создание талона на вклады (DEPOSITS) упадет из-за бага в API, тест для карт (CARDS) все равно будет запущен и проверен. Это позволяет за один прогон собрать полную картину работоспособности системы, а не останавливать проверку при первой же ошибке.

Объединив API Steps, динамическую генерацию данных и параметризацию, мы получили надежный фундамент. Наши тесты легко читаются, не ломаются из-за конфликтов в базе данных и проверяют десятки комбинаций за пару строк кода.

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

Поддержка и масштабирование: логирование, отчетность в Allure и работа с изменениями API

Поддержка и масштабирование: логирование, отчетность в Allure и работа с изменениями API

Представьте: ночью CI/CD-сервер запустил 150 параметризованных тестов, а утром вы видите, что 12 из них упали с лаконичной ошибкой AssertionError: expected <IN_PROGRESS> but was <WAITING>. Баг ли это бэкенда, сбой генерации данных или таймаут сети? Без доступа к телам запросов и ответов вы абсолютно слепы. На расследование уйдут часы, хотя ответ мог бы лежать на поверхности.

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

Вывод системы из тени: глобальное логирование

По умолчанию RestAssured работает «молча». Он отправляет HTTP-вызовы и возвращает объекты, скрывая сырую транзакцию. В первых тестах мы могли использовать метод .log().all() прямо в цепочке вызова. Но когда тестов сотни, а логика скрыта внутри паттерна API Steps, расставлять ручное логирование в каждом методе — путь к дублированию кода.

Правильный архитектурный подход — перехватывать трафик на уровне конфигурации клиента с помощью фильтров.

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

import io.restassured.builder.RequestSpecBuilder;
import io.restassured.filter.log.RequestLoggingFilter;
import io.restassured.filter.log.ResponseLoggingFilter;
import io.restassured.specification.RequestSpecification;

public class ApiConfig {
    public static RequestSpecification getBaseSpec() {
        return new RequestSpecBuilder()
            .setBaseUri("http://localhost:8080")
            .setBasePath("/api/v1")
            .addFilter(new RequestLoggingFilter())
            .addFilter(new ResponseLoggingFilter())
            .build();
    }
}

Теперь, передав эту спецификацию в наши классы шагов (например, TicketSteps), мы гарантируем, что каждый HTTP-вызов оставит подробный след в консоли: куда ушел запрос, с какими заголовками, каким телом, и что в точности ответил сервер.

От консоли к бизнес-отчетам: интеграция Allure

Логи в консоли идеальны для инженера, который отлаживает упавший тест локально. Но для команды разработки, менеджеров и релизных циклов стена текста бесполезна. Им нужен ответ на вопрос: «Работает ли выдача ипотечных талонов?».

Здесь на сцену выходит Allure — фреймворк для создания наглядных интерактивных отчетов тестирования. Он переводит технические результаты выполнения JUnit в понятный бизнес-язык.

Allure строит отчет на основе аннотаций, которыми мы размечаем наш код. Главная сила Allure раскрывается в связке с паттерном API Steps, который мы внедрили ранее.

Разметка шагов

Аннотация @Step вешается на методы наших вспомогательных классов. Текст внутри аннотации поддерживает форматирование и подстановку аргументов метода.

import io.qameta.allure.Step;

public class TicketSteps {

    @Step("Создание талона для услуги: {serviceType}")
    public TicketDto createTicket(String serviceType) {
        // реализация вызова POST /tickets
    }

    @Step("Вызов талона с ID {ticketId} к окну оператора")
    public TicketDto callTicket(int ticketId) {
        // реализация вызова PUT /tickets/{id}/call
    }
}

Когда тест упадет на этапе вызова к окну, в отчете Allure мы увидим не просто NullPointerException в строке 42, а четкое красное дерево:

  1. Создание талона для услуги: MORTGAGE (Зеленая галочка)
  2. Вызов талона с ID 105 к окну оператора (Красный крестик)

Разметка бизнес-сценариев

Сами тестовые классы и методы (наши E2E-сценарии) размечаются аннотациями иерархии: @Epic, @Feature, @Story. Это позволяет Allure сгруппировать сотни тестов в удобный каталог.

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

Управление изменениями API (Контрактная эволюция)

Любая живая система развивается. Допустим, банк внедряет новую систему приоритизации, и API управления потоком клиентов обновляется: вместо /api/v1/tickets появляется /api/v2/tickets.

Если базовые URL захардкожены в каждом классе шагов, стоимость поддержки фреймворка можно описать простой формулой:

C=N×TC = N \times T

Где CC — общие затраты времени на рефакторинг, NN — количество мест, где использован старый URL, а TT — время на исправление и проверку одного места. При росте числа тестов поддержка становится невыносимой.

Именно поэтому мы выносим конфигурацию в единый RequestSpecification (как показано в блоке ApiConfig выше). При смене версии API мы меняем .setBasePath("/api/v1") на /api/v2 ровно в одной строке кода. NN всегда равно 1, и затраты на рефакторинг минимальны.

Изменение структуры данных

Вторая частая проблема — изменение контракта ответа. Например, бэкенд начинает возвращать новое поле estimatedWaitTime в ответе на создание талона.

Поскольку в нашем фреймворке мы уже используем десериализацию Jackson с аннотацией игнорирования неизвестных свойств, старые тесты не упадут. Но если нам нужно начать проверять это поле, мы добавляем его только в один класс — TicketDto. Все тесты, использующие эту модель, автоматически получат доступ к новому полю через метод getEstimatedWaitTime().

Подмена окружений

Единая точка конфигурации также решает задачу запуска тестов на разных стендах (Dev, Test, Stage). Вместо жестко заданного http://localhost:8080, мы можем считывать URL из переменных окружения или properties-файлов:

String envUrl = System.getProperty("base.url", "http://localhost:8080");
// Передача envUrl в RequestSpecBuilder

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