testoracle:用业务不变量规则做断言

testoracle

测试预言库:用业务不变量规则做断言,避免硬编码预期值。

github 地址:https://github.com/tynam-yang/testoracle

很多自动化测试脚本大量硬编码返回值(assert resp["total"] == 10),业务微小变更就造成脚本批量失效。
testoracle 基于 Test Oracle(测试预言) 理论——测试不一定需要预先知道精确输出值,只需要判定输出是否符合业务约束、不变量规则。本库校验 total >= 0id 不重复 这类业务规则,而非写死精确返回值。

  • 适用于:接口返回、UI 提取数据、JSON 报文、报表数据
  • 形态:Python 库 + pytest 插件 + CLI
  • 轻量:无 Web 界面、无数据库、不发起 HTTP 请求、只负责校验数据

安装

pip install testoracle

依赖:jsonpath-ngpyyamlpytestallure-pytest

快速开始

1. 编写规则(yaml)

直接用谓词字段声明约束;同一条规则声明多个谓词时
自动组合为复合规则(AND 语义)

rules:
  - name: "分页total不能为负数"
    path: "$.total"
    min: 0

  - name: "列表ID不能重复"
    path: "$.data[*].id"
    unique: true

  - name: "返回数据不能携带密码明文"
    path: "$.data[*].password"
    forbidden: true

  - name: "状态只能为0/1/2"
    path: "$.data[*].status"
    allowed_values: [0, 1, 2]

  # 复合规则:min + not_null 都要通过
  - name: "价格大于0,且不可为空"
    path: "$.data[*].price"
    min: 0
    not_null: true

1b. 显式 type 式(备选)

也可以用 type 字段显式指定规则类型:

rules:
  - name: "分页total不能为负数"
    type: "range"
    path: "$.total"
    min: 0

  - name: "列表id不能重复"
    type: "unique"
    path: "$.data[*].id"

  - name: "不能返回明文password字段"
    type: "forbidden_field"
    path: "$.data[*].password"

两种风格可混用,也可在同一文件里共存。谓词字段含义见下表。

2. 作为 Python 库调用

支持三种规则来源:YAML 文件JSON 文件字典内联

from testoracle import OracleEngine

data = {"total": 20, "data": [{"id": 1, "status": 0}, {"id": 2, "status": 1}]}

# 方式一:从 YAML 文件加载规则
engine = OracleEngine.from_yaml("demo_rules.yaml")

# 方式二:从 JSON 文件加载规则
engine = OracleEngine.from_json("demo_rules.json")

# 方式三:直接传入规则字典(无需文件)
engine = OracleEngine.from_rules({
    "rules": [
        {"name": "total不能为负", "path": "$.total", "min": 0},
        {"name": "id不能重复", "path": "$.data[*].id", "unique": True},
        {"name": "状态合法", "path": "$.data[*].status", "allowed_values": [0, 1, 2]},
    ]
})

# 校验数据
result = engine.validate(data)
print(result.is_passed)
print(result.summary())
for item in result.rule_results:
    print(item)

JSON 规则文件示例(demo_rules.json):

{
  "rules": [
    {"name": "total不能为负", "path": "$.total", "min": 0},
    {"name": "id不能重复", "path": "$.data[*].id", "unique": true},
    {"name": "状态合法", "path": "$.data[*].status", "allowed_values": [0, 1, 2]}
  ]
}

3. 在 pytest 中使用

from testoracle.pytest_plugin import assert_oracle

def test_user_list():
    resp = requests.get("/api/user/list")

    # 方式一:YAML 规则文件
    assert_oracle(resp.json(), rules_file="demo_rules.yaml")

    # 方式二:JSON 规则文件
    assert_oracle(resp.json(), rules_json="demo_rules.json")

    # 方式三:内联规则字典
    assert_oracle(resp.json(), rules={
        "rules": [
            {"name": "total非负", "path": "$.total", "min": 0},
            {"name": "id唯一", "path": "$.data[*].id", "unique": True},
        ]
    })

    # 方式四:直接传入 engine 实例
    engine = OracleEngine.from_yaml("demo_rules.yaml")
    assert_oracle(resp.json(), engine=engine)

校验失败时会展示:规则名称、jsonpath 路径、实际值、失败原因。
若安装了 allure-pytest,每条规则结果会自动嵌入 allure 步骤。

4. CLI 命令行

# 对一份 json 样本执行规则校验
testoracle run --data sample.json --rules demo_rules.yaml --output result.json

校验存在失败时退出码为 1,可在 CI 中阻断流水线。

支持的规则

两种声明风格:type(显式指定类型)与谓词推断式(不写 type,按字段推断,可组合)。

规则总览

约束 type 谓词字段 说明
数值范围 range min, max 数值落在 [min, max]
值等于/浮点近似 range equals, epsilon 精确等于或浮点近似相等
非空 not_null not_null: true 非 None / 空串 / 空集合
唯一性 unique unique: true 数组元素两两不重复
禁止字段 forbidden_field forbidden: true 路径匹配到任意值即失败
枚举 enum allowed_values 值在允许集合内
长度 length min_length/max_length/length_equals 字符串/集合长度校验
类型 type type: "number" 值的 Python 类型校验
正则 regex regex: "phone" 值匹配正则表达式
条件 conditional if + then 当 A 满足时,B 必须满足
字段存在 field_exist must_exist 字段 key 必须存在于 dict 中
字段不存在 field_not_exist must_not_exist 字段 key 一定不能存在
时间 time time_format/future/past/before/after 日期/时间校验
跨字段对比 compare left_path + right_path/right_expr 跨字段值对比或表达式计算
单调递增/递减 monotonic monotonic/monotonic_step 列表值单调校验,可选步长
子 schema item_schema item_rules 数组内对象嵌套子规则校验
或表达式 any_of any_of 多个子规则满足其一即可
分组统计 count_by_group min_count/max_count/group_by 分组计数约束
非法字符检测 text_sanitize sanitize_categories/sanitize_custom_patterns XSS/SQL 注入等危险模式检测
嵌套深度限制 depth_limit max_depth 对象嵌套层级不能过深
内容包含 contains contains 值必须包含指定内容
内容排除 not_contains not_contains 值不能包含指定内容
至少包含其一 contains_any 必须至少包含多项中的一项
必须全部包含 contains_all 必须同时包含所有指定内容
字段互斥 mutual_exclusive mutual_exclusive 多个字段不能同时有值
聚合统计 aggregate aggregate 求和/平均值/计数等聚合校验
引用完整性 referential_integrity source_path + target_path 跨数组引用值必须存在
复合 —(自动) 多个谓词并存 多约束 AND,全部通过才算通过

条件规则支持的操作符

操作符 说明
equals 等于
not_equals 不等于
in 值在列表中
not_in 值不在列表中
contains 字符串/列表包含
not_contains 不包含
starts_with 字符串前缀匹配
ends_with 字符串后缀匹配
matches 正则匹配
exists 路径存在值
not_exists 路径不存在值
greater_than 大于
less_than 小于
greater_or_equal 大于等于
less_or_equal 小于等于
is_type Python 类型判断

类型规则支持的类型

intfloatnumber(int 或 float,排除 bool)、str/stringbool/booleanlist/arraydict/objectnull/noneany

正则规则内置模式

内置规则名 说明
phone 中国大陆手机号
email 邮箱地址
id_card 18 位中国大陆身份证号
url HTTP/HTTPS URL
ipv4 IPv4 地址
chinese 纯中文字符
postal_code 中国 6 位邮政编码
license_plate 中国车牌号

也支持自定义正则:pattern: "^[a-zA-Z0-9]+$"

时间运算表达式

支持的时间关键字:todaynowtoday_starttoday_end
支持运算表达式:today - 30(30天前)、today + 7dnow - 1hnow + 30mtoday - 2wtoday + 1y

单位:d(天)、h(小时)、m(分钟)、w(周)、y(年)。

聚合类型

sum(求和)、avg(平均)、count(计数)、min(最小)、max(最大)。

说明

  • 谓词推断式中 min/max 一律按数值范围处理;若需长度校验请用 min_length/max_length/length_equalstype: length
  • equals 谓词表示"值等于",支持 epsilon 浮点近似相等。
  • 一条谓词式规则只含单个谓词时退化为普通规则;含多个谓词时自动包装为 composite
  • 谓词推断式中的 type 字段值如果是 Python 类型名(如 numberlist),会被当作类型谓词处理;如果是规则类型名(如 rangetype),会被当作显式 type 式处理。
  • 谓词推断式中的 regex 字段值如果是内置规则名(如 phoneemail),会使用内置正则;否则会被当作自定义正则表达式。
  • 所有规则通过 jsonpath 表达式(基于 jsonpath-ng)定位数据。

编写示例

数值范围与浮点近似相等

rules:
  - name: "分页total不能为负数"
    path: "$.total"
    min: 0

  # 浮点数近似相等
  - name: "计算结果应近似等于3.14"
    path: "$.result"
    equals: 3.14
    epsilon: 0.01

  # 精确等于
  - name: "状态码必须为200"
    path: "$.code"
    equals: 200

条件规则(if-then)

rules:
  - name: "已退款订单必须有退款金额"
    if:
      path: "$.status"
      operator: "equals"
      value: "refunded"
    then:
      path: "$.refund_amount"
      min: 0.01
      not_null: true

字段存在性

rules:
  - name: "成功响应必须包含token字段"
    path: "$"
    must_exist: "access_token"

  - name: "普通用户不能返回内部secret字段"
    path: "$"
    must_not_exist: "inner_secret"

时间校验

rules:
  - name: "创建时间不能早于30天前"
    type: "time"
    path: "$.created_at"
    format: "%Y-%m-%d"
    after: "today - 30"

  - name: "过期时间必须在未来"
    path: "$.expire_at"
    time_format: "%Y-%m-%d %H:%M:%S"
    future: true

  - name: "生日不能晚于今天"
    path: "$.birthday"
    time_format: "%Y-%m-%d"
    max: "today"
    past: true

跨字段对比与计算

rules:
  - name: "退款金额不能超过订单金额"
    type: "compare"
    left_path: "$.refund_amount"
    operator: "less_or_equal"
    right_path: "$.order_amount"

  # 表达式计算对比
  - name: "总价必须等于单价乘以数量"
    type: "compare"
    left_path: "$.total"
    operator: "equals"
    right_expr: "$.price * $.quantity"

  # 谓词推断式
  - name: "折扣价必须低于原价"
    left_path: "$.sale_price"
    compare_operator: "less_than"
    right_path: "$.original_price"

单调递增/递减

rules:
  - name: "列表id必须单调递增"
    type: "monotonic"
    path: "$.data[*].id"
    direction: "asc"

  - name: "创建时间倒序返回"
    type: "monotonic"
    path: "$.data[*].create_ts"
    direction: "desc"
    strict: true

  # 带步长校验
  - name: "ID必须连续递增(步长为1)"
    type: "monotonic"
    path: "$.data[*].id"
    direction: "asc"
    step: 1

  # 谓词推断式
  - name: "序号必须递增"
    path: "$.data[*].seq"
    monotonic: "asc"

数组内对象子 schema 校验

rules:
  - name: "每一条订单子项都要满足基础约束"
    type: "item_schema"
    path: "$.order_items[*]"
    item_rules:
      - min: 0
        path: "$.amount"
      - not_null: true
        path: "$.goods_id"

或表达式(any_of)

rules:
  - name: "状态可以是0、1或2"
    type: "any_of"
    path: "$.data[*].status"
    any_of:
      - path: "$.status"
        equals: 0
      - path: "$.status"
        equals: 1
      - path: "$.status"
        equals: 2

分组统计

rules:
  # 按 status 分组,已完成的记录至少 2 条
  - name: "已完成订单至少2条"
    type: "count_by_group"
    path: "$.data[*]"
    filter: {"status": 2}
    min_count: 2

  # 使用 group_by 自动推断父数组
  - name: "已完成订单至少2条"
    group_by: "$.data[*].status"
    filter: {"status": 2}
    min_count: 2

  # 最大数量限制
  - name: "失败订单最多1条"
    path: "$.data[*]"
    filter: {"status": "failed"}
    max_count: 1

