Skip to content

ChunPingWang/spring-cloud-contract-tutorial

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spring Cloud Contract 完整教程

從零開始學習 Consumer-Driven Contract Testing(消費者驅動的合約測試)


目錄

  1. 什麼是 Spring Cloud Contract?
  2. 為什麼需要合約測試?
  3. 核心概念
  4. 專案架構總覽
  5. Producer 端實作教學
  6. Consumer 端實作教學
  7. 合約 DSL 語法詳解
  8. YAML 合約格式
  9. 進階功能
  10. 訊息驅動合約
  11. 常見問題與解決方案
  12. 最佳實踐
  13. 完整工作流程

1. 什麼是 Spring Cloud Contract?

Spring Cloud Contract 是一個支援 Consumer-Driven Contract(CDC) 測試的框架。它確保微服務之間的 API 溝通符合雙方預期,避免在整合時出現問題。

核心理念

┌──────────────┐      合約 (Contract)      ┌──────────────┐
│   Consumer   │ ◄──────────────────────► │   Producer   │
│  (消費者端)   │    定義 API 互動的規範     │  (提供者端)   │
└──────────────┘                          └──────────────┘
       │                                         │
       │  使用 Stub 進行測試                       │  根據合約自動產生測試
       │  (不需要啟動真實 Producer)                 │  (確保 API 符合合約)
       ▼                                         ▼
  ┌──────────┐                            ┌──────────────┐
  │ WireMock │                            │ 自動產生的測試  │
  │  Stub    │                            │ (驗證 API)    │
  └──────────┘                            └──────────────┘

簡單來說:

  • 合約(Contract):Producer 和 Consumer 之間的 API 協議
  • Producer:提供 API 的服務,需要滿足合約
  • Consumer:呼叫 API 的服務,依賴合約來進行測試

2. 為什麼需要合約測試?

傳統整合測試的問題

問題 說明
環境依賴 需要所有服務同時運行才能測試
測試緩慢 啟動多個服務非常耗時
難以定位問題 錯誤可能來自任何一方
脆弱的測試 一個服務的改動可能破壞所有相關測試

合約測試的解決方案

優勢 說明
獨立測試 Producer 和 Consumer 可以各自獨立測試
快速反饋 不需要啟動整個系統
清晰的責任 合約明確定義了雙方的責任
自動化驗證 合約變更時,雙方都能自動發現不相容的改動

3. 核心概念

3.1 合約(Contract)

合約定義了 Producer 和 Consumer 之間 API 交互的規範,包括:

  • Request:HTTP 方法、URL、Headers、Body
  • Response:狀態碼、Headers、Body

3.2 Stub

從合約自動產生的模擬服務(基於 WireMock),讓 Consumer 可以在不啟動真實 Producer 的情況下進行測試。

3.3 自動產生測試

Spring Cloud Contract 會根據合約自動產生 Producer 端的測試類別,確保 Producer 的 API 實作符合合約。

3.4 工作流程

1. 定義合約(通常放在 Producer 端)
          │
          ▼
2. Producer 端:
   - Maven Plugin 根據合約自動產生測試
   - 執行測試,確保 API 實作符合合約
   - 產生 Stub JAR 並安裝到 Maven 倉庫
          │
          ▼
3. Consumer 端:
   - 使用 Stub Runner 載入 Stub JAR
   - 自動啟動 WireMock 伺服器
   - 對 WireMock 進行測試(模擬真實 Producer)

4. 專案架構總覽

