Test messaging, contract testing và E2E

4 câu hỏi~25 phútTừ khóa: KafkaContainer, RabbitMQContainer, @ServiceConnection, consumer test, idempotency test, consumer-driven contract, Spring Cloud Contract, Pact, E2E, test pyramid

Tiếp đi: test messaging. OrderService gửi event OrderCreated sang Kafka, payment-service đọc. Mình test hai bên thế nào cho chắc?

Có ba tầng cần kiểm: từng service với broker thật, hợp đồng message giữa hai service, và vài luồng E2E quan trọng nhất.

1. Bài toán

order-service ──OrderCreated──► Kafka topic "orders" ──► payment-service
                                                         └─ trừ ví, gửi PaymentCompleted

Hiện tại: unit test mock KafkaTemplate, consumer test gọi thẳng method onOrderCreated(event).

2. Vấn đề

3. Giải pháp

  1. Integration test với broker thật qua Testcontainers (KafkaContainer, RabbitMQContainer).
  2. Idempotency test: gửi cùng message 2 lần, assert chỉ xử lý 1 lần.
  3. Contract test (consumer-driven): hợp đồng message/API được kiểm ở cả producer và consumer, không cần chạy cả hai cùng lúc. Công cụ: Spring Cloud Contract, Pact.
  4. E2E: ít, chỉ cho luồng sống còn (đặt hàng → thanh toán).

4. Code từng bước

Bước 1: Dependency

<dependency><groupId>org.testcontainers</groupId><artifactId>kafka</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>rabbitmq</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.awaitility</groupId><artifactId>awaitility</artifactId><scope>test</scope></dependency>

Bước 2: Config. @ServiceConnection tự set spring.kafka.bootstrap-servers, không cần cổng cố định.

spring:
  kafka:
    consumer:
      auto-offset-reset: earliest   # test không bỏ lỡ message gửi trước khi consumer join
      group-id: payment-service

Bước 3: Code Java

3a. Producer test (order-service)

@SpringBootTest
@Testcontainers
class OrderEventPublisherIT {
    @Container @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));

    @Autowired OrderService orderService;

    @Test
    void createOrderPublishesEventKeyedByOrderId() {
        Long orderId = orderService.create(new CreateOrderRequest(1L, new BigDecimal("250")));

        try (var consumer = testConsumer(kafka.getBootstrapServers())) {
            consumer.subscribe(List.of("orders"));
            ConsumerRecord<String, String> rec =
                KafkaTestUtils.getSingleRecord(consumer, "orders", Duration.ofSeconds(10));
            assertThat(rec.key()).isEqualTo(orderId.toString());
            assertThatJson(rec.value()).node("amount").isEqualTo(250);
        }
    }
}

3b. Consumer test + idempotency (payment-service)

@SpringBootTest
@Testcontainers
class OrderCreatedListenerIT {
    @Container @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));
    @Container @ServiceConnection
    static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");

    @Autowired KafkaTemplate<String, String> kafkaTemplate;
    @Autowired PaymentRepository paymentRepo;
    @Autowired WalletRepository walletRepo;

    @Test
    void duplicateEventChargesOnce() {
        String event = """
            {"eventId":"e-42","orderId":42,"userId":1,"amount":100}""";
        kafkaTemplate.send("orders", "42", event);
        kafkaTemplate.send("orders", "42", event);   // giả lập redelivery

        await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
            assertThat(paymentRepo.countByOrderId(42L)).isEqualTo(1));
        // chờ thêm để chắc message thứ 2 đã được xử lý xong rồi mới kết luận
        await().during(Duration.ofSeconds(2)).atMost(Duration.ofSeconds(5)).untilAsserted(() ->
            assertThat(walletRepo.balanceOf(1L)).isEqualByComparingTo("900"));
    }

    @Test
    void poisonMessageGoesToDlt() {
        kafkaTemplate.send("orders", "x", "not-json");
        await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
            assertThat(readAll("orders.DLT")).hasSize(1));
    }
}

