Test messaging, contract testing và E2E
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 đề
- Mock
KafkaTemplatekhông bắt được lỗi serializer, sai tên topic, thiếu header, sai key (ảnh hưởng thứ tự theo partition). - Gọi thẳng listener bỏ qua deserializer, error handler, retry, DLT.
- Kafka giao at-least-once (bài 8.9): message trùng là bình thường. Không test idempotency thì trừ ví hai lần.
- Team order đổi
amountthànhtotalAmount; unit test hai bên vẫn xanh, production vỡ.
3. Giải pháp
- Integration test với broker thật qua Testcontainers (
KafkaContainer,RabbitMQContainer). - Idempotency test: gửi cùng message 2 lần, assert chỉ xử lý 1 lần.
- 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.
- 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ại | Bắt được | Không bắt được | Chi phí |
|---|---|---|---|
| Unit + mock KafkaTemplate | Logic tạo event | Serializer, topic, header, retry | Rất rẻ |
| IT + Testcontainers | Wiring với broker thật, DLT, idempotency | Service kia đổi schema | Vừa |
| Contract (SCC / Pact) | Lệch hợp đồng giữa các service | Lỗi hạ tầng, luồng nhiều bước | Vừa, cần quy trình |
| E2E | Cả hệ thống ghép lại | Khó chỉ ra lỗi ở đâu | Đắt, chậm, flaky |
| Spring Cloud Contract | Pact | |
|---|---|---|
| Ai viết contract | Thườ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ối | Stub jar qua Maven repo | Pact Broker / PactFlow |
7. Khi nào nên dùng / không nên dùng
- Mọi consumer xử lý tiền: bắt buộc test duplicate message.
- Contract test khi các service do team khác nhau sở hữu và deploy độc lập. Một team một repo nhỏ thì IT là đủ.
- E2E chỉ cho vài luồng tạo ra doanh thu; không viết E2E cho từng case validation.
- Không dùng embedded Kafka nếu production chạy bản khác nhiều; Testcontainers sát thực tế hơn.
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.
- Contract: kiểm hình dạng message/API, chạy riêng từng service, nhanh.
- E2E: kiểm cấu hình thật (topic tồn tại, ACL, network, timeout), luồng nhiều bước qua nhiều service.
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
- Broker thật (Testcontainers) + Awaitility.
- Consumer: test trùng, test poison message → DLT.
- Contract = kiểm tương thích không cần chạy chung.
- E2E: ít và chỉ luồng sống cò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.