spring-cloud-contract-tutorial/
│
├── producer/                          # Producer 端專案
│   ├── pom.xml                        # Maven 設定(含 Contract Plugin)
│   ├── src/main/java/
│   │   └── com/example/producer/
│   │       ├── ProducerApplication.java
│   │       ├── controller/
│   │       │   ├── UserController.java      # User REST API
│   │       │   └── OrderController.java     # Order REST API
│   │       ├── model/
│   │       │   ├── User.java
│   │       │   └── Order.java
│   │       └── service/
│   │           ├── UserService.java
│   │           └── OrderService.java
│   └── src/test/
│       ├── java/com/example/producer/
│       │   ├── BaseContractTest.java        # 預設基底測試類別
│       │   ├── UserBaseContractTest.java    # User 合約基底類別
│       │   └── OrderBaseContractTest.java   # Order 合約基底類別
│       └── resources/contracts/
│           ├── user/                        # User 相關合約
│           │   ├── shouldReturnUserById.groovy
│           │   ├── shouldReturnAllUsers.groovy
│           │   ├── shouldCreateNewUser.groovy
│           │   ├── shouldUpdateUser.groovy
│           │   ├── shouldDeleteUser.groovy
│           │   └── shouldReturnNotFoundForMissingUser.groovy
│           └── order/                       # Order 相關合約
│               ├── shouldReturnOrderById.groovy
│               ├── shouldReturnOrdersByUserId.groovy
│               ├── shouldCreateOrder.groovy
│               └── shouldUpdateOrderStatus.groovy
│
├── consumer/                          # Consumer 端專案
│   ├── pom.xml                        # Maven 設定(含 Stub Runner)
│   ├── src/main/java/
│   │   └── com/example/consumer/
│   │       ├── ConsumerApplication.java
│   │       ├── controller/
│   │       │   └── ConsumerController.java
│   │       ├── model/
│   │       │   ├── User.java
│   │       │   └── Order.java
│   │       ├── service/
│   │       │   ├── UserServiceClient.java   # 呼叫 Producer API
│   │       │   └── OrderServiceClient.java
│   │       └── config/
│   │           └── RestTemplateConfig.java
│   └── src/test/java/
│       └── com/example/consumer/
│           ├── UserServiceClientContractTest.java
│           └── OrderServiceClientContractTest.java
│
└── advanced-examples/                 # 進階範例
    ├── messaging-producer/            # 訊息驅動合約範例
    └── yaml-contracts/                # YAML 格式合約範例

5. Producer 端實作教學

5.1 Maven 設定

Producer 端需要加入 spring-cloud-contract-maven-pluginspring-cloud-starter-contract-verifier

pom.xml 關鍵設定:

<!-- 依賴:合約驗證器 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-verifier</artifactId>
    <scope>test</scope>
</dependency>

<!-- Plugin:自動產生測試和 Stub -->
<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>${spring-cloud-contract.version}</version>
    <extensions>true</extensions>
    <configuration>
        <!-- 指定預設的基底測試類別 -->
        <baseClassForTests>
            com.example.producer.BaseContractTest
        </baseClassForTests>
        <!-- 按目錄映射不同的基底類別 -->
        <baseClassMappings>
            <baseClassMapping>
                <contractPackageRegex>.*user.*</contractPackageRegex>
                <baseClassFQN>com.example.producer.UserBaseContractTest</baseClassFQN>
            </baseClassMapping>
            <baseClassMapping>
                <contractPackageRegex>.*order.*</contractPackageRegex>
                <baseClassFQN>com.example.producer.OrderBaseContractTest</baseClassFQN>
            </baseClassMapping>
        </baseClassMappings>
    </configuration>
</plugin>

5.2 撰寫 REST API

UserController 為例:

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        return userService.getUserById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
        User created = userService.createUser(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
    // ... 其他方法
}

5.3 定義合約

合約放在 src/test/resources/contracts/ 目錄下。

範例 1:基本 GET 請求合約

// shouldReturnUserById.groovy
import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description "應該根據 ID 回傳使用者資料"

    request {
        method GET()
        url "/api/users/1"
        headers {
            contentType applicationJson()
        }
    }

    response {
        status OK()              // 200
        headers {
            contentType applicationJson()
        }
        body(
            id: 1,
            name: "Alice",
            email: "alice@example.com",
            age: 28
        )
    }
}

範例 2:POST 請求合約