非法字符检测

rules:
  - name: "用户输入不能包含危险字符"
    type: "text_sanitize"
    path: "$.content"
    categories: ["xss", "sql_injection"]

  # 带自定义模式
  - name: "订单描述不能有特殊标记"
    type: "text_sanitize"
    path: "$.description"
    categories: ["all"]
    custom_patterns:
      - name: "自定义危险词"
        pattern: "SELECT\\s+.*FROM\\s+password"

  # 谓词推断式
  - name: "昵称不能包含脚本标签"
    path: "$.nickname"
    sanitize_categories: ["xss"]

嵌套深度限制

rules:
  - name: "返回对象嵌套不能超过5层"
    type: "depth_limit"
    path: "$.data"
    max_depth: 5

  # 谓词推断式
  - name: "data 嵌套不能超过3层"
    path: "$.data"
    max_depth: 3

内容包含与排除

rules:
  - name: "商品名称必须包含'无线'"
    type: "contains"
    path: "$.data[*].name"
    value: "无线"

  - name: "描述不能包含'测试'"
    type: "not_contains"
    path: "$.data[*].description"
    value: "测试"

  # 谓词推断式
  - name: "标签必须包含'热卖'"
    path: "$.data[*].tags"
    contains: "热卖"

  # 至少包含一个
  - name: "商品属性必须包含'新品'或'促销'"
    path: "$.data[*].tags"
    contains_any: ["新品", "促销"]

  # 必须全部包含
  - name: "地址必须同时包含'省'和'市'"
    path: "$.data[*].address"
    contains_all: ["省", "市"]

字段互斥

rules:
  - name: "支付方式和优惠券不能同时有值"
    type: "mutual_exclusive"
    path: "$"
    fields: ["$.pay_type", "$.coupon_type"]

  # 谓词推断式
  - name: "三种配送方式只能选一种"
    path: "$"
    mutual_exclusive: ["$.express_delivery", "$.pickup", "$.store_delivery"]

聚合统计

rules:
  # 求和等于另一字段
  - name: "明细金额总和等于总金额"
    type: "aggregate"
    path: "$.items[*].amount"
    agg: "sum"
    compare_path: "$.total_amount"
    operator: "equals"

  # 求和与常量比较
  - name: "订单明细总和不能超过10000"
    path: "$.items[*].amount"
    agg: "sum"
    max: 10000

  # 平均值范围
  - name: "评分平均分在3-5之间"
    path: "$.scores[*].value"
    agg: "avg"
    min: 3
    max: 5

  # 计数校验
  - name: "子项数量必须至少2条"
    path: "$.items[*]"
    agg: "count"
    min: 2

  # 谓词推断式
  - name: "明细总和等于total"
    path: "$.items[*].amount"
    aggregate: "sum"
    compare_path: "$.total"

跨数组引用完整性

校验一个数组中的引用值是否全部存在于另一个数组中,类似数据库的外键约束。

