一、背景与挑战

在传统API测试中,我们常常面临这些问题:

  • 手工编写用例:每个接口要写几十个用例,耗时耗力

  • 边界值覆盖不全:总有遗漏的边界情况

  • 维护成本高:接口一变更,用例全重写

  • 发现问题晚:很多问题到线上才暴露

直到我遇到了Schemathesis——一个基于OpenAPI规范的属性测试框架。

二、技术选型:为什么是Schemathesis?

什么是Schemathesis?

Schemathesis是一个基于属性测试(Property-based Testing)的API自动化测试工具。它能从OpenAPI规范自动生成测试用例,发现手动测试难以覆盖的边缘场景。

与传统框架的对比

 
特性传统框架Schemathesis
用例编写 手动编写 自动生成
维护成本 接口变更需重写 随OpenAPI自动更新
边界覆盖 有限 全面
发现缺陷 已知场景 未知边缘场景
学习成本

Schemathesis的核心价值

  1. 自动发现崩溃:找出哪些请求能让API崩溃

  2. 规范一致性验证:确保API实现与OpenAPI文档一致

  3. 边缘场景覆盖:自动生成边界值、异常值

  4. 零维护成本:测试用例随API schema自动更新

三、环境搭建与初探

3.1 安装Schemathesis

bash
# 使用pip安装
pip install schemathesis

image

# 验证安装
st --help

3.2 创建OpenAPI YAML文件

原始的一个api请求是这样的:

image

 

创建一个它的OpenAPI定义,命名为test_openapi.yml

openapi: 3.0.0
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: 请求参数错误
 
 创建test_api.py:
import schemathesis
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
 
之后执行:

image

 

测试结果分析

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的:

  1. 必填参数校验:缺失Header时返回10000

  2. 参数类型校验:类型错误时返回10000

  3. 边界值处理:pageSize=101时返回10000

  4. HTTP方法限制:非POST方法返回405

 

Schemathesis的核心价值

7.1 自动生成边界测试

手工测试需要编写50个用例,耗时2天;Schemathesis自动生成58个用例,只需5分钟。

7.2 零维护成本

当API接口变更时,只需要更新OpenAPI文件,测试用例自动同步更新。

7.3 发现API设计缺陷

  • 发现缺失的必填参数校验

  • 发现不完善的类型校验

  • 发现不一致的错误码

7.4 提升测试效率

从编写用例到分析结果,效率提升至少80%。

八、经验总结

8.1 成功的关键

  1. OpenAPI规范是基础——规范的质量决定了测试的质量

  2. 渐进式实施:从简单到复杂,逐步完善

  3. 及时调整断言:根据实际API响应调整验证逻辑

8.2 避坑指南

问题解决方案
编码错误 始终指定encoding='utf-8'
base_url缺失 case.call(base_url=...)
网络权限 使用内部API地址
断言失败 查看实际响应,调整断言

 

写在最后

从最初的环境搭建,到最终成功跑通58个测试用例,这个过程让我深刻体会到:

  1. 工具只是手段,质量才是目的——Schemathesis帮助我们发现了大量边界问题

  2. 自动化测试不是万能——但能解放人力去做更有价值的事

  3. 边界测试很重要——很多线上问题都源于边界情况

  4. 持续改进是王道——测试框架也需要不断优化

 

为什么生成了58个测试用例?

Schemathesis 基于组合爆炸原理自动生成测试用例。让我详细分析这58个用例的来源:

用例生成原理

Schemathesis 会针对每个参数进行笛卡尔积组合测试:

text
总用例数 = 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 限制

python
from hypothesis import settings

@schema.parametrize()
@settings(max_examples=20)  # 每个接口最多生成20个用例
def test_api(case):
    # ...

方法3:只测试正例(排除错误用例)

python
@schema.parametrize()
@settings(phases=[Phase.explicit])  # 只使用example中定义的用例
def test_api(case):
    # ...

方法4:按需过滤

python
@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

yaml
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 官方文档提供了详细的示例 :

