我们团队中每一个进入生产环境的 API 破坏都通过了 CI。每一个。单元测试是绿的,集成测试套件通过了,部署发出后,Slack 消息就开始刷屏了。
问题不在于我们没做测试。问题在于我们测错了东西。大多数 CI 流水线验证的是代码能否运行,却不验证生产者与消费者之间的 API contract 是否仍然完整。
为什么你现有的测试捕获不了 API 破坏
单元测试锻炼你的内部逻辑。它们用 mock 依赖调用 handler,并对响应做断言。集成测试启动整个技术栈,然后命中真实端点。
这两种测试都是在测生产者。它们验证的是你的 API 在被你当前调用方式调用时表现正确。它们不测试基于上周的 contract 构建的外部消费者是否还能与你正常通信。
最隐蔽的破坏才是最危险的。你把 user_id 重命名为 userId,因为 linter 在抱怨。你把 200 响应从返回平面对象改成了嵌套对象。你把一个原本是可选的查询参数变成了必填。你自己的测试在同一个拉取请求里被更新了,所以全部通过。但生产环境中的每个客户端都崩溃了。
这就是测代码和测 contract 的区别。
什么算破坏性的 API 变更
破坏性变更是指任何导致正确实现的客户端失败的修改。这和 bug 无关,它关乎的是你的 API 做出的承诺。
最常见的破坏分为三类:
- 结构性变更: 删除或重命名字段、改变类型、调整嵌套结构
- 行为性变更: 把可选参数变成必填、修改分页默认值、改变错误响应的形状
- 生命周期变更: 删除端点、改变 URL 路径、不通知就弃用版本
有些破坏显而易见,有些只有从消费者视角看才明显。大多数团队并不会这样做。
第一层:用 OpenAPI diffing 捕获结构性破坏
OpenAPI spec 描述了你 API 的形状。如果你把 spec 当作 contract,就可以在合并前将它与上一版本做 diff,并标记出破坏性变更。
像 oasdiff 这样的工具可以比较两份 OpenAPI 文档,并将变更分类为破坏性、危险或安全。破坏性变更比如删除响应字段,或将参数从可选改为必填。
以下是在 GitHub Actions 工作流中的用法:
# .github/workflows/api-contract.yml
name: API Contract Check
on:
pull_request:
paths:
- 'openapi.yaml'
jobs:
diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download oasdiff
run: |
curl -L https://github.com/Tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
sudo mv oasdiff /usr/local/bin/
- name: Check for breaking changes
run: |
oasdiff breaking \
--base origin/main:openapi.yaml \
--revision openapi.yaml \
--fail-on WARN
如果拉取请求引入了结构性破坏变更,构建就会失败。它快速、确定性强,能捕获集成测试遗漏的重命名和删除。
但有一个陷阱。OpenAPI diffing 只能看到 schema。它无法判断你是否在保持类型不变的情况下改变了字段的含义。一个布尔值 active 字段,如果含义从「账户已启用」变成了「邮箱已验证」,diff 是看不出来的。类型没变,contract 变了。
第二层:用 Pact 验证消费者 contracts
Consumer-driven contract testing 翻转了模型。不是由 API 生产者断言自己的正确性,而是由消费者定义它们需要什么。这些期望变成了生产者必须在 CI 中满足的 contracts。
流程是这样的。你的前端团队写了一个测试,内容是:“当我调用 GET /users/123 时,我期望返回 200,且 body 中包含字符串类型的 user_id。” Pact 会记录这次交互并存储 contract。
在生产者端,你的后端 CI 拉取所有消费者 contracts,并对当前代码重放它们。如果某个拉取请求删除了 user_id 或把它改成了数字类型,provider verification 就会失败,即使你自己的测试通过了。
用 Pact JS 写的一个最小消费者测试:
// consumer.spec.js
const { PactV3 } = require('@pact-foundation/pact');
const { expect } = require('chai');
const provider = new PactV3({
consumer: 'web-app',
provider: 'user-service',
dir: './pacts',
});
describe('GET /users/:id', () => {
it('returns the user', async () => {
await provider
.given('user exists')
.uponReceiving('a request for user 123')
.withRequest({
method: 'GET',
path: '/users/123',
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
user_id: '123',
email: 'alice@example.com',
},
});
await provider.executeTest(async (mockserver) => {
const user = await fetchUser(mockserver.url, '123');
expect(user.user_id).to.equal('123');
});
});
});
在 CI 中的 provider verification:
# .github/workflows/verify-contracts.yml
name: Verify Consumer Contracts
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify pacts
run: |
docker run --rm \
-v $(pwd)/pacts:/pacts \
pactfoundation/pact-cli \
verify-provider \
--provider-app-version ${{ github.sha }} \
--pact-broker-base-url https://your-pact-broker.io \
--provider user-service
这能捕获 schema diffing 看不到的语义破坏。如果某个消费者依赖特定的值格式或错误消息,Pact 会标记出来。
大多数团队忽略的权衡
这两种方法都不是免费的。
OpenAPI diffing 要求你维护一份准确的 spec。如果你的 spec 是从代码注解生成的,那它会自动保持最新。如果是手写的,它就会漂移,diff 也就变得毫无意义。对于这种工作流,生成的 spec 更好。
Pact 需要组织层面的纪律。消费者必须写 contract tests。你需要一个 broker 来存储和管理 contract 版本。当某个消费者的期望是错的时,必须有人出面协商变更。这会产生摩擦。但这正是它的意义所在——摩擦阻止了悄无声息的破坏。
同时运行两者是最理想的,但并不总是可行。如果你有一两个关键消费者,Pact 很快就能收回成本。如果你有几十个匿名的 API 消费者,OpenAPI diffing 是更好的起点。
还有一点:这两种工具都捕获不了性能回归或认证变更。如果一个新端点需要 OAuth,而之前的端点不需要,这也是一种破坏性变更。如果 auth schema 已经在别处定义了,你的 diff 工具可能不会标记它。对这些盲区保持警惕。
常见问题
什么是破坏性 API 变更?
破坏性 API 变更是指任何导致现有、正确实现的客户端失败的 API 修改。这包括删除字段、改变类型、将可选参数变为必填,或改变现有端点的响应状态码。
API contract testing 和集成测试有什么区别?
集成测试验证你的系统整体是否正常工作。Contract tests 验证 API 的公开 interface 是否符合消费者的期望。即使 contract 已经被破坏,集成测试也可能通过,只要系统的两端在同一个 commit 中被一起更新了。
我可以不手写 spec 就使用 OpenAPI diffing 吗?
可以。像 oasdiff 这样的工具适用于任何 OpenAPI 文档,包括通过 SpringDoc、FastAPI 或 drf-spectacular 等库从代码注解生成的文档。生成的 spec 通常更可靠,因为它们不会与实现发生漂移。
使用 consumer-driven contracts 必须要有 Pact broker 吗?
对于超过几个服务的团队来说,是的。Broker 存储 contracts、追踪版本,并显示哪些消费者会受到提议变更的影响。没有它,你就只能手动传递 contract 文件,这很快就会失控。
GraphQL 呢?
GraphQL 的破坏性变更语义不同。删除字段是破坏性的,但添加字段是安全的。像 GraphQL Inspector 这样的工具提供与 OpenAPI 工具类似的 schema diffing。Pact 也支持 GraphQL 交互。
从 schema diff 开始
先从 OpenAPI diffing 入手。它的投入最低,能捕获最常见的破坏。即使你的 spec 不完美,今天就把加到 CI 里。不完美的检查也好过没有检查。
一旦你在审查中拦截了一个本会毁掉某人周末的破坏,你就会明白这有多重要。