API契約測試 Pact 實戰指南前言在微服務架構中服務之間的集成是系統穩定性的關鍵挑戰之一。傳統的集成測試需要所有相關服務同時運行這在大型系統中變得越來越困難和不切實際。消費者驅動契約測試Consumer-Driven Contract Testing提供了一種全新的方法讓服務的消費者定義它們對提供者服務的期望然後提供者可以驗證是否能滿足這些期望。Pact作為業界最流行的契約測試框架支持多種編程語言並提供了強大的契約管理和驗證功能。本文將深入探討如何在Spring Boot項目中使用Pact進行API契約測試包括核心概念、實現方案、與Spring Cloud Contract的對比以及在持續集成中的最佳實踐。消費者驅動契約測試概念為什麼需要契約測試在微服務架構中當服務A依賴服務B時傳統的集成測試要求兩個服務都運行才能進行測試。這種方式存在幾個明顯的問題首先測試環境的搭建和維護成本高昂其次服務之間的循環依賴可能導致測試無法進行第三測試反饋周期長問題發現不及時。契約測試的核心思想是將服務之間的集成點拆分為獨立的消費者端和提供者端的測試。消費者定義它期望從提供者獲得什麼契約提供者則驗證它是否能滿足這個契約。契約一旦驗證通過就意味著服務之間的集成是可靠的。傳統集成測試 vs 契約測試 傳統方式 ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 服務A │────▶│ 服務B │────▶│ 服務C │ └─────────┘ └─────────┘ └─────────┘ │ │ │ └───────────────┴───────────────┘ │ 需要同時運行 所有服務才能測試 契約測試方式 ┌─────────┐ ┌─────────┐ │ 服務A │ ←───契約定義───→ │ 服務B │ │(消費者) │ │(提供者) │ └─────────┘ └─────────┘ │ │ │ 獨立測試不需要 │ │ 另一方運行 │ └─────────────────────────────┘ │ 契約存儲服務 (Pact Broker)Pact核心術語消費者Consumer使用其他服務API的服務。它定義了對提供者API的期望。提供者Provider暴露API給其他服務使用的服務。它需要驗證是否能滿足消費者的期望。契約Contract消費者和提供者之間關於API交互的協議。它定義了請求格式、響應格式和響應內容。契約文件Contract File契約的實際表現形式通常是JSON文件。Pact Broker用於存儲、分享和管理契約的中央倉庫。Spring Boot消費者端實現Maven配置?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdpact-consumer/artifactId version1.0.0/version properties java.version17/java.version pact.version4.6.3/pact.version spring.cloud.version2023.0.0/spring.cloud.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring.cloud.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdau.com.dius.pact.consumer/groupId artifactIdjunit5/artifactId version${pact.version}/version scopetest/scope /dependency dependency groupIdio.rest-assured/groupId artifactIdrest-assured/artifactId version5.4.0/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId scopetest/scope /dependency /dependencies /project契約測試實現import au.com.dius.pact.consumer.dsl.PactDslJsonBody; import au.com.dius.pact.consumer.dsl.PactDslWithProvider; import au.com.dius.pact.consumer.junit5.PactConsumerTestExt; import au.com.dius.pact.consumer.junit5.PactTestFor; import au.com.dius.pact.core.model.RequestResponsePact; import au.com.dius.pact.core.model.annotations.Pact; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; import org.springframework.boot.test.mock.mockito.MockBean; import reactor.core.publisher.Mono; import java.time.LocalDate; import java.util.List; import java.util.Map; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.assertj.core.api.Assertions.assertThat; ExtendWith(PactConsumerTestExt.class) class ProductConsumerPactTest { MockBean private ProductService productService; Pact(consumer product-client, provider product-service) public RequestResponsePact getProductByIdPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to get product by ID) .path(/api/v1/products/123) .method(GET) .headers(Map.of( Accept, application/json, X-Request-Id, test-request-id )) .willRespondWith() .status(200) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(id, 123) .stringType(name, 測試產品) .stringType(description, 這是一個測試產品) .decimalType(price, 99.99) .integerType(stock, 100) .stringType(category, 電子產品) .stringType(sku, ELEC-001) .booleanType(available, true) .object(manufacturer, mfg - mfg .stringType(name, 測試製造商) .stringType(country, 台灣) ) .minArrayLike(tags, 1, 2) .closeArray() .stringType(createdAt, 2024-01-15T10:30:00Z) .stringType(updatedAt, 2024-01-15T10:30:00Z) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact getProductsWithPaginationPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to get products with pagination) .path(/api/v1/products) .query(page0size10sortname,asc) .method(GET) .headers(Map.of(Accept, application/json)) .willRespondWith() .status(200) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(totalElements, 100) .integerType(totalPages, 10) .integerType(currentPage, 0) .integerType(pageSize, 10) .array(content, content - content .object(obj - obj .integerType(id, 1) .stringType(name, 產品A) .decimalType(price, 50.00) .booleanType(available, true) ) .object(obj - obj .integerType(id, 2) .stringType(name, 產品B) .decimalType(price, 75.00) .booleanType(available, true) ) ) .array(links, links - links .object(link - link .stringValue(rel, self) .stringValue(href, /api/v1/products?page0size10) ) .object(link - link .stringValue(rel, next) .stringValue(href, /api/v1/products?page1size10) ) ) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact searchProductsPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to search products) .path(/api/v1/products/search) .query(qlaptopcategoryelectronicsminPrice1000maxPrice5000) .method(GET) .headers(Map.of(Accept, application/json)) .willRespondWith() .status(200) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(totalResults, 25) .array(products, products - products .object(product - product .integerType(id, 101) .stringType(name, 筆記本電腦) .decimalType(price, 3500.00) .array(highlights, highlights - highlights .stringType(包含 emIntel/em 處理器) .stringType(記憶體 16GB) ) ) ) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact createProductPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to create a new product) .path(/api/v1/products) .method(POST) .headers(Map.of( Content-Type, application/json, Accept, application/json, X-Idempotency-Key, unique-key-123 )) .body(new PactDslJsonBody() .stringType(name, 新產品) .stringType(description, 新產品描述) .decimalType(price, 199.99) .integerType(stock, 50) .stringType(category, 電子產品) .array(tags, tags - tags .stringType(新品) .stringType(熱銷) ) ) .willRespondWith() .status(201) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(id, 999) .stringType(name, 新產品) .stringType(status, CREATED) .stringType(createdAt, 2024-01-15T12:00:00Z) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact productNotFoundPact(PactDslWithProvider builder) { return builder .uponReceiving(a request for non-existent product) .path(/api/v1/products/99999) .method(GET) .headers(Map.of(Accept, application/json)) .willRespondWith() .status(404) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .stringType(error, NOT_FOUND) .stringType(message, 產品不存在) .stringType(timestamp, 2024-01-15T12:00:00Z) .stringType(path, /api/v1/products/99999) ) .toPact(); } Test PactTestFor(pactMethod getProductByIdPact, port 8080) void shouldGetProductById() { Product product productService.getProductById(123); assertThat(product).isNotNull(); assertThat(product.getId()).isEqualTo(123); assertThat(product.getName()).isEqualTo(測試產品); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal(99.99)); assertThat(product.getManufacturer().getName()).isEqualTo(測試製造商); } Test PactTestFor(pactMethod getProductsWithPaginationPact, port 8080) void shouldGetProductsWithPagination() { PagedResultProduct result productService.getProducts(0, 10, name, asc); assertThat(result.getTotalElements()).isEqualTo(100); assertThat(result.getContent()).hasSize(2); assertThat(result.getLinks()).isNotEmpty(); } Test PactTestFor(pactMethod searchProductsPact, port 8080) void shouldSearchProducts() { SearchResultProduct result productService.search( laptop, electronics, new BigDecimal(1000), new BigDecimal(5000)); assertThat(result.getTotalResults()).isEqualTo(25); assertThat(result.getProducts().get(0).getHighlights()).isNotEmpty(); } Test PactTestFor(pactMethod createProductPact, port 8080) void shouldCreateProduct() { CreateProductRequest request CreateProductRequest.builder() .name(新產品) .description(新產品描述) .price(new BigDecimal(199.99)) .stock(50) .category(電子產品) .tags(List.of(新品, 熱銷)) .build(); CreateProductResponse response productService.createProduct(request, unique-key-123); assertThat(response.getId()).isEqualTo(999); assertThat(response.getStatus()).isEqualTo(CREATED); } Test PactTestFor(pactMethod productNotFoundPact, port 8080) void shouldHandleProductNotFound() { assertThatThrownBy(() - productService.getProductById(99999)) .isInstanceOf(ProductNotFoundException.class) .hasMessageContaining(產品不存在); } }Spring Boot提供者端實現Maven配置dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-contract-verifier/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-contract-maven-plugin/artifactId version4.0.4/version extensionstrue/extensions configuration baseClassForTestscom.example.product.ProductContractBaseTest/baseClassForTests testDependencies testDependency groupIdcom.example/groupId artifactIdproduct-contracts/artifactId version${project.version}/version /testDependency /testDependencies /configuration /plugin /plugins /build契約驗證實現SpringBootApplication public class ProductServiceApplication { public static void main(String[] args) { SpringApplication.run(ProductServiceApplication.class, args); } } SpringBootTest AutoConfigureMockMvc public abstract class ProductContractBaseTest { Autowired private MockMvc mockMvc; Autowired private ProductRepository productRepository; MockBean private InventoryService inventoryService; BeforeEach void setUp() { productRepository.deleteAll(); Product product Product.builder() .id(123L) .name(測試產品) .description(這是一個測試產品) .price(new BigDecimal(99.99)) .stock(100) .category(電子產品) .sku(ELEC-001) .available(true) .manufacturer(Manufacturer.builder() .name(測試製造商) .country(台灣) .build()) .tags(List.of(新品, 熱銷)) .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .build(); productRepository.save(product); when(inventoryService.checkAvailability(anyString())).thenReturn(true); } }REST控制器契約測試WebMvcTest(ProductController.class) class ProductControllerContractTest extends ProductContractBaseTest { Test void shouldGetProductById() throws Exception { mockMvc.perform(get(/api/v1/products/123) .header(Accept, application/json) .header(X-Request-Id, test-request-id)) .andExpect(status().isOk()) .andExpect(header().string(Content-Type, application/json)) .andExpect(jsonPath($.id).value(123)) .andExpect(jsonPath($.name).value(測試產品)) .andExpect(jsonPath($.price).value(99.99)) .andExpect(jsonPath($.stock).value(100)) .andExpect(jsonPath($.manufacturer.name).value(測試製造商)); } Test void shouldReturn404WhenProductNotFound() throws Exception { mockMvc.perform(get(/api/v1/products/99999) .header(Accept, application/json)) .andExpect(status().isNotFound()) .andExpect(jsonPath($.error).value(NOT_FOUND)) .andExpect(jsonPath($.message).value(產品不存在)); } }Pact Broker集成使用Docker部署Pact Broker# docker-compose-pact.yml version: 3.8 services: postgres: image: postgres:15-alpine environment: POSTGRES_DB: pact_broker POSTGRES_USER: pact_broker POSTGRES_PASSWORD: pact_broker_pass volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U pact_broker] interval: 10s timeout: 5s retries: 5 pact_broker: image: pactfoundation/pact-broker:2.112.0.0 depends_on: postgres: condition: service_healthy environment: PACT_BROKER_DATABASE_HOST: postgres PACT_BROKER_DATABASE_NAME: pact_broker PACT_BROKER_DATABASE_USERNAME: pact_broker PACT_BROKER_DATABASE_PASSWORD: pact_broker_pass PACT_BROKER_PORT: 9292 PACT_BROKER_LOG_LEVEL: INFO PACT_BROKER_BASE_URL: http://localhost:9292 PACT_BROKER_CORS_ENABLED: true ports: - 9292:9292 volumes: postgres_data:Maven發布契約plugin groupIdau.com.dius.pact.provider/groupId artifactIdmaven/artifactId version4.6.3/version configuration serviceProviders serviceProvider nameproduct-service/name configuration pactFileDirectory${project.build.directory}/pacts/pactFileDirectory /configuration publishVerificationResultstrue/publishVerificationResults providerVersion${project.version}/providerVersion /serviceProvider /serviceProviders /configuration executions execution phasedeploy/phase goals goalpublish/goal /goals /execution /executions /pluginGradle發布契約plugins { id java id maven-publish } pact { publish { pactBrokerUrl http://localhost:9292 pactBrokerUsername pact pactBrokerPassword pact tags [project.version, main] } } publishing { publications { pact(MavenPublication) { artifact(${buildDir}/pacts/*.json) { extension json } } } }持續集成配置GitHub Actions工作流name: Pact Contract Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: consumer-tests: runs-on: ubuntu-latest services: pact_broker: image: pactfoundation/pact-broker:2.112.0.0 ports: - 9292:9292 steps: - uses: actions/checkoutv4 - name: Set up JDK 17 uses: actions/setup-javav4 with: java-version: 17 distribution: temurin - name: Run consumer tests run: mvn test -Dtest*PactTest env: PACT_BROKER_URL: http://localhost:9292 - name: Publish contracts run: mvn pact:publish env: PACT_BROKER_URL: http://localhost:9292 PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} provider-tests: runs-on: ubuntu-latest needs: consumer-tests steps: - uses: actions/checkoutv4 - name: Set up JDK 17 uses: actions/setup-javav4 with: java-version: 17 distribution: temurin - name: Can I Deploy check run: | mvn pact:canIDeploy \ -Dpacticipantproduct-service \ -DpacticipantVersion${GITHUB_SHA} \ -DpactBrokerUrl${{ secrets.PACT_BROKER_URL }}最佳實踐契約版本管理契約版本管理是確保系統穩定性的關鍵。以下是一些重要的版本管理策略/** * 契約版本管理最佳實踐 */ public class ContractVersioningGuide { /** * 1. 向後兼容的變更 * - 添加可選字段 * - 添加新的API端點 * - 這些變更不需要消費者更新契約 */ /** * 2. 需要消費者更新的變更 * - 刪除現有字段 * - 修改字段類型 * - 改變必填字段 * - 需要與消費者協調並同時部署 */ /** * 3. 不兼容的變更 * - 重命名端點 * - 改變URL結構 * - 這些變更需要制定遷移計劃 */ }契約命名約定良好的契約命名可以提高團隊協作效率契約命名格式 {ProviderName}-{ConsumerName}.json 示例 - product-service-order-service.json - payment-service-billing-service.json - user-service-notification-service.json總結Pact契約測試為微服務架構提供了可靠的集成測試方案。通過消費者驅動的契約測試團隊可以獨立開發和測試服務同時確保服務之間的集成不會出現問題。將Pact與持續集成流程結合可以在每次代碼提交時自動驗證契約的有效性大大提高系統的穩定性和交付效率。記住契約測試不是要取代其他類型的測試而是作為測試金字塔的重要補充填補單元測試和端到端測試之間的空白。
API契約測試 Pact 實戰指南
API契約測試 Pact 實戰指南前言在微服務架構中服務之間的集成是系統穩定性的關鍵挑戰之一。傳統的集成測試需要所有相關服務同時運行這在大型系統中變得越來越困難和不切實際。消費者驅動契約測試Consumer-Driven Contract Testing提供了一種全新的方法讓服務的消費者定義它們對提供者服務的期望然後提供者可以驗證是否能滿足這些期望。Pact作為業界最流行的契約測試框架支持多種編程語言並提供了強大的契約管理和驗證功能。本文將深入探討如何在Spring Boot項目中使用Pact進行API契約測試包括核心概念、實現方案、與Spring Cloud Contract的對比以及在持續集成中的最佳實踐。消費者驅動契約測試概念為什麼需要契約測試在微服務架構中當服務A依賴服務B時傳統的集成測試要求兩個服務都運行才能進行測試。這種方式存在幾個明顯的問題首先測試環境的搭建和維護成本高昂其次服務之間的循環依賴可能導致測試無法進行第三測試反饋周期長問題發現不及時。契約測試的核心思想是將服務之間的集成點拆分為獨立的消費者端和提供者端的測試。消費者定義它期望從提供者獲得什麼契約提供者則驗證它是否能滿足這個契約。契約一旦驗證通過就意味著服務之間的集成是可靠的。傳統集成測試 vs 契約測試 傳統方式 ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 服務A │────▶│ 服務B │────▶│ 服務C │ └─────────┘ └─────────┘ └─────────┘ │ │ │ └───────────────┴───────────────┘ │ 需要同時運行 所有服務才能測試 契約測試方式 ┌─────────┐ ┌─────────┐ │ 服務A │ ←───契約定義───→ │ 服務B │ │(消費者) │ │(提供者) │ └─────────┘ └─────────┘ │ │ │ 獨立測試不需要 │ │ 另一方運行 │ └─────────────────────────────┘ │ 契約存儲服務 (Pact Broker)Pact核心術語消費者Consumer使用其他服務API的服務。它定義了對提供者API的期望。提供者Provider暴露API給其他服務使用的服務。它需要驗證是否能滿足消費者的期望。契約Contract消費者和提供者之間關於API交互的協議。它定義了請求格式、響應格式和響應內容。契約文件Contract File契約的實際表現形式通常是JSON文件。Pact Broker用於存儲、分享和管理契約的中央倉庫。Spring Boot消費者端實現Maven配置?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdpact-consumer/artifactId version1.0.0/version properties java.version17/java.version pact.version4.6.3/pact.version spring.cloud.version2023.0.0/spring.cloud.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring.cloud.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdau.com.dius.pact.consumer/groupId artifactIdjunit5/artifactId version${pact.version}/version scopetest/scope /dependency dependency groupIdio.rest-assured/groupId artifactIdrest-assured/artifactId version5.4.0/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId scopetest/scope /dependency /dependencies /project契約測試實現import au.com.dius.pact.consumer.dsl.PactDslJsonBody; import au.com.dius.pact.consumer.dsl.PactDslWithProvider; import au.com.dius.pact.consumer.junit5.PactConsumerTestExt; import au.com.dius.pact.consumer.junit5.PactTestFor; import au.com.dius.pact.core.model.RequestResponsePact; import au.com.dius.pact.core.model.annotations.Pact; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; import org.springframework.boot.test.mock.mockito.MockBean; import reactor.core.publisher.Mono; import java.time.LocalDate; import java.util.List; import java.util.Map; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.assertj.core.api.Assertions.assertThat; ExtendWith(PactConsumerTestExt.class) class ProductConsumerPactTest { MockBean private ProductService productService; Pact(consumer product-client, provider product-service) public RequestResponsePact getProductByIdPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to get product by ID) .path(/api/v1/products/123) .method(GET) .headers(Map.of( Accept, application/json, X-Request-Id, test-request-id )) .willRespondWith() .status(200) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(id, 123) .stringType(name, 測試產品) .stringType(description, 這是一個測試產品) .decimalType(price, 99.99) .integerType(stock, 100) .stringType(category, 電子產品) .stringType(sku, ELEC-001) .booleanType(available, true) .object(manufacturer, mfg - mfg .stringType(name, 測試製造商) .stringType(country, 台灣) ) .minArrayLike(tags, 1, 2) .closeArray() .stringType(createdAt, 2024-01-15T10:30:00Z) .stringType(updatedAt, 2024-01-15T10:30:00Z) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact getProductsWithPaginationPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to get products with pagination) .path(/api/v1/products) .query(page0size10sortname,asc) .method(GET) .headers(Map.of(Accept, application/json)) .willRespondWith() .status(200) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(totalElements, 100) .integerType(totalPages, 10) .integerType(currentPage, 0) .integerType(pageSize, 10) .array(content, content - content .object(obj - obj .integerType(id, 1) .stringType(name, 產品A) .decimalType(price, 50.00) .booleanType(available, true) ) .object(obj - obj .integerType(id, 2) .stringType(name, 產品B) .decimalType(price, 75.00) .booleanType(available, true) ) ) .array(links, links - links .object(link - link .stringValue(rel, self) .stringValue(href, /api/v1/products?page0size10) ) .object(link - link .stringValue(rel, next) .stringValue(href, /api/v1/products?page1size10) ) ) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact searchProductsPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to search products) .path(/api/v1/products/search) .query(qlaptopcategoryelectronicsminPrice1000maxPrice5000) .method(GET) .headers(Map.of(Accept, application/json)) .willRespondWith() .status(200) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(totalResults, 25) .array(products, products - products .object(product - product .integerType(id, 101) .stringType(name, 筆記本電腦) .decimalType(price, 3500.00) .array(highlights, highlights - highlights .stringType(包含 emIntel/em 處理器) .stringType(記憶體 16GB) ) ) ) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact createProductPact(PactDslWithProvider builder) { return builder .uponReceiving(a request to create a new product) .path(/api/v1/products) .method(POST) .headers(Map.of( Content-Type, application/json, Accept, application/json, X-Idempotency-Key, unique-key-123 )) .body(new PactDslJsonBody() .stringType(name, 新產品) .stringType(description, 新產品描述) .decimalType(price, 199.99) .integerType(stock, 50) .stringType(category, 電子產品) .array(tags, tags - tags .stringType(新品) .stringType(熱銷) ) ) .willRespondWith() .status(201) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .integerType(id, 999) .stringType(name, 新產品) .stringType(status, CREATED) .stringType(createdAt, 2024-01-15T12:00:00Z) ) .toPact(); } Pact(consumer product-client, provider product-service) public RequestResponsePact productNotFoundPact(PactDslWithProvider builder) { return builder .uponReceiving(a request for non-existent product) .path(/api/v1/products/99999) .method(GET) .headers(Map.of(Accept, application/json)) .willRespondWith() .status(404) .headers(Map.of(Content-Type, application/json)) .body(new PactDslJsonBody() .stringType(error, NOT_FOUND) .stringType(message, 產品不存在) .stringType(timestamp, 2024-01-15T12:00:00Z) .stringType(path, /api/v1/products/99999) ) .toPact(); } Test PactTestFor(pactMethod getProductByIdPact, port 8080) void shouldGetProductById() { Product product productService.getProductById(123); assertThat(product).isNotNull(); assertThat(product.getId()).isEqualTo(123); assertThat(product.getName()).isEqualTo(測試產品); assertThat(product.getPrice()).isEqualByComparingTo(new BigDecimal(99.99)); assertThat(product.getManufacturer().getName()).isEqualTo(測試製造商); } Test PactTestFor(pactMethod getProductsWithPaginationPact, port 8080) void shouldGetProductsWithPagination() { PagedResultProduct result productService.getProducts(0, 10, name, asc); assertThat(result.getTotalElements()).isEqualTo(100); assertThat(result.getContent()).hasSize(2); assertThat(result.getLinks()).isNotEmpty(); } Test PactTestFor(pactMethod searchProductsPact, port 8080) void shouldSearchProducts() { SearchResultProduct result productService.search( laptop, electronics, new BigDecimal(1000), new BigDecimal(5000)); assertThat(result.getTotalResults()).isEqualTo(25); assertThat(result.getProducts().get(0).getHighlights()).isNotEmpty(); } Test PactTestFor(pactMethod createProductPact, port 8080) void shouldCreateProduct() { CreateProductRequest request CreateProductRequest.builder() .name(新產品) .description(新產品描述) .price(new BigDecimal(199.99)) .stock(50) .category(電子產品) .tags(List.of(新品, 熱銷)) .build(); CreateProductResponse response productService.createProduct(request, unique-key-123); assertThat(response.getId()).isEqualTo(999); assertThat(response.getStatus()).isEqualTo(CREATED); } Test PactTestFor(pactMethod productNotFoundPact, port 8080) void shouldHandleProductNotFound() { assertThatThrownBy(() - productService.getProductById(99999)) .isInstanceOf(ProductNotFoundException.class) .hasMessageContaining(產品不存在); } }Spring Boot提供者端實現Maven配置dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-contract-verifier/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-contract-maven-plugin/artifactId version4.0.4/version extensionstrue/extensions configuration baseClassForTestscom.example.product.ProductContractBaseTest/baseClassForTests testDependencies testDependency groupIdcom.example/groupId artifactIdproduct-contracts/artifactId version${project.version}/version /testDependency /testDependencies /configuration /plugin /plugins /build契約驗證實現SpringBootApplication public class ProductServiceApplication { public static void main(String[] args) { SpringApplication.run(ProductServiceApplication.class, args); } } SpringBootTest AutoConfigureMockMvc public abstract class ProductContractBaseTest { Autowired private MockMvc mockMvc; Autowired private ProductRepository productRepository; MockBean private InventoryService inventoryService; BeforeEach void setUp() { productRepository.deleteAll(); Product product Product.builder() .id(123L) .name(測試產品) .description(這是一個測試產品) .price(new BigDecimal(99.99)) .stock(100) .category(電子產品) .sku(ELEC-001) .available(true) .manufacturer(Manufacturer.builder() .name(測試製造商) .country(台灣) .build()) .tags(List.of(新品, 熱銷)) .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .build(); productRepository.save(product); when(inventoryService.checkAvailability(anyString())).thenReturn(true); } }REST控制器契約測試WebMvcTest(ProductController.class) class ProductControllerContractTest extends ProductContractBaseTest { Test void shouldGetProductById() throws Exception { mockMvc.perform(get(/api/v1/products/123) .header(Accept, application/json) .header(X-Request-Id, test-request-id)) .andExpect(status().isOk()) .andExpect(header().string(Content-Type, application/json)) .andExpect(jsonPath($.id).value(123)) .andExpect(jsonPath($.name).value(測試產品)) .andExpect(jsonPath($.price).value(99.99)) .andExpect(jsonPath($.stock).value(100)) .andExpect(jsonPath($.manufacturer.name).value(測試製造商)); } Test void shouldReturn404WhenProductNotFound() throws Exception { mockMvc.perform(get(/api/v1/products/99999) .header(Accept, application/json)) .andExpect(status().isNotFound()) .andExpect(jsonPath($.error).value(NOT_FOUND)) .andExpect(jsonPath($.message).value(產品不存在)); } }Pact Broker集成使用Docker部署Pact Broker# docker-compose-pact.yml version: 3.8 services: postgres: image: postgres:15-alpine environment: POSTGRES_DB: pact_broker POSTGRES_USER: pact_broker POSTGRES_PASSWORD: pact_broker_pass volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U pact_broker] interval: 10s timeout: 5s retries: 5 pact_broker: image: pactfoundation/pact-broker:2.112.0.0 depends_on: postgres: condition: service_healthy environment: PACT_BROKER_DATABASE_HOST: postgres PACT_BROKER_DATABASE_NAME: pact_broker PACT_BROKER_DATABASE_USERNAME: pact_broker PACT_BROKER_DATABASE_PASSWORD: pact_broker_pass PACT_BROKER_PORT: 9292 PACT_BROKER_LOG_LEVEL: INFO PACT_BROKER_BASE_URL: http://localhost:9292 PACT_BROKER_CORS_ENABLED: true ports: - 9292:9292 volumes: postgres_data:Maven發布契約plugin groupIdau.com.dius.pact.provider/groupId artifactIdmaven/artifactId version4.6.3/version configuration serviceProviders serviceProvider nameproduct-service/name configuration pactFileDirectory${project.build.directory}/pacts/pactFileDirectory /configuration publishVerificationResultstrue/publishVerificationResults providerVersion${project.version}/providerVersion /serviceProvider /serviceProviders /configuration executions execution phasedeploy/phase goals goalpublish/goal /goals /execution /executions /pluginGradle發布契約plugins { id java id maven-publish } pact { publish { pactBrokerUrl http://localhost:9292 pactBrokerUsername pact pactBrokerPassword pact tags [project.version, main] } } publishing { publications { pact(MavenPublication) { artifact(${buildDir}/pacts/*.json) { extension json } } } }持續集成配置GitHub Actions工作流name: Pact Contract Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: consumer-tests: runs-on: ubuntu-latest services: pact_broker: image: pactfoundation/pact-broker:2.112.0.0 ports: - 9292:9292 steps: - uses: actions/checkoutv4 - name: Set up JDK 17 uses: actions/setup-javav4 with: java-version: 17 distribution: temurin - name: Run consumer tests run: mvn test -Dtest*PactTest env: PACT_BROKER_URL: http://localhost:9292 - name: Publish contracts run: mvn pact:publish env: PACT_BROKER_URL: http://localhost:9292 PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} provider-tests: runs-on: ubuntu-latest needs: consumer-tests steps: - uses: actions/checkoutv4 - name: Set up JDK 17 uses: actions/setup-javav4 with: java-version: 17 distribution: temurin - name: Can I Deploy check run: | mvn pact:canIDeploy \ -Dpacticipantproduct-service \ -DpacticipantVersion${GITHUB_SHA} \ -DpactBrokerUrl${{ secrets.PACT_BROKER_URL }}最佳實踐契約版本管理契約版本管理是確保系統穩定性的關鍵。以下是一些重要的版本管理策略/** * 契約版本管理最佳實踐 */ public class ContractVersioningGuide { /** * 1. 向後兼容的變更 * - 添加可選字段 * - 添加新的API端點 * - 這些變更不需要消費者更新契約 */ /** * 2. 需要消費者更新的變更 * - 刪除現有字段 * - 修改字段類型 * - 改變必填字段 * - 需要與消費者協調並同時部署 */ /** * 3. 不兼容的變更 * - 重命名端點 * - 改變URL結構 * - 這些變更需要制定遷移計劃 */ }契約命名約定良好的契約命名可以提高團隊協作效率契約命名格式 {ProviderName}-{ConsumerName}.json 示例 - product-service-order-service.json - payment-service-billing-service.json - user-service-notification-service.json總結Pact契約測試為微服務架構提供了可靠的集成測試方案。通過消費者驅動的契約測試團隊可以獨立開發和測試服務同時確保服務之間的集成不會出現問題。將Pact與持續集成流程結合可以在每次代碼提交時自動驗證契約的有效性大大提高系統的穩定性和交付效率。記住契約測試不是要取代其他類型的測試而是作為測試金字塔的重要補充填補單元測試和端到端測試之間的空白。