在前幾篇中,我實作了 CreateOrderListener,當收到 OrderCreateEvent 後會驗證錢包餘額、進行資產鎖定,最後發送一筆 OrderCreatedEvent 到 RabbitMQ。
但如果你也做過事件驅動架構,應該知道:
只靠信任協定在微服務間溝通是非常危險的。
為了保障服務間的「語言一致性」,我在 wallet-service 中導入了 Spring Cloud Contract,搭配 YAML 格式 撰寫契約,並透過自動化測試來驗證發送邏輯的正確性與穩定性。
為什麼要做契約測試?
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.0' // 依你實際版本調整
id 'io.spring.dependency-management' version '1.1.0'
id 'org.springframework.cloud.contract' version '4.1.0'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
ext {
set('springCloudVersion', "2023.0.1")
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.boot:spring-boot-starter'
compileOnly "org.projectlombok:lombok"
annotationProcessor "org.projectlombok:lombok"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.amqp:spring-rabbit-test'
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'
testImplementation 'org.springframework.integration:spring-integration-core'
}
tasks.named("generateContractTests") {
contractsDslDir = file("src/contractTest/resources/contracts")
basePackageForTests.set("com.eap.eap_wallet")
baseClassForTests.set("com.eap.eap_wallet.application.BaseContractTest")
}
tasks.named('test') {
useJUnitPlatform()
}}
重點在於我們需要告訴 Spring Cloud Contract:
• 契約檔放在哪裡
• 測試產生的 base 類別是誰
tasks.named("generateContractTests") {
contractsDslDir = file("src/contractTest/resources/contracts")
basePackageForTests.set("com.eap.eap_wallet")
baseClassForTests.set("com.eap.eap_wallet.application.BaseContractTest")
}
契約檔:order_created_event.yml:
description: When CreateOrderListener processes OrderCreateEvent, it should emit OrderCreatedEvent
label: order_create_to_created
name: order_create_to_created
input:
triggeredBy: processOrderCreate()
outputMessage:
sentTo: order.exchange
body:
orderId: "123e4567-e89b-12d3-a456-426614174000"
userId: "123e4567-e89b-12d3-a456-426614174000"
price: 100
quantity: 1
type: "BUY"
createdAt: "2025-07-16T12:00:00"
headers:
rabbitmq_routingKey: order.created
這份契約檔會在測試時,呼叫你在 BaseContractTest 中定義的 processOrderCreate() 方法,並自動驗證:
• 是否真的送出一筆事件
• 發送的 routingKey 是否正確
• payload 中每個欄位是否正確
測試基底:BaseContractTest:
@SpringJUnitConfig(classes = {CreateOrderListener.class, BaseContractTest.TestConfiguration.class})
@AutoConfigureMessageVerifier
public class BaseContractTest {
@Configuration
static class TestConfiguration {
@Bean("order.exchange")
public PollableChannel orderExchange() {
return new QueueChannel();
}
@Bean
@Primary
public com.fasterxml.jackson.databind.ObjectMapper objectMapper() {
com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
mapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
@Autowired private CreateOrderListener createOrderListener;
@MockitoBean private WalletRepository walletRepository;
@MockitoBean private RabbitTemplate rabbitTemplate;
@Autowired private PollableChannel orderExchange;
@BeforeEach
void setup() {
// 根據你的 CreateOrderListener,確保 mock 正確設定
UUID testUserId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000");
WalletEntity wallet =
WalletEntity.builder()
.userId(testUserId)
.availableCurrency(10000)
.lockedAmount(0)
.build();
Mockito.when(walletRepository.findByUserId(testUserId)).thenReturn(wallet);
Mockito.doAnswer(
invocation -> {
String exchange = invocation.getArgument(0);
String routingKey = invocation.getArgument(1);
Object message = invocation.getArgument(2);
if ("order.exchange".equals(exchange)) {
org.springframework.messaging.Message<?> msg =
org.springframework.messaging.support.MessageBuilder.withPayload(message)
.setHeader("rabbitmq_routingKey", routingKey)
.build();
orderExchange.send(msg);
}
return null;
})
.when(rabbitTemplate)
.convertAndSend(
Mockito.any(String.class), Mockito.any(String.class), Mockito.any(Object.class));
}
public void processOrderCreate() {
createOrderListener.onOrderCreate(
OrderCreateEvent.builder()
.orderId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"))
.userId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"))
.price(100)
.amount(1)
.orderType("BUY")
.createdAt(LocalDateTime.parse("2025-07-16T12:00:00"))
.build());
}
}
為了讓 Spring Cloud Contract 能成功執行事件契約驗證,我在 BaseContractTest 中使用了幾個關鍵註解與測試支援機制,這裡逐一說明:
@SpringJUnitConfig(classes = {...})
這是 Spring Boot 測試組合註解,等同於:
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {...})
它用來指定測試時要載入哪些 Spring Bean,我這裡指定了:
• CreateOrderListener:事件處理邏輯
• TestConfiguration:額外提供測試專用的 Bean,如自訂的 ObjectMapper 與測試用 MQ channel
@AutoConfigureMessageVerifier
來自 Spring Cloud Contract,會自動啟用與配置:
• ContractVerifierMessaging:模擬訊息平台的收發行為(AMQP/Kafka)
• ContractVerifierObjectMapper:與合約 JSON 格式一致的序列化器
它能讓我們像這樣在測試中撰寫語意化的驗證:
ContractVerifierMessage response = contractVerifierMessaging.receive("order.exchange", ...);
@Configuration + @Bean
我們建立了一個 order.exchange 對應的 PollableChannel,當測試過程中發送事件時,我們會將它導向這個 channel,供契約測試程式驗收使用:
@Bean("order.exchange")
public PollableChannel orderExchange() {
return new QueueChannel();
}
@Primary ObjectMapper
因為事件中有 LocalDateTime 欄位,我們需要註冊 JavaTimeModule 並禁用時間戳輸出,讓它能以 ISO 格式序列化符合契約的時間字串格式:
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
@MockitoBean
這是 Spring Cloud Contract 提供的自動註冊 mock 機制(類似 @MockBean),能將你的 Repository 或依賴元件替換為 Mockito mock,並允許你在 @BeforeEach 中設定行為:
@MockitoBean
private WalletRepository walletRepository;
這樣就能搭配:
Mockito.when(walletRepository.findByUserId(...)).thenReturn(...);
來模擬資料庫查詢結果,確保測試能順利執行。
測試中的 Message 捕捉轉送
我們透過 Mockito.doAnswer(...) 攔截 RabbitTemplate.convertAndSend(...)
的實際行為,將它轉送到我們事先建立的測試 channel 中(也就是 order.exchange):
orderExchange.send(msg);
這樣契約測試產生的驗證程式才能在 channel 中接收到模擬訊息,進而比對其 routingKey 與 payload。
這樣設計的好處在於
執行測試
./gradlew generateContractTests
./gradlew test
這會:
• 根據 contract.yml 自動產生測試
• 呼叫 processOrderCreate()
• 驗證發送的訊息是否符合預期格式與 header
產生的測試類別會像這樣
@SuppressWarnings("rawtypes")
public class ContractVerifierTest extends BaseContractTest {
@Inject ContractVerifierMessaging contractVerifierMessaging;
@Inject ContractVerifierObjectMapper contractVerifierObjectMapper;
@Test
public void validate_order_create_to_created() throws Exception {
// when:
processOrderCreate();
// then:
ContractVerifierMessage response = contractVerifierMessaging.receive("order.exchange",
contract(this, "order_create_to_created.yml"));
assertThat(response).isNotNull();
// and:
assertThat(response.getHeader("rabbitmq_routingKey")).isNotNull();
assertThat(response.getHeader("rabbitmq_routingKey").toString()).isEqualTo("order.created");
// and:
DocumentContext parsedJson = JsonPath.parse(contractVerifierObjectMapper.writeValueAsString(response.getPayload()));
assertThatJson(parsedJson).field("['orderId']").isEqualTo("123e4567-e89b-12d3-a456-426614174000");
assertThatJson(parsedJson).field("['userId']").isEqualTo("123e4567-e89b-12d3-a456-426614174000");
assertThatJson(parsedJson).field("['price']").isEqualTo(100);
assertThatJson(parsedJson).field("['quantity']").isEqualTo(1);
assertThatJson(parsedJson).field("['type']").isEqualTo("BUY");
assertThatJson(parsedJson).field("['createdAt']").isEqualTo("2025-07-16T12:00:00");
}
}
驗證成功代表什麼?
訊息有被發送-> rabbitTemplate.convertAndSend(...) 有執行
JSON 格式正確->DTO 定義與事件 payload 完全相符
routingKey 正確->寄送到 order.exchange,且為 order.created
沒有例外錯誤->表示整體處理流程與契約行為一致
小結
這樣一來,我就成功實現: