【测试体系】契约测试:Spring Cloud Contract、Pact
·
以下是针对**微服务契约测试(Contract Testing)**的工业化实践指南,涵盖 **Spring Cloud Contract(生产者驱动)**与 **Pact(消费者驱动)**的双模式治理。
一、契约测试核心定位
1.1 解决集成测试的脆弱性
传统集成测试痛点:
// 消费者测试(耦合真实提供者)
@Test
void shouldGetUser() {
// 依赖提供者实际启动,数据状态不确定
User user = restTemplate.getForObject("http://provider:8080/users/1", User.class);
assertThat(user.getName()).isEqualTo("张三"); // 可能因数据变更失败
}
契约测试本质:验证请求/响应契约(格式、字段、状态码),而非业务逻辑。
优势:
- 并行开发:消费者使用 Stub 开发,无需等待提供者完成
- 破坏性变更防护:CI 拦截契约破坏(Breaking Changes)
- 替代 E2E:轻量级验证接口兼容性,减少重型集成测试
二、Spring Cloud Contract(生产者驱动)
2.1 工作流程(Producer Driven)
生产者团队 契约仓库 消费者团队
│ │ │
├─编写Groovy/YAML契约───────►│ │
├─生成单元测试(自验证) │ │
├─生成WireMock Stub JAR─────►│◄───────────────────────────┤
│ │ 消费者使用Stub开发
│ │ @AutoConfigureStubRunner
2.2 契约定义(Groovy DSL)
src/test/resources/contracts/shouldReturnUser.groovy:
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "should return user by id"
request {
method GET()
urlPath('/users/1')
headers {
contentType(applicationJson())
}
}
response {
status 200
headers {
contentType(applicationJson())
}
body([
id: 1,
name: '张三',
email: $(regex('.+@.+\\..+')), // 正则匹配邮箱格式
age: $(consumer(25), producer(regex(number()))) // 消费者期望25,生产者验证是数字
])
}
}
2.3 生产者端配置(自动测试生成)
Maven 插件:
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<baseClassForTests>com.example.BaseContractTest</baseClassForTests>
<testFramework>JUNIT5</testFramework>
</configuration>
</plugin>
基础测试类(设置测试上下文):
@SpringBootTest
@AutoConfigureMockMvc
public abstract class BaseContractTest {
@Autowired
private MockMvc mockMvc;
@BeforeEach
void setup() {
RestAssuredMockMvc.mockMvc(mockMvc);
}
// 契约测试自动继承此类,验证Controller返回是否符合契约
}
生成产物:
target/generated-test-sources/contracts/:自动生成的 JUnit 测试target/*.jar:包含 WireMock 映射的 Stub JAR
2.4 消费者端使用(Stub Runner)
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:user-service:+:8080", // groupId:artifactId:version:port
stubsMode = StubRunnerProperties.StubsMode.LOCAL // 或 REMOTE(从Nexus下载)
)
class UserConsumerTest {
@Autowired
private UserClient userClient; // Feign/RestTemplate客户端
@Test
void shouldGetUserViaContract() {
// 自动路由到 WireMock Stub(端口8080)
User user = userClient.getUser(1L);
assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getName()).isEqualTo("张三"); // 与契约一致
}
}
三、Pact(消费者驱动)
3.1 工作流程(Consumer Driven)
消费者团队 Pact Broker 提供者团队
│ │ │
├─编写Consumer测试(定义期望)───────────┼──────────────────────────────────┤
├─生成Pact契约文件(JSON)──────────────►│ │
│ │ │
│◄───────────────────────────────────────┤◄─提供者拉取契约验证──────────────┤
│ 契约发布到Broker │ 打破契约时构建失败
3.2 消费者端测试(JUnit 5)
@ExtendWith(PactConsumerTestExt.class) // JUnit 5扩展
@PactTestFor(providerName = "user-service", port = "8080")
class UserConsumerPactTest {
@Pact(consumer = "order-service") // 当前服务作为消费者
public RequestResponsePact getUserPact(PactDslWithProvider builder) {
return builder
.given("user exists") // 提供者状态(Provider State)
.uponReceiving("get user with id 1")
.method("GET")
.path("/users/1")
.headers("Accept", "application/json")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.integerType("id", 1)
.stringType("name", "张三")
.stringMatcher("email", ".+@.+\\..+", "zhangsan@example.com")
.integerType("age", 25)
)
.toPact();
}
@Test
@PactTestFor(pactMethod = "getUserPact")
void shouldGetUser(MockServer mockServer) {
// 使用Pact提供的Mock Server(真实HTTP,非WireMock)
UserClient client = new UserClient(mockServer.getUrl());
User user = client.getUser(1L);
assertThat(user.getName()).isEqualTo("张三");
}
}
生成契约文件:target/pacts/order-service-user-service.json
3.3 提供者端验证(Provider Verification)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("user-service") // 契约中的提供者名称
@PactBroker(
url = "https://pact-broker.company.com",
authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
class UserProviderPactTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
// 实现Provider State(契约中的"given"条件)
@State("user exists")
void userExistsState() {
// 准备测试数据:插入id=1的用户到DB
userRepository.save(new User(1L, "张三", "zhangsan@example.com", 25));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction(); // 自动验证所有契约
}
}
3.4 Pact Broker(契约管理中心)
Docker 部署:
version: '3'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
POSTGRES_DB: pact
broker:
image: pactfoundation/pact-broker:latest
ports:
- "80:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres/pact
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: admin
CI/CD 集成(Webhook 触发):
# 消费者发布契约
mvn pact:publish -Dpact.consumer.version=$(git rev-parse --short HEAD)
# 提供者验证契约(失败则阻断部署)
mvn pact:verify
Can I Deploy(部署安全门):
# 检查是否可以部署到生产(验证所有消费者契约是否满足)
pact-broker can-i-deploy \
--pacticipant order-service \
--version $(git rev-parse --short HEAD) \
--to-environment production
四、SCC vs Pact 选型对比
| 维度 | Spring Cloud Contract | Pact |
|---|---|---|
| 驱动模式 | 生产者驱动(Producer Driven) | 消费者驱动(Consumer Driven) |
| 技术栈 | Spring 生态深度绑定 | 多语言(Java/JS/Python/Go) |
| 契约存储 | Git 仓库(与代码同版本) | Pact Broker(中心化服务) |
| 契约格式 | Groovy/YAML(DSL) | JSON(标准化格式) |
| Mock 技术 | WireMock(HTTP Server) | Pact Mock Server(原生实现) |
| 双向验证 | 提供者生成测试,消费者用 Stub | 消费者定义契约,提供者主动拉取验证 |
| 环境管理 | 适合 Spring Cloud 微服务 | 适合异构系统/多端应用(Web/App) |
| 版本策略 | 基于 Maven/Gradle 版本 | 基于 Git Commit SHA 或语义化版本 |
决策树:
- 纯 Spring Cloud 体系 → SCC(无缝集成,Stub 复用)
- 多语言混合/移动端 → Pact(消费者驱动更合理)
- 需要可视化契约管理 → Pact + Broker(SCC 无类似 Broker 的 UI)
- API 由后端主导设计 → SCC(生产者控制契约)
- API 由前端/消费者主导 → Pact(消费者驱动设计)
五、生产级实践与治理
5.1 契约版本控制策略
SCC 版本管理(Git Flow):
<!-- 生产者发布 Stub 到 Maven 仓库 -->
<artifactId>user-service</artifactId>
<version>1.2.0-SNAPSHOT</version>
<classifier>stubs</classifier>
<!-- 消费者指定版本范围 -->
@AutoConfigureStubRunner(ids = "com.example:user-service:1.2.0-SNAPSHOT:8080")
Pact 版本管理(Git Commit SHA):
# 消费者发布时带 Git SHA 标签
pact-broker publish pacts/ \
--consumer-app-version $(git rev-parse --short HEAD) \
--tag $(git rev-parse --abbrev-ref HEAD) # 分支名作为标签
5.2 处理 Breaking Changes
流程(以 Pact 为例):
- 消费者更新期望(如新增必填字段
phone) - CI 失败:提供者验证不通过(缺少
phone字段) - 协商:确认是 Breaking Change 还是向后兼容
- 实施:
- 若 Breaking:提供者先上线,消费者后上线
- 若兼容:提供者忽略未知字段(
@JsonIgnoreProperties(ignoreUnknown = true))
SCC 的 Breaking Change 防护:
// 契约中标记向后兼容
Contract.make {
priority(1) // 高优先级契约先匹配
// ...
}
5.3 性能与隔离
避免真实数据库(契约测试应轻量):
// SCC:使用 @WebMvcTest 而非 @SpringBootTest(更快)
@WebMvcTest(UserController.class)
@AutoConfigureRestDocs
class UserContractTest extends BaseContractTest {
@MockBean private UserService service;
@BeforeEach
void setup() {
when(service.findById(1L)).thenReturn(new User(1L, "张三"));
}
}
Pact 并行验证:
@PactTestFor(providerName = "user-service", pactMethod = "getUserPact",
hostInterface = "localhost", port = "0") // 随机端口避免冲突
六、生产级 Checklist
□ 契约范围:是否仅验证接口契约(字段/状态码),不涉及业务逻辑计算
□ 数据独立性:契约测试是否使用固定测试数据(避免生产数据污染)
□ 版本对齐:SCC的Stub版本是否与提供者代码版本严格对应(Maven依赖)
□ Broker治理:Pact Broker是否配置Webhook,在契约变更时自动触发提供者构建
□ 状态管理:Pact的@State方法是否清理数据(避免测试间污染)
□ 向后兼容:新增字段是否标记为optional(避免强制升级消费者)
□ 性能:契约测试是否在秒级完成(避免重量级Spring上下文启动)
□ CI集成:是否将pact:verify或契约测试作为部署门禁(失败阻断发布)
□ 契约过期:是否定期清理Broker中无消费者引用的旧契约(保留策略)
□ 文档化:契约是否生成文档(SCC生成Asciidoc,Pact生成API文档)
核心认知:契约测试是微服务接口的宪法,SCC 像立法机构(生产者定义规则),Pact 像公投机制(消费者提出诉求)。在生产环境中,两者可混合使用:内部 Spring 服务用 SCC 保证快速反馈,对外暴露的 API 用 Pact 管理多消费者兼容性。
更多推荐



所有评论(0)