Idempotency thường làm bằng bảng processed_event(event_id PK) trong cùng transaction với việc trừ ví (xem outbox/inbox). RabbitMQ làm tương tự với RabbitMQContainer và RabbitTemplate; test thêm nhánh nack → DLX (bài 8.5).

3c. Contract test với Spring Cloud Contract (producer = order-service)

// src/test/resources/contracts/orderCreated.groovy
Contract.make {
    label "order_created"
    input { triggeredBy("createOrder()") }
    outputMessage {
        sentTo "orders"
        body(eventId: $(anyUuid()), orderId: 42, userId: 1, amount: 100.00)
        headers { messagingContentType(applicationJson()) }
    }
}
// base class cho test được sinh tự động
@SpringBootTest
@AutoConfigureMessageVerifier
public abstract class MessagingBase {
    @Autowired OrderService orderService;
    public void createOrder() {
        orderService.create(new CreateOrderRequest(1L, new BigDecimal("100.00")));
    }
}

Plugin sinh test kiểm order-service thật sự gửi đúng message đó, và publish stub jar. payment-service dùng stub:

@SpringBootTest
@AutoConfigureStubRunner(ids = "com.shop:order-service:+:stubs",
                         stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class PaymentConsumesContractTest {
    @Autowired StubTrigger trigger;
    @Autowired PaymentRepository paymentRepo;

    @Test
    void handlesOrderCreatedFromContract() {
        trigger.trigger("order_created");
        await().untilAsserted(() -> assertThat(paymentRepo.countByOrderId(42L)).isEqualTo(1));
    }
}

Pact đi theo hướng ngược: consumer (payment) viết kỳ vọng, sinh file pact, đẩy lên Pact Broker; producer (order) verify file đó trong CI. can-i-deploy chặn deploy nếu hợp đồng vỡ.

3d. E2E: một luồng sống còn

# docker compose up order-service payment-service kafka postgres
curl -X POST localhost:8080/orders -H 'Content-Type: application/json' \
     -d '{"userId":1,"amount":100}'
# poll tới khi status = PAID (timeout 30s)
curl localhost:8080/orders/42      # {"id":42,"status":"PAID"}

Bước 4: Chạy

./mvnw verify        # *IT chạy qua failsafe (bài 5.8)
./mvnw -pl order-service spring-cloud-contract:generateTests verify   # sinh test + stub

5. Cách hoạt động

Contract (consumer-driven)
          payment team đề xuất ──► contract (repo order-service hoặc broker)
                                     │
        ┌────────────────────────────┴────────────────────────────┐
order-service CI                                         payment-service CI
 sinh test từ contract                                    dùng stub sinh từ contract
 "tôi có gửi đúng message?"                               "tôi có đọc được message?"
        └── vỡ hợp đồng ──► build đỏ trước khi deploy ◄──────────┘

Test pyramid shop-app
        /  E2E (3-5 luồng)    \     chậm, đắt, dễ flaky
       / contract + IT broker  \
      /   slice test (5.4)      \
     /     unit test (5.2-5.3)   \  nhanh, nhiều

6. So sánh

LoạiBắt đượcKhông bắt đượcChi phí
Unit + mock KafkaTemplateLogic tạo eventSerializer, topic, header, retryRất rẻ
IT + TestcontainersWiring với broker thật, DLT, idempotencyService kia đổi schemaVừa
Contract (SCC / Pact)Lệch hợp đồng giữa các serviceLỗi hạ tầng, luồng nhiều bướcVừa, cần quy trình
E2ECả hệ thống ghép lạiKhó chỉ ra lỗi ở đâuĐắt, chậm, flaky
Spring Cloud ContractPact
Ai viết contractThường ở repo producer (consumer gửi PR)Consumer sinh từ test
Ngôn ngữChủ yếu JVM/SpringĐa ngôn ngữ
Phân phốiStub jar qua Maven repoPact Broker / PactFlow

7. Khi nào nên dùng / không nên dùng

Contract test với E2E nghe giống nhau quá. Có contract rồi thì bỏ E2E được không?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: contract test thay được phần lớn E2E dùng để kiểm "hai service có nói chuyện được với nhau không", nhưng không thay hết.

Nên giữ vài E2E (smoke) sau deploy staging, còn kiểm tương thích thì đẩy về contract.

Trong test idempotency, sao mình không assert ngay sau khi gửi mà phải await().during(...)?

Đúng câu hỏi. Assert "chỉ có 1 payment" có thể xanh trước khi message thứ hai kịp xử lý, nên test xanh giả. during(2s) yêu cầu điều kiện đúng liên tục trong 2 giây, đủ để message trùng đi qua. Cách chặt hơn: gửi thêm một message "đánh dấu" khác key cùng partition rồi chờ nó xử lý xong; vì cùng partition xử lý tuần tự, message trùng chắc chắn đã xong.

Phỏng vấn hỏi test messaging và contract thì trả lời sao?

Q1. Bạn test Kafka consumer thế nào?

🇻🇳 Tiếng Việt:

Logic xử lý em unit test riêng. Sau đó viết integration test với Kafka thật qua Testcontainers: gửi message bằng KafkaTemplate, dùng Awaitility chờ kết quả trong DB. Em test thêm message trùng để kiểm idempotency, message lỗi để kiểm retry và DLT.

🇬🇧 English:

I unit test the handling logic separately. Then I write an integration test against a real Kafka via Testcontainers: send a message with KafkaTemplate and use Awaitility to wait for the result in the database. I also send a duplicate to verify idempotency and a malformed message to verify retry and the dead-letter topic.

Q2. Contract testing là gì, khi nào cần?

🇻🇳 Tiếng Việt:

Là kiểm hợp đồng API hoặc message giữa consumer và producer mà không cần chạy cả hai cùng lúc. Producer chạy test sinh từ contract để chứng minh mình đáp ứng, consumer chạy với stub sinh từ cùng contract. Cần khi các service do team khác nhau sở hữu và deploy độc lập. Công cụ: Spring Cloud Contract, Pact.

🇬🇧 English:

It verifies the API or message contract between a consumer and a provider without running both together. The provider runs tests generated from the contract to prove it complies, and the consumer runs against stubs generated from the same contract. It is needed when services are owned by different teams and deployed independently. Tools include Spring Cloud Contract and Pact.

Q3. Nên có bao nhiêu E2E test?

🇻🇳 Tiếng Việt:

Ít, chỉ cho vài luồng quan trọng nhất như đặt hàng và thanh toán. E2E chậm, đắt, dễ flaky và khó chỉ ra lỗi. Phần lớn độ tin cậy đến từ unit, slice, integration và contract test.

🇬🇧 English:

Few, only for the most critical flows such as placing an order and paying. E2E tests are slow, expensive, flaky and hard to diagnose. Most confidence should come from unit, slice, integration and contract tests.

Cách nhớ ngắn gọn


Tự kiểm tra

Mock KafkaTemplate bỏ sót những lỗi gì?

Serializer, tên topic, header, key/partition, cấu hình producer.

Vì sao consumer phải có test message trùng?

Kafka/RabbitMQ giao at-least-once; redelivery là bình thường, không idempotent thì xử lý hai lần.

Spring Cloud Contract sinh ra gì cho producer và consumer?

Producer: test tự sinh kiểm output. Consumer: stub jar để giả lập producer.

auto-offset-reset: earliest giúp gì trong test?

Consumer group mới vẫn đọc được message gửi trước khi nó join.

Pact can-i-deploy làm gì?

Hỏi Pact Broker xem version này có tương thích với các version đang chạy của service đối tác không, chặn deploy nếu không.