// shouldCreateNewUser.groovy
Contract.make {
    description "應該成功建立新使用者並回傳 201"

    request {
        method POST()
        url "/api/users"
        headers {
            contentType applicationJson()
        }
        body(
            name: "David",
            email: "david@example.com",
            age: 30
        )
    }

    response {
        status CREATED()         // 201
        headers {
            contentType applicationJson()
        }
        body(
            // producer() 端使用 regex 做彈性匹配
            // consumer() 端使用固定值產生 stub
            id: $(producer(regex(positiveInt())), consumer(4)),
            name: "David",
            email: "david@example.com",
            age: 30
        )
    }
}

範例 3:錯誤情境合約(404)

// shouldReturnNotFoundForMissingUser.groovy
Contract.make {
    description "查詢不存在的使用者應回傳 404"

    request {
        method GET()
        url "/api/users/999"
    }

    response {
        status NOT_FOUND()       // 404
    }
}

範例 4:DELETE 請求合約

// shouldDeleteUser.groovy
Contract.make {
    description "應該成功刪除使用者並回傳 204"

    request {
        method DELETE()
        url "/api/users/1"
    }

    response {
        status NO_CONTENT()      // 204
    }
}

範例 5:PATCH 請求合約

// shouldUpdateOrderStatus.groovy
Contract.make {
    description "應該成功更新訂單狀態"

    request {
        method PATCH()
        url "/api/orders/1/status"
        headers {
            contentType applicationJson()
        }
        body(
            status: "SHIPPED"
        )
    }

    response {
        status OK()
        headers {
            contentType applicationJson()
        }
        body(
            id: 1,
            status: "SHIPPED"
            // ... 其他欄位
        )
    }
}

5.4 撰寫基底測試類別

Spring Cloud Contract 自動產生的測試需要繼承一個基底類別:

@SpringBootTest
public abstract class UserBaseContractTest {

    @Autowired
    private UserController userController;

    @BeforeEach
    void setup() {
        // 設定 RestAssuredMockMvc,用來模擬 HTTP 請求
        RestAssuredMockMvc.standaloneSetup(userController);
    }
}

5.5 執行與產生 Stub

# 進入 producer 目錄
cd producer

# 執行測試 + 產生 Stub JAR
mvn clean install

# 執行後會:
# 1. 在 target/generated-test-sources/ 產生測試類別
# 2. 執行自動產生的測試
# 3. 產生 Stub JAR 並安裝到本地 Maven 倉庫

自動產生的測試類別(示意):

// 自動產生,不需手動撰寫
public class UserTest extends UserBaseContractTest {

    @Test
    public void validate_shouldReturnUserById() throws Exception {
        // given:
        MockMvcRequestSpecification request = given()
            .header("Content-Type", "application/json");

        // when:
        ResponseOptions response = given().spec(request)
            .get("/api/users/1");

        // then:
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type")).matches("application/json.*");

        // and:
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).field("['id']").isEqualTo(1);
        assertThatJson(parsedJson).field("['name']").isEqualTo("Alice");
        assertThatJson(parsedJson).field("['email']").isEqualTo("alice@example.com");
        assertThatJson(parsedJson).field("['age']").isEqualTo(28);
    }
}

6. Consumer 端實作教學

6.1 Maven 設定

Consumer 端需要加入 spring-cloud-starter-contract-stub-runner

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    <scope>test</scope>
</dependency>

6.2 撰寫 Service Client

Consumer 使用 RestTemplate 呼叫 Producer API:

@Service
public class UserServiceClient {

    private final RestTemplate restTemplate;
    private final String baseUrl;

    public UserServiceClient(RestTemplate restTemplate,
                             @Value("${producer.base-url}") String baseUrl) {
        this.restTemplate = restTemplate;
        this.baseUrl = baseUrl;
    }

    public Optional<User> getUserById(Long id) {
        try {
            User user = restTemplate.getForObject(
                    baseUrl + "/api/users/{id}", User.class, id);
            return Optional.ofNullable(user);
        } catch (HttpClientErrorException.NotFound e) {
            return Optional.empty();
        }
    }

