本教程带你从零构建一个名为 api-contract-test 的 Claude Code Skill,它读取任意 OpenAPI/Swagger 规范文件,自动为每个端点生成正向、边界、负向三类契约测试用例,并产出可直接比对实现的测试报告。学完你将掌握 SKILL.md 的 YAML frontmatter 规范、渐进式披露的三阶段加载机制、脚本与指令的协作编排,以及如何用脚本确定性运算替代模型 token 生成。适用人群:有 REST API 开发经验、希望用 AI 把枯燥测试编写自动化的后端工程师与 SDET。
openapi.yaml/openapi.json)的工程目录pyyaml,Claude Code 沙箱可按需 pip install测试 API 最让人头疼的不是"会不会写",而是"写不完"。一个中等规模的服务动辄三五十个端点,每个端点都要覆盖正常路径、必填校验、类型校验、越界、未授权、资源不存在——手动写一遍要几天,而规范一旦微调,测试又得跟着改。更隐蔽的是契约漂移:实现偷偷返回了规范里没声明的状态码,或者悄悄改了字段名,测试还全绿,线上却在裸奔。
OpenAPI 规范本身就是一份机器可读的契约:它声明了每个端点接受什么参数、返回什么状态码、响应体长什么样。既然契约已经写在文件里,用例就不该手写——它们应该从规范里"长"出来。这正是 Claude Code Skill 的理想场景:把"解析规范→枚举端点→按策略派生用例→比对实现"这套确定性流程固化成可复用的能力,模型只在需要语义判断时介入。
把它做成 Skill 而非一次性脚本,关键收益有三:
.claude/skills/ 提交到 Git,全组共享同一套测试口径。Skill 的核心设计原则是三阶段加载。会话启动时,Claude 只读取每个 Skill 的 name 和 description 两个字段(约 30–50 token),据此判断要不要激活;激活后才加载 SKILL.md 正文;正文里引用的脚本和参考文件,则在真正执行时才被读取——而脚本的代码本身从不进入上下文,只有它的输出消耗 token。这意味着你可以塞进一个上千行的解析器,却只为最终的 JSON 结果买单。
按这个原则,我们的 Skill 目录结构如下:
.claude/skills/api-contract-test/
├── SKILL.md # 核心指令(必需)
├── scripts/
│ ├── parse_spec.py # 解析 OpenAPI,提取端点契约
│ ├── gen_cases.py # 按策略生成测试用例
│ └── run_contract.py # 对真实服务发起请求并断言
├── references/
│ ├── case_strategies.md # 用例设计策略(按需加载)
│ └── assertion_rules.md # 状态码与 schema 断言规则
└── templates/
└── test_report.md # 报告骨架
SKILL.md 始终加载,保持精简(建议 500 行内);scripts/ 的代码确定性执行、不入上下文;references/ 只在模型需要决策(比如"这个端点该怎么设计负向用例")时才被读进来。
frontmatter 是 Skill 的"身份证",也是模型决定是否激活的唯一依据。两个字段必填、校验严格:name 限 64 字符、只能用小写字母/数字/连字符,且不能含 anthropic、claude 等保留词或 XML 标签;description 限 1024 字符,必须同时说明"做什么"和"何时用"——这是 Skill 能否被可靠触发的命脉。模糊描述如"分析接口"几乎不会激活;有效描述要塞入用户真实会说的触发词。
新建文件 .claude/skills/api-contract-test/SKILL.md:
---
name: api-contract-test
description: |
Generate contract test cases from an OpenAPI/Swagger specification.
Reads openapi.yaml/openapi.json, enumerates every endpoint, and
produces positive, boundary, and negative test cases with expected
status codes and response schemas. Use when the user asks to test an
API, generate API test cases, write contract tests, validate endpoints
against an OpenAPI/swagger spec, or detect contract drift between the
implementation and its spec. Also use when onboarded to an unfamiliar
API and wanting a quick contract-level smoke test.
allowed-tools:
- Read
- Write
- Edit
- Bash
- Glob
- Grep
---
# API 契约测试生成器
(正文见下一节)
allowed-tools 是可选的工具白名单:写明它能让权限提示更少、行为更可控。注意它约束的是模型可用工具集的提示,并非硬性沙箱——真正收紧执行仍需配合权限模式(如 permissions 里的 deny 规则)。如果 Skill 需要默认全部工具,直接省略该字段。
常见问题:装好了却不触发?九成是 description 没塞够触发词。把"contract test""契约测试""OpenAPI""端点校验"这些用户高频词都写进去,比纠结指令文笔重要得多。
正文是给模型的工作手册。好的正文是声明式、分步骤、带边界的——告诉模型在什么条件下做什么、用什么脚本、产出什么格式,而不是泛泛而谈"请测试这个 API"。续写 SKILL.md:
# API 契约测试生成器
从 OpenAPI/Swagger 规范自动生成契约测试用例,覆盖 positive / boundary / negative 三类。
## 工作流程(严格按序执行)
1. **定位规范文件**:用 Glob 搜索 `**/openapi.{yaml,yml,json}` 与 `**/swagger.{yaml,yml,json}`。
若多个命中或零命中,向用户确认路径,不要猜测。
2. **解析规范**:
`python3 scripts/parse_spec.py <spec-path> > endpoints.json`
得到所有端点的契约清单。若报 `ModuleNotFoundError: yaml`,先 `pip install pyyaml`。
3. **生成用例**:
`python3 scripts/gen_cases.py endpoints.json > test_cases.json`
得到结构化用例列表。
4. **可选——对真实服务跑契约**:仅当用户给出可访问的 base URL 时执行
`python3 scripts/run_contract.py test_cases.json <base-url> > results.json`。
对 POST/PUT/DELETE 等写操作,执行前必须向用户确认,默认只生成不执行。
5. **产出报告**:按 `templates/test_report.md` 渲染,按端点分组,
标注 pass / fail / skip,并对 fail 用例标注实际状态码与预期的差异(契约漂移)。
## 用例设计原则(详见 references/case_strategies.md)
- **positive**:规范声明的每个 2xx 响应,至少一条用例
- **boundary**:每个 required 参数缺失一次 → 预期 400;类型不符、越界值
- **negative**:无认证 → 401;无权限 → 403;不存在资源 → 404
## 输出约定
每条用例必须含字段:`endpoint`、`category`、`name`、`expected_status`。
报告必须按 endpoint 分组,并在末尾给出统计:通过数 / 失败数 / 跳过数 / 漂移数。
## 边界(不要做的事)
- 不修改任何业务代码,只产出测试文件与报告
- 不主动对生产环境发起写请求
- 规范含跨文件 `$ref` 时,提示用户先合并为单文件再运行
注意正文刻意把"何时调用哪个脚本、产出什么中间文件"写死——这把模型从"自由发挥"拉回"确定性流水线",避免它每次用不同方式拆解规范导致结果不可复现。
确定性的事交给脚本,不交给 token。这个脚本提取每个端点的方法、路径、参数、请求体、声明的响应码,并解析 $ref 引用:
#!/usr/bin/env python3
"""解析 OpenAPI 规范,提取可测试的端点契约。"""
import json, sys, yaml
def load_spec(path):
with open(path, encoding='utf-8') as f:
return yaml.safe_load(f) if path.endswith(('.yaml', '.yml')) else json.load(f)
def resolve_ref(spec, ref):
"""解析形如 #/components/schemas/Pet 的 $ref。"""
if not ref or not ref.startswith('#/'):
return None
node = spec
for part in ref.lstrip('#/').split('/'):
node = node.get(part, {}) if isinstance(node, dict) else None
if node is None:
return None
return node
def extract_endpoints(spec):
endpoints = []
for path, item in spec.get('paths', {}).items():
path_params = item.get('parameters', [])
for method, op in item.items():
if method.lower() not in ('get', 'post', 'put', 'patch', 'delete'):
continue
params = list(path_params) + op.get('parameters', [])
endpoints.append({
'id': f"{method.upper()} {path}",
'method': method.upper(),
'path': path,
'operationId': op.get('operationId', f"{method}_{path}"),
'summary': op.get('summary', ''),
'tags': op.get('tags', []),
'parameters': params,
'requestBody': op.get('requestBody'),
'responses': list(op.get('responses', {}).keys()),
})
return endpoints
if __name__ == '__main__':
spec = load_spec(sys.argv[1])
print(json.dumps({
'title': spec.get('info', {}).get('title', ''),
'version': spec.get('info', {}).get('version', ''),
'endpoint_count': len(extract_endpoints(spec)),
'endpoints': extract_endpoints(spec),
}, ensure_ascii=False, indent=2))
预期输出(针对一个含 /pets 的 Petstore 规范):
{
"title": "Petstore",
"version": "1.0.0",
"endpoint_count": 2,
"endpoints": [
{ "id": "GET /pets", "method": "GET", "responses": ["200", "400"] },
{ "id": "POST /pets", "method": "POST", "responses": ["201", "405"] }
]
}
常见问题:$ref 解析返回 None?多半是引用跨了文件(形如 external.yaml#/...),脚本只处理文件内引用。按 SKILL.md 的边界约定,提示用户先把多文件合并成单文件。
这是 Skill 的"大脑",把端点契约翻译成具体用例。核心策略:声明成功的响应码各派一条正向用例;每个 required 参数缺失派一条边界用例(预期 400);若规范声明了 401,派一条未授权负向用例。
#!/usr/bin/env python3
"""按 positive/boundary/negative 策略生成契约测试用例。"""
import json, sys
def gen_for_endpoint(ep):
cases = []
# positive:每个 2xx 响应一条
for code in ep['responses']:
if str(code).startswith('2'):
cases.append({'endpoint': ep['id'], 'category': 'positive',
'name': f"{ep['id']} 正常返回 {code}",
'expected_status': int(code)})
# boundary:每个 required 参数缺失一条
for p in ep.get('parameters', []):
if p.get('required'):
cases.append({'endpoint': ep['id'], 'category': 'boundary',
'name': f"{ep['id']} 缺少必填参数 {p['name']}",
'expected_status': 400})
# negative:规范声明 401 → 无凭证;声明 404 → 资源不存在
if '401' in ep['responses']:
cases.append({'endpoint': ep['id'], 'category': 'negative',
'name': f"{ep['id']} 无认证访问",
'expected_status': 401})
if '404' in ep['responses']:
cases.append({'endpoint': ep['id'], 'category': 'negative',
'name': f"{ep['id']} 访问不存在的资源",
'expected_status': 404})
return cases
if __name__ == '__main__':
data = json.load(open(sys.argv[1]))
all_cases = []
for ep in data['endpoints']:
all_cases.extend(gen_for_endpoint(ep))
print(json.dumps({'case_count': len(all_cases), 'cases': all_cases},
ensure_ascii=False, indent=2))
预期输出(接上例):
{ "case_count": 4, "cases": [
{"endpoint":"GET /pets","category":"positive","name":"GET /pets 正常返回 200","expected_status":200},
{"endpoint":"GET /pets","category":"boundary","name":"GET /pets 缺少必填参数 limit","expected_status":400},
{"endpoint":"POST /pets","category":"positive","name":"POST /pets 正常返回 201","expected_status":201},
{"endpoint":"POST /pets","category":"negative","name":"POST /pets 无认证访问","expected_status":401}
]}
常见问题:生成的用例太少?检查规范的 required: true 是否真的标在每个参数上——很多团队写规范时偷懒不标 required,导致边界用例为零。这种"规范本身不严谨"恰是契约测试的价值所在,应在报告里反推提醒。
生成用例后,若想直接验证实现是否达标,需要一个发起请求并比对的脚本。它读 test_cases.json,对每条用例的 endpoint 发请求,比对实际状态码与 expected_status:
#!/usr/bin/env python3
"""对真实服务逐条执行契约用例并断言状态码。"""
import json, sys, urllib.request, urllib.error
def run(cases, base_url):
results = []
for c in cases['cases']:
method, path = c['endpoint'].split(' ', 1)
url = base_url.rstrip('/') + path
req = urllib.request.Request(url, method=method)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
actual = resp.getcode()
except urllib.error.HTTPError as e:
actual = e.code
except Exception as e:
results.append({**c, 'actual_status': None, 'verdict': 'skip', 'error': str(e)})
continue
verdict = 'pass' if actual == c['expected_status'] else 'fail'
results.append({**c, 'actual_status': actual, 'verdict': verdict,
'drift': actual != c['expected_status']})
return results
if __name__ == '__main__':
cases = json.load(open(sys.argv[1]))
base_url = sys.argv[2]
results = run(cases, base_url)
passed = sum(1 for r in results if r['verdict'] == 'pass')
print(json.dumps({'total': len(results), 'passed': passed,
'failed': sum(1 for r in results if r['verdict'] == 'fail'),
'skipped': sum(1 for r in results if r['verdict'] == 'skip'),
'results': results}, ensure_ascii=False, indent=2))
用标准库 urllib 而非 requests,是为了零依赖——沙箱里不必额外安装。fail 且 drift=true 的用例就是契约漂移点,是报告里最该被高亮的部分。
references/case_strategies.md 写策略详解(何时派边界、负向用例如何选资源 ID),references/assertion_rules.md 写状态码与 schema 断言细则。它们按需加载——模型遇到"这个 PUT 端点该测哪些边界"时才读,平时不占 token。
templates/test_report.md 给出报告骨架:
# API 契约测试报告
- 规范:{{title}} v{{version}}
- 端点数:{{endpoint_count}} | 用例数:{{case_count}}
- 通过 {{passed}} / 失败 {{failed}} / 跳过 {{skipped}}
## 契约漂移(FAIL)
| 端点 | 用例 | 预期 | 实际 |
|------|------|------|------|
{{#drifts}}| {{endpoint}} | {{name}} | {{expected_status}} | {{actual_status}} |
{{/drifts}}
## 逐端点明细
(按 endpoint 分组列出每条用例与 verdict)
在工程根目录放一份 openapi.yaml(节选):
openapi: 3.0.0
info: { title: Petstore, version: "1.0.0" }
paths:
/pets:
get:
summary: 列出宠物
parameters:
- { name: limit, in: query, required: true, schema: { type: integer } }
responses:
"200": { description: OK }
"400": { description: 缺参数 }
post:
summary: 新建宠物
responses:
"201": { description: 创建成功 }
"401": { description: 未认证 }
然后在 Claude Code 里直接用自然语言驱动:
给这个项目的 API 补一套契约测试,规范在 openapi.yaml。
模型会:① Glob 命中 openapi.yaml → ② 跑 parse_spec.py 得 2 个端点 → ③ 跑 gen_cases.py 得 4 条用例 → ④ 因你未给 base URL,跳过执行 → ⑤ 按 test_report.md 产出报告,并指出"GET /pets 声明了 400 但参数 limit 标了 required,已派边界用例覆盖"。
整个过程你只说了一句话,模型自主激活了 Skill、选对了脚本、按既定流程产出。这就是 Skill 把"专家流程"封装为"一句话能力"的威力。
flowchart TD
A["用户: 给这个API补契约测试"] --> B{"Skill是否激活?"}
B -->|description命中触发词| C["加载 SKILL.md 正文"]
B -->|未命中| Z["不激活,按普通对话处理"]
C --> D["Glob 定位 openapi.yaml"]
D --> E["执行 scripts/parse_spec.py<br/>提取端点契约 (不入上下文)"]
E --> F["endpoints.json"]
F --> G["执行 scripts/gen_cases.py<br/>派生 positive/boundary/negative"]
G --> H["test_cases.json"]
H --> I{"用户提供 base URL?"}
I -->|是且确认写操作| J["执行 run_contract.py<br/>断言状态码,标漂移"]
I -->|否| K["跳过执行"]
J --> L["results.json"]
K --> L
L --> M["按 templates/test_report.md<br/>渲染分组报告"]
M --> N["输出报告 + 漂移清单"]
style B fill:#fef3c7
style E fill:#dbeafe
style G fill:#dbeafe
style J fill:#dbeafe
图中蓝色节点是脚本执行——它们承担确定性运算,代码不入上下文,只有 JSON 输出消耗 token。黄色节点是模型唯一的语义判断点(激活与否)。把重活压到脚本、把轻判断留给模型,是写好 Skill的黄金分工。
api-contract-test 是否进入索引;未进入通常是 YAML 格式错误(--- 必须恰好成对)或 name 含非法字符。references/,正文只留工作流程与边界。run_contract.py 默认不碰写操作,正是出于这个考虑。code.claude.com/docs/en/skills——frontmatter 与发现机制的权威说明gregpriday/claude-code-docs——社区维护的 Claude Code Skills 技术深扒,含三阶段加载细节skill-creator 跑 eval,量化 description 触发准确率。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
