Опирается на правила:
TS-4…TS-8из Test Strategy Style Guide → раздел 2. BaseIntegrationTest.
Важно знать
- Один платформенный
PlatformBaseIntegrationTest+ по одному доменному на каждый Bounded Context.@ServiceConnection(Spring Boot 3.1+) — Spring сам прокидывает свойства вspring.datasource.*.@TestInstance(PER_CLASS)— дорогой setup безstatic.@MockitoBean DateTimeService+UuidGenerator— детерминированное время и UUID.@Import(TestJwtConfiguration.class)— фейковый JWT +TestHttpHeaders.withSuccessToken().- Без
@DynamicPropertySourceдля PostgreSQL —@ServiceConnectionделает это сам.- Платформенный держит infrastructure, доменный добавляет
DatabasePreparer.
BaseIntegrationTest — единая точка настройки. Без неё каждый тест-класс копирует Testcontainers, @MockitoBean, JWT-конфигурацию — через 50 тестов в проекте получаем 50 разных вариантов «как настроить». UCP формулирует двухуровневую схему: платформа + домен.
Платформенный base
TS-4: один на сервис.
@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();
}
}
Что внутри:
@SpringBootTest(RANDOM_PORT)— полный context + случайный порт для HTTP.@ActiveProfiles("integration-test")— профиль, отключающий Kafka/Redis.@Testcontainers+@ServiceConnection— Spring сам поднимает PostgreSQL.@TestInstance(PER_CLASS)— экземпляр класса живёт всё время, не нужноstaticдля зависимостей.@Import(TestJwtConfiguration.class)— единая JWT-конфигурация.static { postgres.start(); }— контейнер запускается один раз на всю JVM (shared между тестами).
Доменный base
На каждый Bounded Context — свой:
public abstract class OrderBaseIntegrationTest extends PlatformBaseIntegrationTest {
@Autowired
protected OrderDatabasePreparer databasePreparer;
@Autowired
protected DSLContext dsl;
@BeforeEach
void clearDatabase() {
databasePreparer.clearAll();
}
}
Что добавляет:
@Autowired DatabasePreparer— fluent setup БД per BC.@Autowired DSLContext— для assertions через JOOQ.@BeforeEach— чистая БД перед каждым тестом.
public abstract class PaymentBaseIntegrationTest extends PlatformBaseIntegrationTest {
@Autowired
protected PaymentDatabasePreparer databasePreparer;
}
Тест:
public class CreateOrderEndpointIntegrationTest extends OrderBaseIntegrationTest {
@Autowired private TestRestTemplate restTemplate;
@Test
void create_whenValid_returns201() {
// ...
}
}
Это даёт DRY (один BaseIntegrationTest на сервис) + домен-специфичные расширения.
@ServiceConnection
TS-5: Spring Boot 3.1+ feature.
@ServiceConnection
protected static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
Что делает:
- Spring сам распознаёт container, прокидывает URL/username/password в
spring.datasource.url/username/password. - Не нужно писать
@DynamicPropertySource:
// УСТАРЕЛО — не нужно
@DynamicPropertySource
static void postgresProps(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
}
Для других контейнеров (Redis, Kafka — если очень нужно для отдельного теста) — тоже работает: @ServiceConnection распознаёт RedisContainer, KafkaContainer через Spring Boot autoconfigure.
@TestInstance(PER_CLASS)
TS-6: lifecycle экземпляра.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class PlatformBaseIntegrationTest { ... }
По умолчанию JUnit Jupiter создаёт новый экземпляр test class для каждого тест-метода. С PER_CLASS — один экземпляр на весь class.
Что это даёт:
@BeforeAllбезstatic— можно использовать field-injection.- Тяжёлый setup один раз — не на каждый метод.
- Поля inheritable — доменный base может иметь
@Autowiredполя без статики.
Цена — нужно следить за state между тестами (тестовая БД чистится через @BeforeEach).
@MockitoBean для time и UUID
TS-7: детерминизм.
@MockitoBean
protected DateTimeService dateTimeService;
@MockitoBean
protected UuidGenerator uuidGenerator;
В production code сервиса:
@Component
public class DateTimeService {
public OffsetDateTime getCurrentDateTimeInUTC() {
return OffsetDateTime.now(ZoneOffset.UTC);
}
}
@Component
public class UuidGenerator {
public UUID generate() {
return UUID.randomUUID();
}
}
В Handler:
@UseCase
@RequiredArgsConstructor
public class CreateOrderHandler implements UseCaseHandler<CreateOrderCommand, Order> {
private final OrderRepository orderRepository;
private final DateTimeService dateTimeService;
private final UuidGenerator uuidGenerator;
@Override
@Transactional
public Order handle(CreateOrderCommand command) {
var order = Order.create(
uuidGenerator.generate(),
command,
dateTimeService.getCurrentDateTimeInUTC()
);
return orderRepository.save(order);
}
}
В тесте — given:
var orderId = UUID.fromString("11111111-1111-1111-1111-111111111111");
var now = OffsetDateTime.parse("2026-05-26T10:00:00Z");
given(uuidGenerator.generate()).willReturn(orderId);
given(dateTimeService.getCurrentDateTimeInUTC()).willReturn(now);
Теперь Order.id всегда 11111111-..., createdAt всегда 2026-05-26T10:00:00Z. Assertions точные.
@MockitoBean — заменяет @MockBean в Spring Boot 3.4+. Использует Mockito, регистрируется в Spring context как primary bean.
@Import(TestJwtConfiguration)
TS-8: тестовая JWT-конфигурация.
@TestConfiguration
public class TestJwtConfiguration {
@Bean
@Primary
public JwtDecoder testJwtDecoder() {
return token -> {
if (token.equals("test-success-token")) {
return Jwt.withTokenValue(token)
.header("alg", "none")
.claim("sub", "test-user-id")
.claim("realm_access", Map.of("roles", List.of("customer")))
.build();
}
if (token.equals("test-admin-token")) {
return Jwt.withTokenValue(token)
.header("alg", "none")
.claim("sub", "test-admin-id")
.claim("realm_access", Map.of("roles", List.of("admin")))
.build();
}
throw new JwtException("Invalid test token");
};
}
}
Helper:
public final class TestHttpHeaders {
public static HttpHeaders withSuccessToken() {
var headers = new HttpHeaders();
headers.setBearerAuth("test-success-token");
return headers;
}
public static HttpHeaders withAdminToken() {
var headers = new HttpHeaders();
headers.setBearerAuth("test-admin-token");
return headers;
}
public static HttpHeaders withCustomerToken(String customerId) {
// generates token with sub=customerId
}
}
В тесте:
var response = restTemplate.exchange(
"/v1/orders",
HttpMethod.POST,
new HttpEntity<>(request, TestHttpHeaders.withSuccessToken()),
OrderResponse.class
);
Это даёт: единый source-of-truth по тестовой авторизации; никаких токенов руками в каждом тесте; легко менять (новая роль, новый claim) в одном месте.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@DynamicPropertySource для PostgreSQL | TS-5 | @ServiceConnection |
@TestInstance(PER_METHOD) (default) | TS-6 | PER_CLASS |
Instant.now() напрямую в production | TS-7 | DateTimeService бин |
UUID.randomUUID() напрямую | TS-7 | UuidGenerator бин |
| JWT-токены руками в каждом тесте | TS-8 | TestHttpHeaders.* хелперы |
| Платформенный base в каждом BC отдельно | TS-4 | один на сервис |
@MockBean (deprecated) | TS-7 | @MockitoBean |
Отсутствие @ActiveProfiles("integration-test") | TS-4 | явный профиль |
| Несколько контейнеров PostgreSQL в одном servce | TS-5 | один shared контейнер через static |
Куда дальше
- Test Strategy → раздел 2. BaseIntegrationTest — нормативные формулировки.
- Базовые правила — синхронность, AAA.
- DatabasePreparer — что добавляется в доменный base.
- TestObjectGenerator — fluent builders.
- Один тест —
TestRestTemplate+TestHttpHeaders. - Auth → JWT validation —
TestJwtConfigurationкак заменаoauth2ResourceServer.