第 9 章 · 01 属性测试:hypothesis 的力量 本节摘要:本节讲 Sequoia-X 测试体系的核心方法论——属性测试(property-based testing)。Sequoia-X 不用普通单元测试,而是用 Python 的 库做属性测试。两者根本差异是:单元测试「给具体输入,验证具体输出」,属性测试「描述性质,框架自动生成无数用例」。这种范式特别适合「不变量守护」场景——比如「SQLite 的 (symbol, date) 唯一约束」「策略返回 list[str]」「飞书 HTTP 失败仅记日志不抛异常」「同名 logger 返回同一实例」等。
本节摘要:本节讲 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。
阅读完本节,你应当能够:
# 普通单元测试 def test_sum(): assert sum([1, 2, 3]) == 6 assert sum([]) == 0 assert sum([1]) == 1
特点:
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
特点:
| 维度 | 单元测试 | 属性测试 |
|---|---|---|
| 输入来源 | 测试者手写 | 框架自动生成 |
| 覆盖度 | 取决于测试者经验 | 依赖框架的搜索能力 |
| 典型用例数 | 5~20 | 50~200+(每个 @given 函数) |
| 适合场景 | 具体业务逻辑 | 不变量守护 |
| 找到的 bug | 「我没想到的」 | 「我没想到的边界」 |
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 不重复 |
这七条不变量是「系统稳定运行的基石」——任何一条被破坏,整套系统就会出现奇异行为。
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_logger与from <核心飞书模块> import FeishuNotifier, Settings引用了教学简化版模块。把第 4 章的get_logger和第 7 章的FeishuNotifier合并到一个文件里即可直接运行。
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 真实测试目录包含 6 个测试模块,全部用 hypothesis 属性测试:
| 测试模块 | 守护的不变量 |
|---|---|
| 配置模块测试 | 配置加载、必填校验 |
| 数据引擎测试 | SQLite 唯一约束 |
| 策略返回类型测试 | 策略返回类型(list[str]) |
| 飞书推送测试 | 飞书推送、URL 正确、HTTP 失败容错 |
| 日志系统测试 | 命名 logger 幂等性 |
| 主流程异常测试 | 主流程异常 → 非零退出 |
每个测试模块 1~3 个 @given 函数——总测试函数很少,但总用例数很多——质量与维护成本的平衡点。
读者可能会问:「为什么不写更多具体场景的单元测试?」答案是——属性测试已经覆盖了「性质」——具体场景只是「性质的特例」。
| 维度 | 普通单元测试 | 属性测试 |
|---|---|---|
| 「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 节)定时任务与生产部署
@given(strategy) 装饰器 + st.xxx() 输入策略——50+ 个用例自动生成。配套教学脚本:
images/hypothesis_demo.py——三条核心属性的可运行演示。
下一节,把 Sequoia-X 「挂到系统」——讲定时任务与生产部署。