Архитектура проекта автотестов на Java: Builder, Factory, fixtures и слои
Практический разбор структуры Java-проекта для QA Automation: как разделить tests, clients, pages, models и fixtures, где применять Builder и Factory и как не превратить фреймворк автотестов в набор Utils.
С чего начинается нормальная архитектура автотестов
Хороший проект автотестов начинается не с BaseTest и не с папки utils. Он начинается с границ ответственности. Тест должен описывать бизнес-сценарий, API-клиент — общение с сервисом, Page Object — действия с интерфейсом, fixture — подготовку и очистку данных, а model — данные без лишней логики.
Если один тест одновременно собирает JSON, вызывает REST Assured, пишет SQL, ищет элементы Selenide и вручную удаляет созданные данные — проблема не в длине теста. Проблема в том, что инфраструктурные детали протекли в сценарий.
Базовая структура Java-проекта автотестов
Для большинства проектов удобнее группировать код по ответственности, а не складывать всё рядом с тестами. Ниже — пример структуры, от которой можно отталкиваться. Это не догма: названия пакетов можно менять под домен команды.
1src/test/java
2├── tests
3│ ├── api
4│ ├── ui
5│ └── integration
6├── clients
7│ ├── OrderApiClient.java
8│ └── UserApiClient.java
9├── pages
10│ ├── LoginPage.java
11│ └── OrdersPage.java
12├── models
13│ ├── Order.java
14│ └── User.java
15├── factories
16│ ├── OrderFactory.java
17│ └── UserFactory.java
18├── fixtures
19│ ├── OrderFixture.java
20│ └── UserFixture.java
21├── repositories
22│ └── OrderRepository.java
23├── assertions
24│ └── OrderAssertions.java
25├── config
26│ └── TestConfig.java
27└── support
28 ├── ApiSpec.java
29 └── TestEnvironment.javaСамая важная идея: tests должны зависеть от более низких слоёв, но низкие слои не должны знать о конкретных тестах. OrderApiClient не должен содержать метод createOrderForShouldReturn400Test(). Клиент знает, как вызвать endpoint. Сценарий знает, зачем он это делает.
Каким должен быть сам тест
Тест лучше читать как короткий сценарий: подготовили данные, выполнили действие, проверили результат. Технические детали должны быть спрятаны за понятными методами предметной области.
1@Test
2void customerCanCreateOrder() {
3 var customer = userFixture.createCustomer();
4 var order = OrderFactory.validOrderFor(customer.id());
5
6 var createdOrder = orderApi.create(order);
7
8 assertThat(createdOrder.status()).isEqualTo("CREATED");
9 orderAssertions.assertStoredInDatabase(createdOrder.id());
10}Из этого теста понятно, что происходит, даже если читатель пока не знает, как устроен HTTP-запрос или SQL-проверка. Это хороший признак: тест описывает поведение системы, а не библиотеку, которой вы пользуетесь.
Builder: когда он действительно полезен
Builder полезен для сложных тестовых данных, где у объекта много необязательных полей и тесту нужно менять только 1–2 параметра. Особенно хорошо он работает для request DTO, событий Kafka и доменных моделей.
1var order = OrderRequest.builder()
2 .customerId(customer.id())
3 .restaurantId(42L)
4 .deliveryType(DeliveryType.COURIER)
5 .comment("leave at the door")
6 .build();Но Builder сам по себе не решает проблему тестовых данных. Если каждый тест вручную заполняет десять обязательных полей, код всё равно будет шумным. Поэтому Builder обычно удобнее сочетать с Factory.
Factory: готовые валидные объекты вместо копипаста
Factory отвечает на другой вопрос: не «как пошагово собрать объект», а «дай мне готовый объект нужного типа». Она особенно полезна, когда у системы есть стандартный валидный набор данных.
1public final class OrderFactory {
2
3 private OrderFactory() {}
4
5 public static OrderRequest validOrderFor(long customerId) {
6 return OrderRequest.builder()
7 .customerId(customerId)
8 .restaurantId(42L)
9 .deliveryType(DeliveryType.COURIER)
10 .items(List.of(new OrderItem(101L, 2)))
11 .build();
12 }
13}В тесте после этого меняется только то, что важно конкретному сценарию. Например, для негативного кейса можно взять валидный объект и изменить одно поле. В Java для этого часто удобно использовать toBuilder() у Lombok или явный метод copy.
1var invalidOrder = OrderFactory.validOrderFor(customer.id())
2 .toBuilder()
3 .restaurantId(null)
4 .build();Builder и Factory — не конкуренты
| Инструмент | Что решает | Когда применять |
|---|---|---|
| Builder | Удобная пошаговая сборка объекта | Много полей, много комбинаций, нужны точечные изменения |
| Factory | Создание готового осмысленного варианта данных | Нужны валидные дефолты и повторяемые сценарии |
| Fixture | Создание данных в реальной системе и cleanup | Нужно вызвать API/БД и удалить данные после теста |
Распространённая ошибка — называть Factory любой класс, который просто вызывает new. Хорошая Factory добавляет смысл: validCustomer(), premiumUser(), orderWithTwoItems(). Она кодирует допустимые варианты тестовых данных и делает тесты короче.
Fixture: где заканчивается Factory и начинается инфраструктура
Factory создаёт объект в памяти. Fixture создаёт состояние в тестируемой системе. Например, UserFactory может вернуть UserRequest, а UserFixture — вызвать API, получить id и зарегистрировать удаление созданного пользователя после теста.
1public class UserFixture {
2 private final UserApiClient userApi;
3 private final Deque<Long> createdUsers = new ArrayDeque<>();
4
5 public UserFixture(UserApiClient userApi) {
6 this.userApi = userApi;
7 }
8
9 public User createCustomer() {
10 var request = UserFactory.validCustomer();
11 var user = userApi.create(request);
12 createdUsers.push(user.id());
13 return user;
14 }
15
16 public void cleanup() {
17 while (!createdUsers.isEmpty()) {
18 userApi.delete(createdUsers.pop());
19 }
20 }
21}Так тест не обязан помнить, какие id нужно удалить. Cleanup становится частью жизненного цикла fixture. В реальном проекте это можно связать с JUnit 5 Extension, @AfterEach или отдельным FixtureScope.
API client: не превращайте его в тест
API client должен инкапсулировать HTTP-детали: URL, headers, сериализацию, спецификации REST Assured и разбор ответа. Но бизнес-проверки лучше не прятать внутрь клиента.
1public Order create(OrderRequest request) {
2 return given()
3 .spec(apiSpec)
4 .body(request)
5 .when()
6 .post("/api/orders")
7 .then()
8 .statusCode(201)
9 .extract().as(Order.class);
10}Если тесту нужно проверить 400, клиенту лучше дать низкоуровневый вариант метода, который возвращает Response, или отдельный метод без жёсткой проверки status code. Не стоит создавать десятки методов вроде createOrderExpecting400(), createOrderExpecting409() только ради разных assert.
Page Object: хранит поведение страницы, а не весь тест
Для UI действует тот же принцип. Page Object скрывает локаторы и простые действия, но не должен превращаться в огромный класс со всеми бизнес-сценариями приложения.
1public class LoginPage {
2 private final SelenideElement email = $("[data-testid=email]");
3 private final SelenideElement password = $("[data-testid=password]");
4 private final SelenideElement submit = $("[data-testid=login]");
5
6 public void login(String userEmail, String userPassword) {
7 email.setValue(userEmail);
8 password.setValue(userPassword);
9 submit.click();
10 }
11}Метод login() здесь уместен: это законченное действие на странице. А вот createCustomerAndPlaceOrderThroughUiAndCheckDatabase() — уже оркестрация нескольких слоёв, ей место в тесте или отдельном domain/service layer.
Нужен ли BaseTest
BaseTest не является обязательным паттерном. Маленький базовый класс с общим lifecycle допустим, но глубокая иерархия BaseApiTest → AuthenticatedApiTest → OrderBaseTest быстро создаёт скрытые зависимости. Через несколько месяцев разработчик уже не понимает, откуда взялась конфигурация, пользователь или cleanup.
- ▸Используйте composition чаще, чем inheritance.
- ▸Общие зависимости передавайте через fixture, extension или объект окружения.
- ▸Не храните изменяемое состояние тестов в static-полях.
- ▸Не создавайте BaseTest только потому, что «так принято».
Почему папка Utils часто становится проблемой
Utils обычно начинается с пары методов, а заканчивается DateUtils, JsonUtils, ApiUtils, TestUtils и сотнями несвязанных функций. Это признак потерянной ответственности. Метод генерации пользователя лучше держать в UserFactory, чтение конфигурации — в config, работу с JSON — рядом с сериализацией, а ожидание Kafka-события — в KafkaTestBus или специализированном helper.
Полезный вопрос при добавлении метода: к какому объекту предметной области или инфраструктуры он относится? Если ответ есть, скорее всего отдельный Utils ему не нужен.
Когда нужен отдельный service/domain layer
Если один и тот же бизнес-сценарий повторяется во многих тестах и включает несколько клиентов, можно добавить сервисный слой. Например, OrderSteps или OrderScenario может создать пользователя, оформить заказ и дождаться Kafka-события. Но такой слой стоит вводить только после появления реального повторения.
Не нужно заранее строить десять уровней абстракции. Хорошая архитектура тестов растёт вместе с проектом: сначала простой client + test, затем factory/fixture, а service layer появляется только там, где он уменьшает дублирование и делает сценарии понятнее.
Антипаттерны, которые чаще всего ломают тестовый проект
- ▸Огромный BaseTest, который создаёт половину инфраструктуры до каждого теста.
- ▸Статические mutable-поля с текущим пользователем, токеном или orderId.
- ▸Один ApiHelper на все endpoints приложения.
- ▸Page Object на 1500 строк с бизнес-логикой и assert внутри каждого метода.
- ▸Factory без дефолтов, где тест всё равно заполняет каждое поле вручную.
- ▸Cleanup через sleep или ручное удаление в конце теста: если assert упадёт раньше, данные останутся.
- ▸Наследование ради переиспользования двух методов.
- ▸Абстракции «на будущее», которыми пока пользуется только один тест.
Чек-лист архитектуры Java QA Automation проекта
- ▸Тест читается как сценарий, а не как набор HTTP/SQL/UI-команд.
- ▸API clients отвечают за транспорт и сериализацию.
- ▸Page Objects скрывают локаторы и действия конкретной страницы.
- ▸Models/DTO не зависят от тестов.
- ▸Factory создаёт осмысленные варианты тестовых данных.
- ▸Builder помогает точечно менять сложные объекты.
- ▸Fixtures управляют созданием и cleanup данных.
- ▸Проверки, которые повторяются, вынесены в assertions/domain helpers.
- ▸Нет глобального изменяемого состояния между тестами.
- ▸Параллельный запуск не ломает данные и окружение.
- ▸Новая абстракция появляется из реальной боли, а не из желания применить паттерн.
Как понять, что архитектура стала лучше
Главный критерий — не количество паттернов. Хорошая архитектура уменьшает стоимость изменения тестов. Если endpoint поменял URL, вы исправляете client, а не 70 тестов. Если изменился локатор, правите Page Object. Если появились новые обязательные поля заказа, обновляете Factory. Если тест упал, по его коду понятно, какой бизнес-сценарий сломан.
Builder, Factory, Page Object и fixtures — это не цель. Это инструменты, которые помогают отделить сценарий от инфраструктуры. Используйте их тогда, когда они делают код тестов короче, понятнее и безопаснее для изменений.
Куда двигаться дальше
Если хочется сначала увидеть полный список навыков Java QA Automation, открой бесплатный roadmap. А если нужен законченный пример проекта с API, UI, PostgreSQL, Kafka, fixtures и CI/CD, посмотри программу Java QA Automation ThreadQA.