    public User createUser(User user) {
        return restTemplate.postForObject(
                baseUrl + "/api/users", user, User.class);
    }
}

6.3 撰寫合約測試

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(
    // 格式:groupId:artifactId:version:classifier:port
    // + 表示最新版本
    ids = "com.example:contract-producer:+:stubs:8080",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
public class UserServiceClientContractTest {

    @Autowired
    private UserServiceClient userServiceClient;

    @Test
    void shouldGetUserById() {
        // Stub Runner 已經啟動了 WireMock,
        // 載入了 Producer 的 Stub
        Optional<User> user = userServiceClient.getUserById(1L);

        assertThat(user).isPresent();
        assertThat(user.get().getId()).isEqualTo(1L);
        assertThat(user.get().getName()).isEqualTo("Alice");
        assertThat(user.get().getEmail()).isEqualTo("alice@example.com");
    }

    @Test
    void shouldReturnEmptyForMissingUser() {
        Optional<User> user = userServiceClient.getUserById(999L);

        assertThat(user).isEmpty();
    }

    @Test
    void shouldCreateUser() {
        User newUser = new User();
        newUser.setName("David");
        newUser.setEmail("david@example.com");
        newUser.setAge(30);

        User created = userServiceClient.createUser(newUser);

        assertThat(created).isNotNull();
        assertThat(created.getId()).isPositive();
        assertThat(created.getName()).isEqualTo("David");
    }
}

6.4 @AutoConfigureStubRunner 詳解

參數 說明
ids Stub 的 Maven 座標,格式 groupId:artifactId:version:classifier:port
stubsMode LOCAL(本地 Maven 倉庫)、REMOTE(遠端倉庫)、CLASSPATH(類別路徑)
repositoryRoot 遠端倉庫的 URL(REMOTE 模式使用)

ids 格式範例:

com.example:contract-producer:+:stubs:8080
│              │               │   │    │
│              │               │   │    └── WireMock 啟動的 port
│              │               │   └── classifier(固定為 stubs)
│              │               └── 版本(+ 表示最新)
│              └── artifactId
└── groupId

6.5 執行 Consumer 測試

# 前提:先在 Producer 端執行 mvn clean install
cd producer
mvn clean install

# 然後在 Consumer 端執行測試
cd ../consumer
mvn clean test

7. 合約 DSL 語法詳解

7.1 HTTP 方法

request {
    method GET()       // GET 請求
    method POST()      // POST 請求
    method PUT()       // PUT 請求
    method PATCH()     // PATCH 請求
    method DELETE()    // DELETE 請求
}

7.2 URL 與查詢參數

request {
    // 固定 URL
    url "/api/users/1"

    // URL 帶查詢參數
    url("/api/users") {
        queryParameters {
            parameter "page": 0
            parameter "size": 10
            parameter "sort": "name,asc"
        }
    }

    // URL 使用正則表達式
    urlPath $(consumer(regex("/api/users/[0-9]+")))
}

7.3 Headers

request {
    headers {
        contentType applicationJson()
        header "Authorization": "Bearer token123"
        header "X-Custom-Header": $(consumer("value"), producer(regex(nonEmpty())))
    }
}

response {
    headers {
        contentType applicationJson()
        header "X-Request-Id": $(producer(regex(uuid())), consumer("550e8400-e29b-41d4-a716-446655440000"))
    }
}

7.4 Request Body

request {
    // 物件格式
    body(
        name: "David",
        email: "david@example.com",
        age: 30
    )

    // 巢狀物件
    body(
        user: [
            name: "David",
            address: [
                city: "Taipei",
                zip: "100"
            ]
        ]
    )

    // 從檔案讀取
    body(file("request.json"))
}

7.5 Response Body 與動態值

response {
    body(
        // 固定值
        name: "Alice",

        // Producer 端使用 regex,Consumer 端使用固定值
        id: $(producer(regex(positiveInt())), consumer(1)),

        // 簡寫
        email: $(regex(email())),

        // 自訂正則
        status: $(regex("ACTIVE|INACTIVE|PENDING"))
    )
}

7.6 內建正則表達式

方法 匹配 範例
onlyAlphaUnicode() 只有字母 Alice
alphaNumeric() 字母和數字 User123
number() 數字(含小數) 42, 3.14
positiveInt() 正整數 1, 42
anyBoolean() 布林值 true, false
ip() IP 位址 192.168.1.1
hostname() 主機名 example.com
email() Email user@example.com
url() URL https://example.com
uuid() UUID 550e8400-...
isoDate() ISO 日期 2024-01-01
isoDateTime() ISO 日期時間 2024-01-01T00:00:00
isoTime() ISO 時間 12:00:00
nonEmpty() 非空字串 anything
nonBlank() 非空白字串 anything

7.7 HTTP 狀態碼

response {
    status OK()                  // 200
    status CREATED()             // 201
    status ACCEPTED()            // 202
    status NO_CONTENT()          // 204
    status MOVED_PERMANENTLY()   // 301
    status FOUND()               // 302
    status BAD_REQUEST()         // 400
    status UNAUTHORIZED()        // 401
    status FORBIDDEN()           // 403
    status NOT_FOUND()           // 404
    status CONFLICT()            // 409
    status TOO_MANY_REQUESTS()   // 429
    status INTERNAL_SERVER_ERROR() // 500
    status SERVICE_UNAVAILABLE() // 503
    status 200                   // 直接用數字
}

8. YAML 合約格式

除了 Groovy DSL,也可以使用 YAML 格式來定義合約。適合不熟悉 Groovy 的團隊。

8.1 基本 GET 請求

# shouldReturnUserById.yml
description: "應該根據 ID 回傳使用者資料"
request:
  method: GET
  url: /api/users/1
  headers:
    Content-Type: application/json
response:
  status: 200
  headers:
    Content-Type: application/json
  body:
    id: 1
    name: "Alice"
    email: "alice@example.com"
    age: 28

8.2 POST 請求 + 正則匹配

# shouldCreateUser.yml
description: "應該成功建立新使用者"
request:
  method: POST
  url: /api/users
  headers:
    Content-Type: application/json
  body:
    name: "David"
    email: "david@example.com"
    age: 30
response:
  status: 201
  headers:
    Content-Type: application/json
  body:
    id: 4
    name: "David"
    email: "david@example.com"
    age: 30
  matchers:
    body:
      - path: $.id
        type: by_regex
        value: "[0-9]+"

8.3 Groovy vs YAML 比較

特性 Groovy DSL YAML
學習曲線 需要了解 Groovy 直覺易懂
動態值 $(producer(...), consumer(...)) matchers 區塊
複雜邏輯 支援(可寫 Groovy 程式碼) 有限
IDE 支援 IntelliJ 有完整支援 基本語法高亮
推薦場景 複雜合約 簡單合約、跨語言團隊

9. 進階功能

9.1 使用 producer()consumer() 的差異

body(
    // producer() 側:用於 Producer 測試時的驗證規則
    // consumer() 側:用於產生 Stub 時回傳的固定值
    id: $(producer(regex(positiveInt())), consumer(42))
)

白話解釋:

  • Producer 測試跑起來時:API 回傳的 id 只要是正整數就通過
  • Consumer 使用 Stub 時:WireMock 會固定回傳 id: 42

9.2 多合約定義在同一檔案

import org.springframework.cloud.contract.spec.Contract

[
    Contract.make {
        description "取得使用者 - 成功"
        request {
            method GET()
            url "/api/users/1"
        }
        response {
            status OK()
            body(id: 1, name: "Alice")
        }
    },
    Contract.make {
        description "取得使用者 - 不存在"
        request {
            method GET()
            url "/api/users/999"
        }
        response {
            status NOT_FOUND()
        }
    }
]

9.3 Priority(優先權)

當多個合約匹配同一個請求時,可以設定優先權:

Contract.make {
    priority 1  // 數字越小優先權越高
    request {
        method GET()
        url "/api/users/1"
    }
    response {
        status OK()
        body(id: 1, name: "Alice")
    }
}

9.4 Body Matchers(進階匹配)

Contract.make {
    request {
        method POST()
        url "/api/users"
        body(
            name: "David",
            email: "david@example.com"
        )
        bodyMatchers {
            // 驗證 name 欄位符合正則
            jsonPath('$.name', byRegex(nonEmpty()))
            // 驗證 email 欄位符合 email 格式
            jsonPath('$.email', byRegex(email()))
        }
    }
    response {
        status CREATED()
        body(
            id: 1,
            name: "David"
        )
        bodyMatchers {
            jsonPath('$.id', byRegex(positiveInt()))
        }
    }
}

9.5 使用外部 JSON 檔案

Contract.make {
    request {
        method POST()
        url "/api/users"
        headers {
            contentType applicationJson()
        }
        // 從檔案讀取 request body
        body(file("createUserRequest.json"))
    }
    response {
        status CREATED()
        headers {
            contentType applicationJson()
        }
        // 從檔案讀取 response body
        body(file("createUserResponse.json"))
    }
}

9.6 查詢參數合約

Contract.make {
    description "分頁查詢使用者"

    request {
        method GET()
        url("/api/users") {
            queryParameters {
                parameter "page": $(consumer(regex("[0-9]+")), producer("0"))
                parameter "size": $(consumer(regex("[0-9]+")), producer("10"))
            }
        }
    }

    response {
        status OK()
        headers {
            contentType applicationJson()
        }
        body(
            content: [
                [id: 1, name: "Alice"],
                [id: 2, name: "Bob"]
            ],
            totalElements: 2,
            totalPages: 1,
            currentPage: 0
        )
    }
}

10. 訊息驅動合約

Spring Cloud Contract 不僅支援 HTTP 合約,也支援訊息驅動(Messaging)合約。

10.1 定義訊息合約

Contract.make {
    description "當使用者建立時,應該發送 USER_CREATED 事件"

    // 標籤:用來觸發訊息的標記
    label "user_created"

    // 輸入:觸發訊息發送的條件
    input {
        triggeredBy("publishUserCreatedEvent()")
    }

    // 輸出訊息
    outputMessage {
        sentTo "user-events"
        headers {
            header("contentType", applicationJson())
        }
        body(
            eventType: "USER_CREATED",
            userId: $(producer(regex(positiveInt())), consumer(1)),
            userName: $(producer(regex(nonEmpty())), consumer("Alice")),
            email: $(producer(regex(email())), consumer("alice@example.com")),
            timestamp: $(producer(regex(isoDateTime())), consumer("2024-01-01T00:00:00Z"))
        )
    }
}

10.2 Producer 端基底類別

@SpringBootTest
public abstract class MessagingBaseContractTest {

    @Autowired
    private UserEventPublisher userEventPublisher;

    // 方法名稱必須與合約中的 triggeredBy 一致
    public void publishUserCreatedEvent() {
        userEventPublisher.publishUserCreated(1L, "Alice", "alice@example.com");
    }
}

10.3 Consumer 端訊息測試

@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.example:messaging-producer:+:stubs",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
public class UserEventConsumerTest {

    @Autowired
    private StubTrigger stubTrigger;

    @Test
    void shouldReceiveUserCreatedEvent() {
        // 觸發訊息
        stubTrigger.trigger("user_created");

        // 驗證 Consumer 正確處理了訊息
        // ...
    }
}

11. 常見問題與解決方案

Q1:合約測試失敗,找不到基底類別?

原因: baseClassForTests 設定不正確。

解決: 確認 pom.xml 中的完整類別名稱正確:

<baseClassForTests>
    com.example.producer.BaseContractTest
</baseClassForTests>

Q2:Consumer 找不到 Stub?

原因: Producer 的 Stub JAR 尚未安裝到本地 Maven 倉庫。

解決: 在 Producer 端先執行:

cd producer
mvn clean install

Q3:Stub 的回應與預期不符?

原因: 合約中的 consumer() 值與測試預期不一致。

解決: 檢查合約中的 consumer() 部分是否正確:

// consumer() 的值會成為 Stub 回傳的值
id: $(producer(regex(positiveInt())), consumer(42))
// Consumer 測試中應該預期 id = 42

Q4:多個合約匹配同一個 URL?

解決: 使用 priority 設定優先順序,或確保 request 的區分度(不同 header、body 等)。

Q5:如何在 CI/CD 中使用?

方案一:本地安裝

# 先 build Producer
cd producer && mvn clean install

# 再 test Consumer
cd ../consumer && mvn clean test

方案二:使用遠端倉庫

@AutoConfigureStubRunner(
    ids = "com.example:contract-producer:+:stubs:8080",
    stubsMode = StubRunnerProperties.StubsMode.REMOTE,
    repositoryRoot = "https://nexus.example.com/repository/maven-releases/"
)

方案三:使用 Git 倉庫

@AutoConfigureStubRunner(
    ids = "com.example:contract-producer",
    stubsMode = StubRunnerProperties.StubsMode.REMOTE,
    repositoryRoot = "git://https://github.com/example/contracts.git"
)

Q6:如何除錯合約測試?

  1. 查看自動產生的測試:target/generated-test-sources/contracts/
  2. 查看產生的 Stub:target/stubs/
  3. 開啟 WireMock 日誌:
# application-test.yml
logging:
  level:
    com.github.tomakehurst.wiremock: DEBUG

12. 最佳實踐

12.1 合約管理

實踐 說明
合約放在 Producer 端 Producer 維護合約,確保合約與實作一致
按功能分目錄 例如 contracts/user/contracts/order/
合約命名清晰 使用 should... 開頭描述預期行為
版本控制 合約隨程式碼一起版本控制

12.2 合約設計原則

  1. 一個合約只測試一個情境

    • 好:shouldReturnUserById.groovy
    • 壞:把所有 User 相關測試塞在同一個合約
  2. 使用正則表達式增加彈性

    // 好:允許 ID 是任何正整數
    id: $(producer(regex(positiveInt())), consumer(1))
    
    // 壞:硬編碼 ID
    id: 1
  3. 只驗證 Consumer 真正需要的欄位

    • 不要過度約束 Producer 的回應格式
  4. 涵蓋成功與失敗場景

    • 別忘了 404、400、500 等錯誤情境

12.3 團隊協作模式

1. Consumer 團隊定義需求(希望 API 長怎樣)
         │
         ▼
2. 與 Producer 團隊討論,達成共識
         │
         ▼
3. 撰寫合約(可以由任一方撰寫)
         │
         ▼
4. Producer 團隊實作 API,通過合約測試
         │
         ▼
5. 安裝 Stub JAR 到倉庫
         │
         ▼
6. Consumer 團隊使用 Stub 進行測試

13. 完整工作流程

步驟 1:Clone 專案

git clone <repository-url>
cd spring-cloud-contract-tutorial

步驟 2:建置 Producer 並產生 Stub

cd producer
mvn clean install

成功後會看到:

[INFO] Installing .../contract-producer-0.0.1-SNAPSHOT.jar
[INFO] Installing .../contract-producer-0.0.1-SNAPSHOT-stubs.jar

步驟 3:執行 Consumer 測試

cd ../consumer
mvn clean test

步驟 4:修改合約並觀察影響

嘗試修改 shouldReturnUserById.groovy 中的回應格式,然後重新執行 Producer 測試:

cd ../producer
mvn clean test

你會看到合約測試失敗,因為 API 回應不再符合合約。

步驟 5:修改 API 使其通過合約

修改 UserControllerUserService 使 API 回應符合新合約,然後重新執行:

mvn clean install  # 重新產生 Stub

環境需求

工具 版本
Java 17+
Maven 3.8+
Spring Boot 3.2.x
Spring Cloud 2023.0.x
Spring Cloud Contract 4.1.x

參考資源


授權

本教程以 MIT 授權條款釋出,歡迎自由使用與修改。

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors