一、背景与挑战
在传统API测试中,我们常常面临这些问题:
-
手工编写用例:每个接口要写几十个用例,耗时耗力
-
边界值覆盖不全:总有遗漏的边界情况
-
维护成本高:接口一变更,用例全重写
-
发现问题晚:很多问题到线上才暴露
直到我遇到了Schemathesis——一个基于OpenAPI规范的属性测试框架。
二、技术选型:为什么是Schemathesis?
什么是Schemathesis?
Schemathesis是一个基于属性测试(Property-based Testing)的API自动化测试工具。它能从OpenAPI规范自动生成测试用例,发现手动测试难以覆盖的边缘场景。
与传统框架的对比
| 特性 | 传统框架 | Schemathesis |
|---|---|---|
| 用例编写 | 手动编写 | 自动生成 |
| 维护成本 | 接口变更需重写 | 随OpenAPI自动更新 |
| 边界覆盖 | 有限 | 全面 |
| 发现缺陷 | 已知场景 | 未知边缘场景 |
| 学习成本 | 低 | 中 |
Schemathesis的核心价值
-
自动发现崩溃:找出哪些请求能让API崩溃
-
规范一致性验证:确保API实现与OpenAPI文档一致
-
边缘场景覆盖:自动生成边界值、异常值
-
零维护成本:测试用例随API schema自动更新
三、环境搭建与初探
3.1 安装Schemathesis
# 使用pip安装
pip install schemathesis

# 验证安装
st --help
3.2 创建OpenAPI YAML文件
原始的一个api请求是这样的:

创建一个它的OpenAPI定义,命名为test_openapi.yml:
info:
title: 内部租户初始化流程任务配置API
version: 1.0.0
servers:
- url: https://**-test.******.com
description: 测试
paths:
/test1/public/rest/api/bu/config/tenantInitFlowTask/page:
post:
summary: 分页查询租户初始化流程任务
description: 用于分页查询租户初始化流程任务配置信息
parameters:
# Header 参数
- name: oah-app-id
in: header
required: true
schema:
type: string
example: "DZ-Tr9ilnE8"
description: 应用ID
- name: oah-sign
in: header
required: true
schema:
type: string
example: "hz8zuVeF+IXYMVnPMPVGSmCelZo3WBGl6qVKeUnwT+g="
description: 签名
- name: oah-sign-type
in: header
required: true
schema:
type: string
enum: [ASK]
example: "ASK"
description: 签名类型
- name: oah-timestamp
in: header
required: true
schema:
type: string
example: "1761113092294"
description: 时间戳
- name: tenant-id
in: header
required: true
schema:
type: string
example: "TE7382974987348738"
description: 租户ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- pageNumber
- pageSize
properties:
pageNumber:
type: integer
description: 当前页码
example: 1
minimum: 1
pageSize:
type: integer
description: 每页大小
example: 20
minimum: 1
maximum: 100
types:
type: array
description: 类型列表
items:
type: integer
example: [1]
responses:
'200':
description: 成功响应
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
status_code:
type: integer
example: 200
data:
type: object
properties:
data:
type: object
properties:
total:
type: integer
rows:
type: array
items:
type: object
code:
type: string
msg:
type: string
traceId:
type: string
'400':
description: 请求参数错误
import yaml
#加载schema
with open("test_openapi.yaml", 'r', encoding='utf-8') as f:
raw_spec = yaml.safe_load(f)
schema = schemathesis.openapi.from_dict(raw_spec)
@schema.parametrize()
def test_api(case):
"""测试租户初始化流程任务分页查询接口"""
print(f"\n测试接口: {case.method} {case.path}")
print(f" Headers: {case.headers}")
print(f" Body: {case.body}")
try:
# 调用内部API
response = case.call(base_url="https://icc-oah-ingress-test.msxf.com")
print(f"响应状态码: {response.status_code}")
# 解析响应
if response.status_code == 200:
resp_json = response.json()
print(f" 响应code: {resp_json.get('code')}")
print(f" 响应msg: {resp_json.get('msg')}")
print(f" 响应data: {resp_json.get('data')}")
# 正确的断言:检查code是否为"200"
assert resp_json.get('code') == "200", f"接口调用失败: {resp_json.get('msg')}"
# 如果返回了data,可以进一步验证
data = resp_json.get('data', {})
if data:
print(f" 查询结果总数: {data.get('total')}")
print(f" 本次返回行数: {len(data.get('rows', []))}")
else:
print(f" 错误响应: {response.text[:200]}")
except Exception as e:
print(f" ❌ 测试执行错误: {e}")
raise

测试结果分析
6.1 测试用例统计
运行测试后,Schemathesis自动生成了 58个测试用例!
| 测试类型 | 数量 | 说明 |
|---|---|---|
| Header缺失组合 | 5 | 各种header缺失情况 |
| Header类型错误 | 7 | sign-type的各种错误值 |
| pageNumber边界值 | 10 | 0、负数、小数、字符串等 |
| pageSize边界值 | 12 | 0、101、小数、数组等 |
| types数组变体 | 8 | 空数组、null、对象等 |
| HTTP方法测试 | 2 | TRACE返回405 |
| 其他极端情况 | 14 | 空body、null、false等 |
| 总计 | 58 |
成功与失败
-
成功用例:1个(提供正确Header和Body的用例)
-
失败用例:57个(验证了API的参数校验机制)
这57个失败用例恰恰证明了API的健壮性——它对各种异常输入都返回了正确的错误码。
关键发现
通过这次测试,我们验证了API的:
-
必填参数校验:缺失Header时返回10000
-
参数类型校验:类型错误时返回10000
-
边界值处理:pageSize=101时返回10000
-
HTTP方法限制:非POST方法返回405
Schemathesis的核心价值
7.1 自动生成边界测试
手工测试需要编写50个用例,耗时2天;Schemathesis自动生成58个用例,只需5分钟。
7.2 零维护成本
当API接口变更时,只需要更新OpenAPI文件,测试用例自动同步更新。
7.3 发现API设计缺陷
-
发现缺失的必填参数校验
-
发现不完善的类型校验
-
发现不一致的错误码
7.4 提升测试效率
从编写用例到分析结果,效率提升至少80%。
八、经验总结
8.1 成功的关键
-
OpenAPI规范是基础——规范的质量决定了测试的质量
-
渐进式实施:从简单到复杂,逐步完善
-
及时调整断言:根据实际API响应调整验证逻辑
8.2 避坑指南
| 问题 | 解决方案 |
|---|---|
| 编码错误 | 始终指定encoding='utf-8' |
| base_url缺失 | case.call(base_url=...) |
| 网络权限 | 使用内部API地址 |
| 断言失败 | 查看实际响应,调整断言 |
写在最后
从最初的环境搭建,到最终成功跑通58个测试用例,这个过程让我深刻体会到:
-
工具只是手段,质量才是目的——Schemathesis帮助我们发现了大量边界问题
-
自动化测试不是万能——但能解放人力去做更有价值的事
-
边界测试很重要——很多线上问题都源于边界情况
-
持续改进是王道——测试框架也需要不断优化
为什么生成了58个测试用例?
Schemathesis 基于组合爆炸原理自动生成测试用例。让我详细分析这58个用例的来源:
用例生成原理
Schemathesis 会针对每个参数进行笛卡尔积组合测试:
总用例数 = Header组合数 × Body组合数 × HTTP方法数
58个用例的详细构成
| 参数类别 | 参数名 | 生成的测试值 | 组合数 |
|---|---|---|---|
| Header参数 | oah-app-id | ['', 'DZ-Tr9ilnE8'] | 2 |
| oah-sign | ['', 'hz8zuVeF+...'] | 2 | |
| oah-sign-type | ['ASK', '1024', '{}', 'null,null', 'null', 'false', '0'] | 7 | |
| oah-timestamp | ['', '1761113092294'] | 2 | |
| tenant-id | ['', 'TE7382974987348738'] | 2 | |
| Body参数 | pageNumber | [1, 0, 2, 0.1, {}, [], "", None, False] | 9 |
| pageSize | [20, 0, 1, 99, 100, 101, 0.1, {}, [], "", None, False] | 12 | |
| types | [[1], [0], [0.1], [{}], [[]], [""], [None], [False], {}, "", None, False, 0] | 13 | |
| HTTP方法 | method | ['POST', 'GET', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'TRACE', 'QUERY'] | 8 |
计算公式:
-
Header基础组合:2×2×7×2×2 = 112种
-
Body基础组合:9×12×13 = 1,404种
-
总组合:112 × 1,404 × 8 = 1,258,496种
但 Schemathesis 通过智能采样,只选择了最关键的58个用例。
如何控制测试用例数量?
方法1:限制每个参数的测试值
paths:
/api/endpoint:
post:
parameters:
- name: oah-sign-type
in: header
schema:
type: string
enum: [ASK] # 只保留有效值,移除错误值
example: "ASK"
方法2:使用 max_examples 限制
from hypothesis import settings
@schema.parametrize()
@settings(max_examples=20) # 每个接口最多生成20个用例
def test_api(case):
# ...
方法3:只测试正例(排除错误用例)
@schema.parametrize()
@settings(phases=[Phase.explicit]) # 只使用example中定义的用例
def test_api(case):
# ...
方法4:按需过滤
@schema.parametrize()
def test_api(case):
# 只测试POST方法
if case.method != 'POST':
pytest.skip("只测试POST方法")
# 只测试有正确Header的用例
if not case.headers.get('oah-app-id'):
pytest.skip("跳过缺失Header的用例")
方法5:在YAML中指定 example
parameters:
- name: oah-sign-type
in: header
schema:
type: string
enum: [ASK]
example: "ASK" # Schemathesis会优先使用example
OpenAPI 本身确实只定义了单个接口的规范,但 Schemathesis 完全支持你说的这种串联接口的复杂场景测试,官方称之为 Stateful Testing(状态测试)。
我来给你详细解释一下怎么实现。
什么是 Stateful Testing?
你描述的“上一个接口的输出是下一个的输入”,正是状态测试要解决的问题。Schemathesis 文档中举了一个很形象的例子 :
没有状态测试时:
-
POST /users→ 创建用户,测试通过 ✓ -
GET /users/123→ 用随机 ID,返回 404 ✗ -
DELETE /users/456→ 用随机 ID,返回 404 ✗
有了状态测试后:
-
POST /users→ 创建用户,返回 ID: 789 ✓ -
GET /users/789→ 用真实的 ID,返回 200 OK ✓ -
DELETE /users/789→ 用真实的 ID,返回 200 OK ✓
实现方式:三种途径让 Schemathesis 理解接口间的依赖关系
方式一:在 OpenAPI 中定义 Links(最推荐,最规范)
你可以在 YAML 文件中显式地定义接口间的连接关系。Schemathesis 官方文档提供了详细的示例 :
paths:
/users:
post:
operationId: createUser
responses:
'201':
description: 用户创建成功
content:
application/json:
schema:
type: object
properties:
id:
type: string
links: # 关键在这里!
GetUserById:
operationId: getUser # 告诉工具:这个接口和 getUser 有关联
parameters:
userId: '$response.body#/id' # 把本接口返回的 id 传给 getUser 的 userId 参数
/users/{userId}:
get:
operationId: getUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
有了这个定义,Schemathesis 就会自动生成这样的测试序列:
-
先调用
POST /users -
从响应里拿到
id -
再拿这个真实的
id去调用GET /users/{userId}
方式二:从 Location Header 自动学习
如果你的 API 在创建资源时返回了 Location 头,Schemathesis 会自动识别并利用这个信息 。
POST /users → 201 Created
Location: /users/123
Schemathesis 会自动学习到:可以调用 GET /users/123、PUT /users/123、DELETE /users/123。
方式三:通过代码自定义状态机(最灵活)
如果你想完全控制测试流程,可以通过继承 APIStateMachine 来实现 :
import schemathesis
schema = schemathesis.openapi.from_dict(your_openapi_spec)
# 创建自定义的状态机
class APIWorkflow(schema.as_state_machine()):
def setup(self):
"""每个测试场景开始时执行,可以在这里初始化数据"""
# 例如:先创建一个用户,保存其 ID 供后续使用
case = schema["/users"]["POST"].Case(body={"name": "test_user"})
response = case.call()
self.user_id = response.json()["id"]
def before_call(self, case):
"""每次调用 API 前执行,可以在这里修改请求"""
# 例如:给需要 user_id 的接口自动注入
if "user_id" in case.path_parameters:
case.path_parameters["user_id"] = self.user_id
# 绑定到 TestCase
TestAPI = APIWorkflow.TestCase
完整的实战例子:用户 + 订单串联测试
假设你有这样一个业务流程:
-
创建用户(
POST /users) -
为用户创建订单(
POST /users/{userId}/orders) -
查询订单详情(
GET /orders/{orderId})
你的 OpenAPI 定义可以这样写:
paths:
/users:
post:
operationId: createUser
responses:
'201':
content:
application/json:
schema:
properties:
id: { type: string }
links:
CreateOrderForUser:
operationId: createOrder
parameters:
userId: '$response.body#/id'
/users/{userId}/orders:
post:
operationId: createOrder
parameters:
- name: userId
in: path
required: true
responses:
'201':
content:
application/json:
schema:
properties:
orderId: { type: string }
links:
GetOrder:
operationId: getOrder
parameters:
orderId: '$response.body#/orderId'
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
Schemathesis 会生成这样的测试场景 :
-
创建用户 → 为该用户创建订单 → 查询该订单
-
创建用户 → 创建多个订单 → 查询所有订单
-
... 各种随机组合
总结
| 你的需求 | Schemathesis 的解决方案 |
|---|---|
| 单接口测试 | @schema.parametrize() |
| 多接口串联 | Stateful Testing + OpenAPI Links |
| 复杂业务流 | 自定义状态机继承 |
你之前跑出来的 58 个测试用例只是单接口的参数组合测试。要测多接口串联,需要在 OpenAPI 里加上 links 定义,然后用 schema.as_state_machine() 来执行状态测试。文档里提到,接口之间的连接越多,测试能覆盖的场景就越深 。
你只需要写好 OpenAPI YAML 文件,测试代码可以完全不用动。
为什么可以不动代码?
因为你的测试代码是通用的:
@schema.parametrize()
def test_api(case):
response = case.call(base_url="你的域名")
assert resp_json.get('code') == "200"
@schema.parametrize() 会自动识别 YAML 文件里的所有接口和所有依赖关系(links),然后生成相应的测试用例。
举个例子:从单接口到多接口串联
你现在的 YAML(单接口)
paths:
/tenantInitFlowTask/page:
post:
# ... 只有一个接口
你想测的多接口串联 YAML
paths:
# 接口1:创建租户
/tenant/create:
post:
operationId: createTenant
responses:
'200':
content:
application/json:
schema:
properties:
tenantId: { type: string }
links:
InitTenantFlow:
operationId: initTenantFlow
parameters:
tenantId: '$response.body#/tenantId'
# 接口2:初始化租户流程(依赖 tenantId)
/tenant/{tenantId}/flow/init:
post:
operationId: initTenantFlow
parameters:
- name: tenantId
in: path
required: true
responses:
'200':
links:
QueryTaskPage:
operationId: queryTaskPage
parameters:
tenantId: '$request.path#/tenantId'
# 接口3:分页查询(你现在测的接口)
/tenant/{tenantId}/flow/task/page:
post:
operationId: queryTaskPage
parameters:
- name: tenantId
in: path
required: true
你的测试代码还是这一行,不用改
@schema.parametrize()
def test_api(case):
response = case.call(base_url="你的域名")
assert resp_json.get('code') == "200"
Schemathesis 会自动:
-
先测接口1:创建租户,拿到 tenantId
-
再测接口2:用这个 tenantId 初始化流程
-
最后测接口3:用同一个 tenantId 分页查询
那么难题就变成怎么写好这个yml了。
测试框架是通用的,真正的难点和关键在于写好 OpenAPI YAML 文件。这就像写代码一样,YAML 就是你的“测试脚本”
为什么写好 YAML 是核心难点?
因为 YAML 文件要同时做到三件事:
-
准确描述接口:请求参数、响应结构、必填字段、类型约束
-
定义业务规则:哪些字段互斥、哪些字段依赖、值的范围
-
表达接口关联:通过
links定义接口间的依赖关系
写好 YAML 的关键要点
1. 参数定义要精确
parameters:
- name: tenantId
in: path
required: true
schema:
type: string
pattern: '^TE\d{16}$' # 正则表达式:TE + 16位数字
minLength: 18
maxLength: 18
example: "TE7382974987348738"
2. 响应结构要完整
responses:
'200':
content:
application/json:
schema:
type: object
properties:
code:
type: string
enum: ['200', '10000', '10001'] # 所有可能的返回码
msg:
type: string
data:
type: object
properties:
tenantId:
type: string
total:
type: integer
rows:
type: array
required:
- code
- msg
3. Links 定义要准确(这是串联的关键)
links:
QueryTenantTasks:
operationId: queryTenantTasks # 关联的接口
parameters:
tenantId: '$response.body#/data/tenantId' # 从响应体中提取
description: "创建租户后,查询该租户的任务"
实战:从你现在的 YAML 扩展到多接口
当前 YAML(你已经有的)
paths:
/test1/public/rest/api/bu/config/tenantInitFlowTask/page:
post:
operationId: queryTaskPage # 给这个接口起个名字
# ... 你的参数定义
扩展成多接口 YAML
paths:
# 接口1:创建租户
/test1/public/rest/api/bu/tenant/create:
post:
operationId: createTenant
parameters:
- name: tenant-name
in: header
required: true
schema:
type: string
- name: oah-app-id # 你已有的认证头
in: header
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
type: object
properties:
tenantType:
type: integer
enum: [1, 2, 3]
responses:
'200':
content:
application/json:
schema:
type: object
properties:
code:
type: string
data:
type: object
properties:
tenantId:
type: string
pattern: '^TE\d{16}$'
links:
InitTenantFlow: # 关联到接口2
operationId: initTenantFlow
parameters:
tenantId: '$response.body#/data/tenantId'
# 接口2:初始化租户流程
/test1/public/rest/api/bu/tenant/{tenantId}/flow/init:
post:
operationId: initTenantFlow
parameters:
- name: tenantId
in: path
required: true
schema:
type: string
pattern: '^TE\d{16}$'
- name: oah-app-id # 复用认证头
in: header
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
type: object
properties:
code:
type: string
data:
type: object
properties:
flowId:
type: string
links:
QueryTaskPage: # 关联到你已有的接口
operationId: queryTaskPage
parameters:
tenantId: '$request.path#/tenantId' # 从请求路径中提取
flowId: '$response.body#/data/flowId'
# 接口3:你现有的分页查询接口
/test1/public/rest/api/bu/config/tenantInitFlowTask/page:
post:
operationId: queryTaskPage
parameters:
- name: tenantId # 新增路径参数
in: path
required: true
schema:
type: string
- name: flowId
in: query
schema:
type: string
# ... 你原来的 header 参数
requestBody:
# ... 你原来的 body
responses:
'200':
# ... 你原来的响应
写好 YAML 的实战技巧
技巧1:先写 operationId
operationId: createTenant # 给每个接口起个唯一的名字,links 要用
技巧2:善用 $ref 复用定义
components:
schemas:
TenantId:
type: string
pattern: '^TE\d{16}$'
ApiResponse:
type: object
properties:
code:
type: string
msg:
type: string
parameters:
OahAppId:
name: oah-app-id
in: header
required: true
schema:
type: string
paths:
/tenant/create:
post:
parameters:
- $ref: '#/components/parameters/OahAppId' # 复用
responses:
'200':
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse' # 复用
- type: object
properties:
data:
type: object
properties:
tenantId:
$ref: '#/components/schemas/TenantId' # 复用
技巧3:用工具验证 YAML
# 安装 openapi 验证工具
npm install -g swagger-cli
# 验证你的 YAML
swagger-cli validate internal_api.yaml
学习资源推荐
-
官方文档:OpenAPI Specification(必看)
-
在线编辑器:https://editor.swagger.io/(实时验证语法)
-
示例仓库:OpenAPI Examples
-
已经跑通 58 个用例,说明你对当前接口的 YAML 写得很好
浙公网安备 33010602011771号