Стратегия тестов
Подход к интеграционным тестам Java/Spring без Kafka/Redis: всё синхронно, поднимаем только PostgreSQL и WireMock, события через in-memory publisher.
Главный принцип: тест должен быть быстрым и детерминированным. Если нужно поднимать Kafka, Redis, обмазывать всё Awaitility — это не тест бизнес-логики, а инфраструктурный smoke. Их пишут отдельно и редко.
Каждое правило ниже имеет код (TS-1, TS-9 и т.д.) — на эти коды ссылается скилл ucp-test-design при написании и обзоре. Подход проверен на боевом сервисе с базовым классом PlatformBaseIntegrationTest.
1. Базовые правила
TS-1 Интеграционный тест запускает полный Spring-контекст + реальный PostgreSQL + ваш контроллер через HTTP. Внешние HTTP-сервисы — WireMock или @MockitoBean. Kafka/Redis — заменяются на Outbox-проверку или выключаются профилем.
TS-2 Все тесты синхронные. Никаких CompletableFuture.get(), Awaitility.await(), Thread.sleep. Время и UUID — детерминированные через @MockitoBean.
TS-3 Один тест — один сценарий. AAA-структура: Arrange → Act → Assert. Не «проверить всё в одном».
2. BaseIntegrationTest
TS-4 На сервис заводим один платформенный BaseIntegrationTest + по одному доменному base на каждый Bounded Context. Доменный наследуется от платформенного либо повторяет его настройку и добавляет свой DatabasePreparer.
@Import(TestJwtConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration-test")
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class PlatformBaseIntegrationTest {
@ServiceConnection
protected static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@MockitoBean
protected DateTimeService dateTimeService;
@MockitoBean
protected UuidGenerator uuidGenerator;
static {
postgres.start();
}
}
TS-5 @ServiceConnection (Spring Boot 3.1+) сам прокидывает свойства в spring.datasource.*. Не пишем @DynamicPropertySource для PostgreSQL.
TS-6 @TestInstance(Lifecycle.PER_CLASS) — экземпляр класса теста живёт всё время. Дорогой setup можно держать в @BeforeAll без static.
TS-7 Время и UUID — @MockitoBean. В коде сервиса всё, что зовёт «сейчас» или «новый id», идёт через два бина: DateTimeService и UuidGenerator. В тесте они мокаются и возвращают предзаданные значения. Никаких Instant.now() / UUID.randomUUID() напрямую — иначе тест становится недетерминированным.
given(dateTimeService.getCurrentDateTimeInUTC()).willReturn(now);
given(uuidGenerator.generate()).willReturn(orderId);
TS-8 @Import(TestJwtConfiguration.class) даёт фейковый JWT-validator + хелпер TestHttpHeaders.withSuccessToken() для подкладывания токена в запрос. Единый source-of-truth по тестовой авторизации.
3. DatabasePreparer — fluent setup БД
TS-9 На каждый Bounded Context — свой <Domain>DatabasePreparer как @Component-обёртка над DSLContext. Методы делятся на три группы:
clear*()— очистка таблиц.create*(...)— вставка тестовых данных.prepare()— запуск всей очереди.
@Component
public class OrderDatabasePreparer {
private final DSLContext dsl;
private final List<Runnable> preparers = new ArrayList<>();
public OrderDatabasePreparer clearOrders() {
preparers.add(() -> dsl.deleteFrom(Orders.ORDERS).execute());
return this;
}
public OrderDatabasePreparer createOrder(OrdersPojo order) {
preparers.add(() -> dsl.insertInto(Orders.ORDERS)
.set(dsl.newRecord(Orders.ORDERS, order))
.execute());
return this;
}
public void prepare() {
preparers.forEach(Runnable::run);
preparers.clear();
}
}
В тесте:
@BeforeEach
void setUp() {
databasePreparer
.clearOrderItems()
.clearOrders()
.clearOutbox()
.createOrder(order)
.prepare();
}
TS-10 Не пересоздаём схему между тестами. Только DELETE нужных таблиц — миллисекунды вместо секунд.
TS-11 Порядок вызовов = порядок исполнения. При наличии FK сначала чистим зависимые, последними создаём родительские.
4. TestObjectGenerator — fluent builders сущностей
TS-12 На каждую POJO-таблицу — builder с with* и generate():
public class OrderTestObjectGenerator {
private UUID id = UUID.randomUUID();
private OrderStatus status = OrderStatus.DRAFT;
private UUID customerId = UUID.randomUUID();
private OffsetDateTime createdAt = OffsetDateTime.now().withNano(0);
public OrderTestObjectGenerator withId(UUID id) { this.id = id; return this; }
public OrderTestObjectGenerator withStatus(OrderStatus s) { this.status = s; return this; }
public OrderTestObjectGenerator withCreatedAt(OffsetDateTime t) { this.createdAt = t; return this; }
public OrdersPojo generate() {
var pojo = new OrdersPojo();
pojo.setId(id);
pojo.setStatus(status);
pojo.setCustomerId(customerId);
pojo.setCreatedAt(createdAt);
return pojo;
}
}
TS-13 У генератора разумные дефолты (UUID.randomUUID(), текущее время с обнулёнными наносекундами). В тесте перезаписываем только то, что важно для сценария.
TS-14 withNano(0) — обязательно при сравнении OffsetDateTime в БД (PostgreSQL timestamp хранит миллисекунды, а Java — наносекунды).
5. Один тест
TS-15 Тест extends <Domain>BaseIntegrationTest, инжектит TestRestTemplate и DatabasePreparer:
public class CreateOrderEndpointIntegrationTest extends OrderBaseIntegrationTest {
private static final String BASE_URL = "/v1/orders";
@Autowired private TestRestTemplate restTemplate;
@Autowired private OrderDatabasePreparer databasePreparer;
@BeforeEach
void setUp() {
databasePreparer.clearOrderItems().clearOrders().clearOutbox().prepare();
}
@Test
@DisplayName("BR-002: confirm fails when reservation failed")
void confirmOrder_whenReservationFailed_returns409() {
// Arrange
var orderId = UUID.randomUUID();
var now = OffsetDateTime.now().withNano(0);
given(uuidGenerator.generate()).willReturn(orderId);
given(dateTimeService.getCurrentDateTimeInUTC()).willReturn(now);
var draft = new OrderTestObjectGenerator()
.withId(orderId).withStatus(OrderStatus.DRAFT).withCreatedAt(now).generate();
databasePreparer.createOrder(draft).prepare();
// Act
var response = restTemplate.exchange(
BASE_URL + "/" + orderId + "/confirm",
HttpMethod.POST,
new HttpEntity<>(TestHttpHeaders.withSuccessToken()),
ProblemDetailJsonBean.class);
// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(response.getBody().getCode()).isEqualTo("OUT_OF_STOCK");
}
}
TS-16 Имена методов: <action>_when<Condition>_<expectedResult> или длинное говорящее имя. В обоих случаях — @DisplayName с цитированием BR-кода.
TS-17 HTTP-вызов через TestRestTemplate.exchange(...) — даёт точный контроль над методом, заголовками, телом. MockMvc оставляем для unit-тестов контроллера без БД.
TS-18 JWT — через TestHttpHeaders.withSuccessToken() или специализированные хелперы (withCustomerToken(customerId), withSellerToken(sellerId)). Не собираем токены руками в каждом тесте.
6. Kafka, Redis, async — по умолчанию НЕТ
TS-19 Не поднимаем Kafka в интеграционных тестах. События остаются в Outbox-таблице — в тесте проверяем содержимое outbox через DSLContext:
var outboxRows = dsl.selectFrom(OUTBOX_ENTITY_CHANGES)
.where(OUTBOX_ENTITY_CHANGES.AGGREGATE_ID.eq(orderId))
.fetch();
assertThat(outboxRows).extracting(r -> r.getEventType())
.containsExactly("OrderConfirmed");
TS-20 Redis тоже не поднимаем. Профиль integration-test ставит spring.cache.type=none.
TS-21 Если есть подписка на Kafka (idempotent consumer) — тестируем handler напрямую как Spring-бин: eventHandler.handle(testEvent). Без EmbeddedKafka.
TS-22 Async/Saga (@TransactionalEventListener(AFTER_COMMIT)) — переводим в синхрон через профиль теста или ручной commit + явный вызов handler-а.
7. Внешние HTTP — WireMock
TS-23 Если сервис вызывает внешний REST — поднимаем WireMock в BaseIntegrationTest:
@RegisterExtension
static WireMockExtension catalog = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@DynamicPropertySource
static void wireMockProps(DynamicPropertyRegistry r) {
r.add("clients.catalog.base-url", catalog::baseUrl);
}
TS-24 Стабы пишем в самом тесте, не в общих mappings/*.json. В тесте видно, на что он опирается.
catalog.stubFor(get("/api/v1/products/" + productId)
.willReturn(okJson("""
{ "id": "%s", "price": "100.00", "currency": "RUB" }
""".formatted(productId))));
TS-25 Если внешний клиент — @FeignClient, можно мокать его через @MockitoBean. Но по умолчанию WireMock предпочтительнее — он проверяет ещё и сериализацию HTTP, заголовки, retry, timeout.
8. Что НЕ покрывается интеграционными тестами
TS-26 Чистая бизнес-логика агрегата — unit-тест без Spring. Просто new Order(...), order.confirm(). Самые быстрые и многочисленные.
TS-27 Контроллер + сериализация JSON, без БД — @WebMvcTest + MockMvc.
TS-28 E2E через настоящие Kafka/внешние сервисы — отдельная группа @Tag("e2e"), отдельный CI-этап, ≤ 5–10 тестов на сервис.
Чек-лист обзора
| Группа | Правила |
|---|---|
| Платформенный/доменный base | TS-4–TS-8 |
DatabasePreparer | TS-9–TS-11 |
TestObjectGenerator | TS-12–TS-14 |
| Структура теста | TS-1–TS-3, TS-15–TS-18 |
| Kafka/Redis/async | TS-19–TS-22 |
| Внешние HTTP | TS-23–TS-25 |
| Что НЕ интеграционные | TS-26–TS-28 |
Дальше
- Скилл
ucp-test-designвusecase-pattern-skills— пишет тесты по этим правилам автоматически: берёт спеку и генерируетBaseIntegrationTest,DatabasePreparer,TestObjectGeneratorи тесты на каждый use case / BR / событие. - Use Case Pattern — методология поверх стиля тестов.
- Java Style Guide — именование тестов (
JS-2.6.1) и общий стиль.