Стратегия тестов

Подход к интеграционным тестам Java/Spring без Kafka/Redis: всё синхронно, поднимаем только PostgreSQL и WireMock, события через in-memory publisher.

Статья внедрена в скилл AI-агента ucp-test-design Стратегия тестов

Главный принцип: тест должен быть быстрым и детерминированным. Если нужно поднимать 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 тестов на сервис.


Чек-лист обзора

ГруппаПравила
Платформенный/доменный baseTS-4TS-8
DatabasePreparerTS-9TS-11
TestObjectGeneratorTS-12TS-14
Структура тестаTS-1TS-3, TS-15TS-18
Kafka/Redis/asyncTS-19TS-22
Внешние HTTPTS-23TS-25
Что НЕ интеграционныеTS-26TS-28

Дальше

  • Скилл ucp-test-design в usecase-pattern-skills — пишет тесты по этим правилам автоматически: берёт спеку и генерирует BaseIntegrationTest, DatabasePreparer, TestObjectGenerator и тесты на каждый use case / BR / событие.
  • Use Case Pattern — методология поверх стиля тестов.
  • Java Style Guide — именование тестов (JS-2.6.1) и общий стиль.