Опирается на правила: TS-19TS-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-19Outbox проверка
Реальный Redis container в integrationTS-20spring.cache.type=none
Awaitility.await() для Kafka consumerTS-19handler.handle(event) напрямую
Thread.sleep для wait async handlerTS-22sync conversion
@TransactionalEventListener AFTER_COMMIT без conversionTS-22sync для тестов
EmbeddedKafkaBroker в base testTS-19direct handler test
Зависимость от Kafka producer flushTS-19outbox-таблица
Cache testing в integration suiteTS-20отдельный класс с @Import(CacheTestConfig)

Куда дальше

  • Test Strategy → раздел 6. Kafka, Redis, async — нормативные формулировки.
  • Базовые правила — синхронность как основа.
  • Один тест — assert Outbox в тесте.
  • DatabasePreparer — clearOutbox().
  • Kafka → outbox publishing — что проверяем в тесте.
  • Kafka → idempotent consumer — processed_event в тесте.
  • Что НЕ покрывается интеграционными — где Kafka E2E.