yaml
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 就会自动生成这样的测试序列:

  1. 先调用 POST /users

  2. 从响应里拿到 id

  3. 再拿这个真实的 id 去调用 GET /users/{userId}

方式二:从 Location Header 自动学习

如果你的 API 在创建资源时返回了 Location 头,Schemathesis 会自动识别并利用这个信息 。

http
POST /users → 201 Created
Location: /users/123

Schemathesis 会自动学习到:可以调用 GET /users/123PUT /users/123DELETE /users/123

方式三:通过代码自定义状态机(最灵活)

如果你想完全控制测试流程,可以通过继承 APIStateMachine 来实现 :

python
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

完整的实战例子:用户 + 订单串联测试

假设你有这样一个业务流程:

  1. 创建用户(POST /users

  2. 为用户创建订单(POST /users/{userId}/orders

  3. 查询订单详情(GET /orders/{orderId}

你的 OpenAPI 定义可以这样写:

yaml
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 文件,测试代码可以完全不用动。

为什么可以不动代码?

因为你的测试代码是通用的:

python
@schema.parametrize()
def test_api(case):
    response = case.call(base_url="你的域名")
    assert resp_json.get('code') == "200"

@schema.parametrize() 会自动识别 YAML 文件里的所有接口和所有依赖关系(links),然后生成相应的测试用例。

举个例子:从单接口到多接口串联

你现在的 YAML(单接口)

yaml
paths:
  /tenantInitFlowTask/page:
    post:
      # ... 只有一个接口

你想测的多接口串联 YAML

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

你的测试代码还是这一行,不用改

python
@schema.parametrize()
def test_api(case):
    response = case.call(base_url="你的域名")
    assert resp_json.get('code') == "200"

Schemathesis 会自动:

  1. 先测接口1:创建租户,拿到 tenantId

  2. 再测接口2:用这个 tenantId 初始化流程

  3. 最后测接口3:用同一个 tenantId 分页查询

 

那么难题就变成怎么写好这个yml了。

测试框架是通用的,真正的难点和关键在于写好 OpenAPI YAML 文件。这就像写代码一样,YAML 就是你的“测试脚本”

 

为什么写好 YAML 是核心难点?

因为 YAML 文件要同时做到三件事:

  1. 准确描述接口:请求参数、响应结构、必填字段、类型约束

  2. 定义业务规则:哪些字段互斥、哪些字段依赖、值的范围

  3. 表达接口关联:通过 links 定义接口间的依赖关系

写好 YAML 的关键要点

1. 参数定义要精确

yaml
parameters:
  - name: tenantId
    in: path
    required: true
    schema:
      type: string
      pattern: '^TE\d{16}$'  # 正则表达式:TE + 16位数字
      minLength: 18
      maxLength: 18
    example: "TE7382974987348738"

2. 响应结构要完整

yaml
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 定义要准确(这是串联的关键)

yaml
links:
  QueryTenantTasks:
    operationId: queryTenantTasks  # 关联的接口
    parameters:
      tenantId: '$response.body#/data/tenantId'  # 从响应体中提取
    description: "创建租户后,查询该租户的任务"

实战:从你现在的 YAML 扩展到多接口

当前 YAML(你已经有的)

yaml
paths:
  /test1/public/rest/api/bu/config/tenantInitFlowTask/page:
    post:
      operationId: queryTaskPage  # 给这个接口起个名字
      # ... 你的参数定义

扩展成多接口 YAML

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

yaml
operationId: createTenant  # 给每个接口起个唯一的名字,links 要用

技巧2:善用 $ref 复用定义

yaml
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

bash
# 安装 openapi 验证工具
npm install -g swagger-cli

# 验证你的 YAML
swagger-cli validate internal_api.yaml

学习资源推荐

  1. 官方文档:OpenAPI Specification(必看)

  2. 在线编辑器:https://editor.swagger.io/(实时验证语法)

  3. 示例仓库:OpenAPI Examples

  4. 已经跑通 58 个用例,说明你对当前接口的 YAML 写得很好

 
 
posted on 2026-03-11 14:19  小海海宁宁  阅读(68)  评论(0)    收藏  举报