第 9 章 · 01 属性测试:hypothesis 的力量


文档摘要

第 9 章 · 01 属性测试:hypothesis 的力量 本节摘要:本节讲 Sequoia-X 测试体系的核心方法论——属性测试(property-based testing)。Sequoia-X 不用普通单元测试,而是用 Python 的 库做属性测试。两者根本差异是:单元测试「给具体输入,验证具体输出」,属性测试「描述性质,框架自动生成无数用例」。这种范式特别适合「不变量守护」场景——比如「SQLite 的 (symbol, date) 唯一约束」「策略返回 list[str]」「飞书 HTTP 失败仅记日志不抛异常」「同名 logger 返回同一实例」等。

第 9 章 · 01 属性测试:hypothesis 的力量

本节摘要:本节讲 Sequoia-X 测试体系的核心方法论——属性测试(property-based testing)。Sequoia-X 不用普通单元测试,而是用 Python 的 hypothesis 库做属性测试。两者根本差异是:单元测试「给具体输入,验证具体输出」,属性测试「描述性质,框架自动生成无数用例」。这种范式特别适合**「不变量守护**」场景——比如「SQLite 的 (symbol, date) 唯一约束」「策略返回 list[str]」「飞书 HTTP 失败仅记日志不抛异常」「同名 logger 返回同一实例」等。本节会先讲「为什么 Sequoia-X 选属性测试」,再盘点 Sequoia-X 守护的「核心不变量」,最后用一段可运行的 hypothesis 示例让你亲手体验「框架帮你找边界 bug」的威力。

内容来源:原项目测试目录精读 + hypothesis 官方文档。

💡 核心心法:好的「不变量测试」不是「测每个 case」,而是「描述性质」——让框架自动生成大量边界用例——找到你没想过的 bug

学习目标

阅读完本节,你应当能够:

  1. 讲清属性测试单元测试根本差异
  2. 理解 hypothesis 的「@given 装饰器 + strategies 描述输入」范式。
  3. 盘点 Sequoia-X 守护的「核心不变量」。
  4. 写一段可运行的 hypothesis 属性测试。

一、属性测试 vs 单元测试

1.1 单元测试的范式

# 普通单元测试 def test_sum(): assert sum([1, 2, 3]) == 6 assert sum([]) == 0 assert sum([1]) == 1

特点:

  • 测试者写死输入——「这些 case 应当满足」;
  • 漏掉的 case 不会被测试——「我没想过的 case 怎么办」?

1.2 属性测试的范式

from hypothesis import given, strategies as st @given(st.lists(st.integers())) def test_sum_property(lst: list[int]) -> None: """性质:sum 应等于其元素之和。""" expected = sum(lst) # 用 Python 内置 sum 算"正确答案" assert my_sum(lst) == expected

特点:

  • 测试者描述「性质」——「任意输入都应满足」;
  • 框架自动生成 N 个用例N 可配,100/200/1000)——自动覆盖边界

1.3 根本差异

维度 单元测试 属性测试
输入来源 测试者手写 框架自动生成
覆盖度 取决于测试者经验 依赖框架的搜索能力
典型用例数 5~20 50~200+(每个 @given 函数)
适合场景 具体业务逻辑 不变量守护
找到的 bug 我没想到的 我没想到的边界

Sequoia-X 选属性测试——因为它守护的全是「不变量(如唯一约束、类型保证、幂等性)——正是属性测试的强项

二、Sequoia-X 守护的核心不变量

Sequoia-X 用属性测试守护七类不变量

不变量 守护方式
① 配置加载:环境变量正确反映到 Settings @given(st.text()) 随机 db_path → 验证读取
② 必填字段:缺失 feishu_webhook_url 抛 ValidationError 直接构造 Settings,期望抛异常
③ SQLite 唯一约束:同股同日插入两次 → 第二条抛 IntegrityError 随机 (symbol, date) → 重复插入 → 期望被拒
④ 策略 run() 返回类型:list[str] 随机 symbols → 验证返回类型
⑤ 飞书 HTTP 失败:仅记日志不抛异常 随机 status_code 400~599 → 验证不抛异常
⑥ 飞书推送 URL 正确:settings.feishu_webhook_url 随机 URL → 验证发出的 URL 一致
⑦ 日志幂等性:同名 logger 总是同一实例 随机 name → 验证同一实例 + handler 不重复

这七条不变量是「系统稳定运行的基石」——任何一条被破坏,整套系统就会出现奇异行为

