iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
Software Development

事件驅動電力交易平台:Spring Boot 實戰系列 第 6

Day 6|事件契約測試:使用 Spring Cloud Contract 驗證 Wallet Service 發送事件

  • 分享至 

  • xImage
  •  

在前幾篇中,我實作了 CreateOrderListener,當收到 OrderCreateEvent 後會驗證錢包餘額、進行資產鎖定,最後發送一筆 OrderCreatedEvent 到 RabbitMQ。
但如果你也做過事件驅動架構,應該知道:
只靠信任協定在微服務間溝通是非常危險的。
為了保障服務間的「語言一致性」,我在 wallet-service 中導入了 Spring Cloud Contract,搭配 YAML 格式 撰寫契約,並透過自動化測試來驗證發送邏輯的正確性與穩定性。

為什麼要做契約測試?

  • 事件格式改了,下游沒發現:契約測試失敗 → 明確告知格式不符
  • 欄位誤刪、誤改型別:測試在 CI 階段失敗 → 不會在上線後才爆炸
  • 對接測試成本太高:不需要等實際發送,只驗證格式與觸發行為
    專案設定:build.gradle(完整解析)
    這是我在 wallet-service 中使用的 build.gradle 設定,支援 Spring Cloud Contract:
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。
這樣設計的好處在於

  • 契約測試不需要真正跑 RabbitMQ:全程只用 Spring 模擬的 PollableChannel。
  • 程式碼與測試分離:測試中的 mock 明確、乾淨,不會影響實際業務邏輯。
  • 高度一致性:同樣的 CreateOrderListener 被測試執行,契約驗證更具真實性。

執行測試

./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
沒有例外錯誤->表示整體處理流程與契約行為一致

小結
這樣一來,我就成功實現:

  • 以 YAML 編寫事件契約
  • 測試 CreateOrderListener 能正確發送事件
  • 自動產生測試、納入 CI 測試流程中
  • 建立微服務間穩定的語言協議機制

上一篇
Day 5|為什麼選擇事件驅動 Wallet 核定,而不是 API 呼叫?以及我如何追蹤訂單狀態
下一篇
Day 7|從 REST 到事件:Order Service 的請求 → 事件映射全流程
系列文
事件驅動電力交易平台:Spring Boot 實戰9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言