字段 说明
source_path 引用方路径,提取需要校验的引用值(如商品中的 category_id
target_path 被引用方路径,提取合法值集合(如分类列表中的 id

校验逻辑:source_path 提取到的每一个值,都必须在 target_path 提取到的值集合中存在,否则失败。

rules:
  # 商品引用的分类ID,必须全部存在于分类列表的ID中
  - name: "商品分类ID必须合法"
    type: "referential_integrity"
    source_path: "$.items[*].category_id"    # 引用方:每个商品的 category_id
    target_path: "$.categories[*].id"        # 被引用方:分类列表中所有合法的 id

  # 谓词推断式
  - name: "下单用户必须存在"
    source_path: "$.orders[*].user_id"       # 引用方:每个订单的 user_id
    target_path: "$.users[*].id"             # 被引用方:用户列表中所有合法的 id

上述示例中,如果 $.items[*].category_id 提取到 [1, 2, 99],而 $.categories[*].id 提取到 [1, 2, 3],则 99 未在目标集合中,校验失败。

失败结果结构

{
  "is_passed": false,
  "summary": "总计 6 条规则,通过 2,失败 4",
  "passed_count": 2,
  "failed_count": 4,
  "rule_results": [
    {
      "rule_name": "分页total不能为负数",
      "rule_type": "range",
      "passed": false,
      "path": "$.total",
      "actual": -5,
      "message": "值 -5 小于最小值 0"
    }
  ]
}

CI 集成示例(GitLab CI)

test:oracle:
  script:
    - testoracle run --data sample.json --rules demo_rules.yaml --output result.json
  artifacts:
    when: always
    paths:
      - result.json

非 0 退出码会阻断流水线;result.json 作为产物保留供排查。

自定义规则(扩展点)

from testoracle.rules import register_rule
from testoracle.rules.base import BaseRule, RuleResult

class MyRule(BaseRule):
    rule_type = "my_rule"

    def validate(self, data):
        vals = self.extract_values(data)
        # ... 自定义逻辑
        return self._pass(vals)

register_rule("my_rule", MyRule)

注册后即可在 yaml 中使用 type: "my_rule"

项目结构

testoracle/
├── testoracle/
│   ├── core/                             # 规则解析、执行引擎、数据提取、报告
│   │   ├── rule_engine.py                # OracleEngine + ValidationReport
│   │   ├── rule_parser.py                # YAML/JSON 规则解析器
│   │   ├── extractor.py                  # JSONPath 数据提取
│   │   └── exceptions.py                 # 自定义异常
│   ├── rules/                            # 内置规则实现与注册表
│   │   ├── __init__.py                   # 规则注册表 + 谓词推断
│   │   ├── base.py                       # BaseRule 抽象基类
│   │   ├── _comparators.py               # 共享比较操作符工具
│   │   ├── _subrule_mixin.py             # 子规则编译混合类
│   │   ├── range_rule.py                 # 数值范围 + equals + epsilon
│   │   ├── not_null_rule.py              # 非空校验
│   │   ├── unique_rule.py                # 唯一性校验
│   │   ├── enum_rule.py                  # 枚举校验
│   │   ├── length_rule.py                # 长度校验
│   │   ├── type_rule.py                  # 类型校验
│   │   ├── regex_rule.py                 # 正则校验(含内置模式)
│   │   ├── composite_rule.py             # 复合规则(AND)
│   │   ├── conditional_rule.py           # 条件规则(if-then)
│   │   ├── field_exist_rule.py           # 字段存在性(含 forbidden 模式)
│   │   ├── time_rule.py                  # 时间校验(含日期运算)
│   │   ├── compare_rule.py               # 跨字段对比与表达式计算
│   │   ├── monotonic_rule.py             # 单调递增/递减
│   │   ├── item_schema_rule.py           # 数组子 schema 校验
│   │   ├── any_of_rule.py                # 或表达式(OR)
│   │   ├── count_by_group_rule.py        # 分组统计
│   │   ├── text_sanitize_rule.py         # 非法字符检测
│   │   ├── depth_limit_rule.py           # 嵌套深度限制
│   │   ├── contains_rule.py              # 内容包含与排除
│   │   ├── mutual_exclusive_rule.py      # 字段互斥
│   │   ├── aggregate_rule.py             # 聚合统计
│   │   └── referential_integrity_rule.py # 跨数组引用完整性
│   ├── pytest_plugin.py                  # pytest 插件入口
│   └── cli.py                            # 命令行入口
├── examples/                             # 演示规则、库示例、pytest 示例、样本数据
├── tests/                                # 项目自身单元测试(400+ 用例)
└── pyproject.toml

设计原则

  • 不写死预期值,只声明业务不变量
  • 只校验数据,不发起请求、不抓流量、不做用例管理平台
  • 规则解析 → 数据提取 → 规则校验 → 结果报告,职责单一
  • 两种声明风格可混用:显式 type 式与谓词推断式

License

MIT

posted @ 2026-08-18 10:51  Tynam.Yang  阅读(60)  评论(0)    收藏  举报