三、hypothesis 的核心 API

from hypothesis import given, strategies as st # 给定一个 lists[str] 输入,验证某个性质 @given(st.lists(st.text(min_size=6, max_size=6, alphabet="0123456789"), max_size=10)) def test_xxx(symbols): ...

关键元素:

API 作用
@given(strategy) 装饰器:声明「输入由这个 strategy 生成
st.text(...) 字符串策略(可指定字符集、长度范围)
st.lists(...) 列表策略(可指定元素策略、最大长度)
st.integers(...) 整数策略(可指定范围)
st.from_regex(...) 正则策略(生成匹配正则的字符串
st.dates(...) 日期策略
h_settings(max_examples=100) 装饰器:声明「跑多少个用例

hypothesis 的搜索能力很强大——它会尝试「最小失败用例」——找到 bug 后自动收缩到「最简复现」。

四、可运行教学代码:三条核心属性

下面这段代码完整演示「用 hypothesis 守护三条核心不变量」:

# hypothesis_demo.py # 可运行:属性测试三条核心不变量 import os import re import sqlite3 import tempfile from hypothesis import given, settings as h_settings, strategies as st from pydantic import ValidationError # === 属性 1:SQLite (symbol, date) 唯一约束 === def make_table() -> sqlite3.Connection: conn = sqlite3.connect(":memory:") conn.execute(""" CREATE TABLE stock_daily ( id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT NOT NULL, date TEXT NOT NULL, open REAL, high REAL, low REAL, close REAL, volume REAL, turnover REAL, UNIQUE (symbol, date) ) """) conn.commit() return conn @given( symbol=st.text(min_size=6, max_size=6, alphabet="0123456789"), trade_date=st.dates(min_value=__import__("datetime").date(2024, 1, 1), max_value=__import__("datetime").date(2025, 12, 31)), ) @h_settings(max_examples=50, deadline=None) def test_unique_symbol_date(symbol: str, trade_date) -> None: """同股同日插入两次,第二次必被 UNIQUE 拒绝。""" conn = make_table() row = (symbol, str(trade_date), 10.0, 11.0, 9.0, 10.5, 1000.0, 10500.0) conn.execute("INSERT INTO stock_daily (symbol,date,open,high,low,close,volume,turnover) VALUES (?,?,?,?,?,?,?,?)", row) conn.commit() # 重复插入必被拒绝 try: conn.execute("INSERT INTO stock_daily (symbol,date,open,high,low,close,volume,turnover) VALUES (?,?,?,?,?,?,?,?)", row) conn.commit() assert False, "UNIQUE 约束未生效" except sqlite3.IntegrityError: pass # 期望的失败 finally: conn.close() # === 属性 2:同名 logger 返回同一实例 === @given(name=st.text(min_size=1, max_size=20, alphabet="abcdefghijklmnopqrstuvwxyz._")) @h_settings(max_examples=30, deadline=None) def test_logger_idempotent(name: str) -> None: """任意 name,多次调用 get_logger(name) 应返回同一实例。""" from <核心日志模块> import get_logger # 复用第 4 章的 get_logger log1 = get_logger(name) log2 = get_logger(name) assert log1 is log2 assert len(log1.handlers) == 1 # handler 不重复 # === 属性 3:飞书 HTTP 失败时仅记日志,不抛异常 === @given(status_code=st.integers(min_value=400, max_value=599)) @h_settings(max_examples=30, deadline=None) def test_feishu_http_failure_no_raise(status_code: int) -> None: """任意 4xx/5xx 响应,send() 不应抛异常。""" from <核心飞书模块> import FeishuNotifier from <核心配置模块> import Settings import logging as _logging settings = Settings( db_path="data/test.db", start_date="2024-01-01", feishu_webhook_url="https://feishu.test/hook", ) notifier = FeishuNotifier(settings) # 收集 ERROR 日志 log_records: list = [] class _ListHandler(_logging.Handler): def emit(self, record): log_records.append(record) feishu_logger = _logging.getLogger("feishu_test") feishu_logger.addHandler(_ListHandler(level=_logging.ERROR)) try: from unittest.mock import patch, MagicMock with patch("requests.post") as mock_post: mock_post.return_value = MagicMock(status_code=status_code, text="error") notifier.send(symbols=["000001"], strategy_name="Test", webhook_key="default") finally: feishu_logger.removeHandler(_ListHandler(level=_logging.ERROR)) # 不抛异常 + 记了 ERROR 日志 assert any(r.levelno == _logging.ERROR for r in log_records), \ f"未记 ERROR 日志: status={status_code}" if __name__ == "__main__": print("=" * 50) print("属性 1:SQLite UNIQUE 约束") print("=" * 50) test_unique_symbol_date() print("✅ 50 个随机 (symbol, date) 全部通过\n") print("=" * 50) print("属性 2:同名 logger 幂等性") print("=" * 50) test_logger_idempotent() print("✅ 30 个随机 logger name 全部通过\n") print("=" * 50) print("属性 3:飞书 HTTP 失败不抛异常") print("=" * 50) test_feishu_http_failure_no_raise() print("✅ 30 个随机 4xx/5xx 状态码全部通过")

预期输出(节选):

================================================== 属性 1:SQLite UNIQUE 约束 ================================================== ✅ 50 个随机 (symbol, date) 全部通过 ================================================== 属性 2:同名 logger 幂等性 ================================================== ✅ 30 个随机 logger name 全部通过 ================================================== 属性 3:飞书 HTTP 失败不抛异常 ================================================== ✅ 30 个随机 4xx/5xx 状态码全部通过

hypothesis 自动生成了 50+30+30 = 110 个测试用例——远超普通单元测试——其中任何一个失败都会让整条属性失败

配套简化模块:上面示例用 from <核心日志模块> import get_loggerfrom <核心飞书模块> import FeishuNotifier, Settings 引用了教学简化版模块。把第 4 章的 get_logger 和第 7 章的 FeishuNotifier 合并到一个文件里即可直接运行。

五、hypothesis 的「自动收缩

hypothesis 的杀手锏是「失败用例自动收缩」——找到 bug 时,自动找最小复现

完整失败用例:(symbol="000001", date="2024-06-15", open=10.0, high=11.0, low=9.0, close=10.5) ↓ 自动收缩 最小失败用例:(symbol="000001", date="2024-06-15") ← 框架找出的最简形式

这种「自动收缩」让 bug 复现极其容易——不需要你手动找最小用例

六、Sequoia-X 真实测试目录的盘点

Sequoia-X 真实测试目录包含 6 个测试模块,全部用 hypothesis 属性测试

测试模块 守护的不变量
配置模块测试 配置加载、必填校验
数据引擎测试 SQLite 唯一约束
策略返回类型测试 策略返回类型(list[str])
飞书推送测试 飞书推送、URL 正确、HTTP 失败容错
日志系统测试 命名 logger 幂等性
主流程异常测试 主流程异常 → 非零退出

每个测试模块 1~3 个 @given 函数——总测试函数很少,但总用例数很多——质量与维护成本的平衡点

七、为什么 Sequoia-X 不写更多「具体场景」测试

读者可能会问:「为什么不写更多具体场景的单元测试?」答案是——属性测试已经覆盖了「性质——具体场景只是「性质的特例

维度 普通单元测试 属性测试
sum 测 3 个数 assert sum([1,2,3]) == 6 @given(st.lists(st.integers())) 自动测 100 种
UNIQUE 测 2 个 case assert duplicate raises @given 随机 50 个 (symbol, date) 全部测
维护成本 (每个 case 都要手写) (只描述性质)
覆盖度 (手写 case 难穷尽) (框架搜索)

描述性质」比「列举 case」更接近软件工程的本质——软件的不变量比具体行为更重要

八、本节在全景图的位置

属性测试 ✅ ← 本节 │ ▼ 下一节(第 02 节)定时任务与生产部署

本节要点回顾

  1. 属性测试 vs 单元测试:手写 case vs 描述性质——覆盖度与维护成本的权衡
  2. 核心不变量:七条不变量守护整套系统稳定——配置、唯一约束、返回类型、HTTP 失败、日志幂等。
  3. hypothesis 范式@given(strategy) 装饰器 + st.xxx() 输入策略——50+ 个用例自动生成
  4. 自动收缩:找到 bug 后自动找最小复现——比手动调试快 10 倍
  5. 可运行示例:3 条核心属性(UNIQUE、日志幂等、HTTP 容错)共 110 个用例——全部通过
  6. 为什么不用更多单元测试属性测试已经覆盖了「性质——具体场景只是特例。

配套教学脚本:images/hypothesis_demo.py——三条核心属性的可运行演示。

下一节,把 Sequoia-X 「挂到系统」——讲定时任务与生产部署


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U