Опирается на правила:
TS-19…TS-22из Test Strategy Style Guide → раздел 6. Kafka, Redis, async — по умолчанию НЕТ.
Важно знать
- Kafka не поднимаем в integration-тестах — проверяем Outbox-таблицу через DSLContext.
- Redis не поднимаем — профиль
integration-testставитspring.cache.type=none.- Kafka listener — тестируем handler напрямую как Spring-бин, без
EmbeddedKafka.@TransactionalEventListener(AFTER_COMMIT)— переводим в синхрон для теста.- Outbox-проверка через
DSLContext— содержимое outbox после write.- Цель — детерминированные, быстрые тесты, без async-индетерминизма.
- Если очень нужен Kafka в тесте — отдельный
@Tag("e2e")test suite.
Главный принцип UCP-тестов: синхронность. Каждое async-добавление в тесте (Kafka producer/consumer, Redis cache, @Async handler) добавляет неопределённость, замедление, flakiness. Все они заменяются на проверки в БД (outbox table, processed_event) или прямые вызовы handler-ов.
Kafka — не поднимаем
TS-19: Outbox-проверка вместо Kafka consumer.
В production коде write-handler пишет outbox event:
@UseCase
@Transactional
public Order handle(ConfirmOrderCommand command) {
var order = orderRepository.findById(command.orderId()).orElseThrow();
order.confirm();
orderRepository.save(order);
outboxRepository.append(OutboxEvent.builder()
.aggregateType("Order")
.aggregateId(order.getId())
.eventType("OrderConfirmed")
.payload(jsonbHelper.serialize(OrderConfirmedEvent.from(order)))
.build());
return order;
}
В тесте — не запускаем Kafka. Не запускаем outbox-relay (@Scheduled отключен в integration-test профиле). Просто проверяем, что строка в outbox создана:
@Test
void confirmOrder_whenDraft_createsOrderConfirmedOutboxEvent() {
// Arrange
var orderId = UUID.randomUUID();
var draft = new OrderTestObjectGenerator()
.withId(orderId)
.withStatus(OrderStatus.DRAFT)
.generate();
databasePreparer.createOrder(draft).prepare();
// Act
restTemplate.exchange(
"/v1/orders/" + orderId + "/confirm",
HttpMethod.POST,
new HttpEntity<>(TestHttpHeaders.withSuccessToken()),
Void.class
);
// Assert
var outboxRows = dsl.selectFrom(OUTBOX)
.where(OUTBOX.AGGREGATE_ID.eq(orderId.toString()))
.fetch();
assertThat(outboxRows)
.extracting(OutboxRecord::getEventType)
.containsExactly("OrderConfirmed");
var payload = objectMapper.readTree(outboxRows.get(0).getPayload().data());
assertThat(payload.get("orderId").asText()).isEqualTo(orderId.toString());
}
Что это даёт:
- Синхронность — outbox строка создана в transaction, видна сразу.
- Полная проверка — что именно опубликовано в Kafka (event_type, payload, partition_key).
- Скорость — не нужно ждать Kafka producer flush.
Что не проверяется: что Kafka доставила сообщение consumer-у. Это другой уровень тестирования (E2E, см. ниже).
Redis — не поднимаем
TS-20: профиль integration-test.
spring:
config:
activate:
on-profile: integration-test
cache:
type: none
spring.cache.type=none — Spring создаёт NoOpCacheManager. @Cacheable методы выполняются как обычные методы, без cache. Тест проверяет business-логику, без зависимости от Redis.
Если нужно специально тестировать кеш (cache-aside, eviction) — это отдельный тест с явным ConcurrentMapCacheManager:
@TestConfiguration
public class CacheTestConfig {
@Bean
@Primary
public CacheManager testCacheManager() {
return new ConcurrentMapCacheManager("user-profiles");
}
}
Tests с @Import(CacheTestConfig.class). Это отдельный класс — большинство интеграционных тестов не беспокоятся о cache.
Kafka listener — direct test
TS-21: handler как Spring-бин.
В production:
@KafkaListener(topics = "payment.events", groupId = "order-service-payment")
@Transactional
public void onPaymentEvent(PaymentEvent event, Acknowledgment ack) {
paymentEventHandler.handle(event);
ack.acknowledge();
}
@Component
@RequiredArgsConstructor
public class PaymentEventHandler {
private final OrderRepository orderRepository;
private final ProcessedEventRepository processedEventRepository;
@Transactional
public void handle(PaymentEvent event) {
if (!processedEventRepository.tryMarkProcessed(event.eventId(), "order-payment")) {
return;
}
var order = orderRepository.findBySagaId(event.sagaId()).orElseThrow();
order.applyPaymentEvent(event);
orderRepository.save(order);
}
}
В тесте — не поднимаем Kafka. Тестируем PaymentEventHandler напрямую:
public class PaymentEventHandlerIntegrationTest extends OrderBaseIntegrationTest {
@Autowired private PaymentEventHandler handler;
@Test
@DisplayName("BR-005: payment success transitions order to PAID")
void handle_whenPaymentCharged_updatesOrderToPaid() {
// Arrange
var orderId = UUID.randomUUID();
var sagaId = UUID.randomUUID();
var draft = new OrderTestObjectGenerator()
.withId(orderId)
.withSagaId(sagaId)
.withStatus(OrderStatus.AWAITING_PAYMENT)
.generate();
databasePreparer.createOrder(draft).prepare();
var event = new PaymentChargedEvent(
UUID.randomUUID(),
sagaId,
new BigDecimal("100.00"),
OffsetDateTime.parse("2026-05-26T10:00:00Z")
);
// Act
handler.handle(event);
// Assert
var order = orderRepository.findById(orderId).orElseThrow();
assertThat(order.status()).isEqualTo(OrderStatus.PAID);
var processed = dsl.selectFrom(PROCESSED_EVENT)
.where(PROCESSED_EVENT.EVENT_ID.eq(event.eventId()))
.fetch();
assertThat(processed).hasSize(1);
}
}
Без EmbeddedKafka, без KafkaTemplate.send(). Просто handler.handle(event).
@TransactionalEventListener(AFTER_COMMIT)
TS-22: async-листенер в синхрон.
Production:
@Component
@RequiredArgsConstructor
public class OrderConfirmedSagaListener {
private final NotificationService notificationService;
@EventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderConfirmed(OrderConfirmedDomainEvent event) {
notificationService.notifyConfirmed(event.orderId(), event.customerId());
}
}
AFTER_COMMIT — handler выполняется после commit, поэтому в @Transactional тесте handler не вызывается (transaction всё ещё активна).
Варианты для теста:
Вариант 1: профиль теста меняет фазу
@TestConfiguration
public class TestEventListenerConfig {
@Bean
@Primary
public OrderConfirmedSagaListener syncListener(NotificationService notificationService) {
return new OrderConfirmedSagaListener(notificationService) {
@Override
@EventListener
public void onOrderConfirmed(OrderConfirmedDomainEvent event) {
super.onOrderConfirmed(event);
}
};
}
}
@EventListener без @TransactionalEventListener — handler срабатывает синхронно.
Вариант 2: ручной вызов
@Test
void confirmOrder_whenDraft_sendsNotification() {
// ... arrange + act
// Manual trigger of saga listener
sagaListener.onOrderConfirmed(new OrderConfirmedDomainEvent(orderId, customerId));
// Assert notification was sent (через WireMock или @MockitoBean)
}
Вариант 1 — лучше для частых тестов (один раз настроил, везде синхрон). Вариант 2 — для редких тестов или для explicit testing of saga-flow.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@EmbeddedKafka в integration-тесте | TS-19 | Outbox проверка |
| Реальный Redis container в integration | TS-20 | spring.cache.type=none |
Awaitility.await() для Kafka consumer | TS-19 | handler.handle(event) напрямую |
Thread.sleep для wait async handler | TS-22 | sync conversion |
@TransactionalEventListener AFTER_COMMIT без conversion | TS-22 | sync для тестов |
EmbeddedKafkaBroker в base test | TS-19 | direct handler test |
| Зависимость от Kafka producer flush | TS-19 | outbox-таблица |
| Cache testing в integration suite | TS-20 | отдельный класс с @Import(CacheTestConfig) |
Куда дальше
- Test Strategy → раздел 6. Kafka, Redis, async — нормативные формулировки.
- Базовые правила — синхронность как основа.
- Один тест — assert Outbox в тесте.
- DatabasePreparer —
clearOutbox(). - Kafka → outbox publishing — что проверяем в тесте.
- Kafka → idempotent consumer —
processed_eventв тесте. - Что НЕ покрывается интеграционными — где Kafka E2E.