Test Strategy Style Guide

Свод правил интеграционных и unit-тестов в Java/Spring с кодами TS-N: всё синхронно, поднимаем только PostgreSQL + WireMock, события через Outbox-таблицу, время и UUID — детерминированные через @MockitoBean, БД через DatabasePreparer + TestObjectGenerator.

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

Свод правил интеграционных и unit-тестов в Java/Spring-сервисах команды UCP. Каждое правило идентифицируется кодом TS-N — скилл ucp-test-design цитирует эти коды в выдаче и при review-обзорах.

Главный принцип (TS-1): тест должен быть быстрым и детерминированным. Если нужно поднимать Kafka, Redis, обмазывать всё Awaitility — это не тест бизнес-логики, а инфраструктурный smoke. Их пишут отдельно и редко.

Подход проверен на боевом сервисе с базовым классом PlatformBaseIntegrationTest.

Связанные стандарты:

  • Use Case Pattern — каждый UseCase / Handler покрывается интеграционным тестом, AAA-структурой.
  • DDD tactical — unit-тесты на инварианты агрегатов отдельно от integration.
  • R-JOOQ-REPO-6 — каждый репозиторий покрыт интеграционным тестом против Testcontainers PostgreSQL, без mock'ов DSLContext.
  • R-RES-OAS-* — WireMock для outbound HTTP-стабов.
  • R-KFK-CONS-* — listener тесты с in-memory dispatcher или Testcontainers Kafka.
  • AUTH-19 — Idempotency-Key тесты для money-операций.

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-14withNano(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) и общий стиль.