周五下班前你在私募的 CI 仪表盘上看到一片绿:xyzprice 的 86 个测试全过,行覆盖率显示 95%。周一开盘九点二十,研究系统在喂一段空盘后行情时崩在了 mean_price([]) 上——你写过的测试里,从来没有一个把空列表喂进去。覆盖率告诉你「这一行跑过」,但不会告诉你「这一行只在 happy path 上跑过」。上一课的 pytest 让你能写出测试,但它对三件事保持沉默:哪些代码从没被任何测试触达、哪些边界输入你压根没想到、哪些测试其实在偷偷依赖外部系统。本课把这三个缺口逐一补上。
覆盖率:先量化你不知道的事
pytest-cov 把行覆盖率(line coverage)与分支覆盖率(branch coverage)挂在 pytest 调用上。把它加进 pyproject.toml 的 dev 可选依赖(第一课已经建好这段),然后从仓库根目录跑:
pytest --cov=xyzprice --cov-report=term-missing
输出会按模块列出覆盖率,并把 Missing 一列指向具体行号,例如 src/xyzprice/fetch.py 42 7 83% 18-22, 47,意思是第 18–22 行与第 47 行从未被任何测试触达。再追加 --cov-branch,pytest-cov 会把 if/elif 的每个分支单独计数:if x: 即便 x=True 那一支跑过,x=False 没有测试时仍会被标红。
两条铁律必须刻进脑子:分支覆盖比行覆盖更重要;100% 覆盖率配上没有 assert 的测试只是表演(test theatre)。覆盖率是必要条件不是充分条件——它告诉你哪里 没有 测试,但不证明 有 测试在断言什么。把下面这段写进 pyproject.toml,让 CI 在覆盖率掉到 80% 以下时直接失败:
[tool.pytest.ini_options]
addopts = "--cov --cov-fail-under=80"
阈值 80% 是起点而非教条;老仓库可以从「当前实际值 −5%」起步,给增量代码留出收紧空间。反模式是为了凑数写出一堆没有断言的 smoke test——这类测试只动了行数,没增加任何信心。审 code 时盯着新增的 tests/ 文件至少要有一条 assert,比盯着 CI 上的 92% 数字更能判断这次提交到底带来了多少保护。
基于属性的测试:让机器替你想边界
回到 mean_price。直觉告诉你这个函数有一条不变量(invariant):返回值必须落在输入的 min 与 max 之间。可你的 pytest 用例只手写了三五组输入。hypothesis 把这件事自动化——你声明输入的「形状」,它生成上百组随机输入并验证不变量。它同样在 dev extras 里安装好:
from hypothesis import given, strategies as st
from xyzprice import mean_price
@given(st.lists(st.floats(min_value=0, max_value=1e6), min_size=1))
def test_mean_price_within_bounds(xs):
assert min(xs) <= mean_price(xs) <= max(xs)
st.lists(st.floats(...), min_size=1) 描述「一个非空浮点列表」这一策略(strategy);@given 把抽样这件事接管过去。min_size=1 这一参数本身就是教训:如果删掉它,hypothesis 会立刻给你扔出空列表,而你原来对空输入的处理是没定义的——这是它替你想到的第一个边界。当不变量被违反时,hypothesis 会启动收缩(shrinking):它先抓到一个失败用例,比如 [3.14e5, 0.0, 4.71e-2, ...],然后反复尝试更短、更小、更「干净」的输入,最终打印出一个最小反例(counterexample),通常是 [0.0, 0.0] 之类的形状。最小反例比一长串随机数更容易让你看出错在语义的哪一层。
Mock:把外部世界从测试里切掉
xyzprice.fetch.fetch_latest_price('XYZ001.SH') 内部会调 requests.get(...),再从 JSON 里取 price 字段。这种函数在 CI 里直接跑等于让构建依赖网络与远端 API,是反模式。两条主流办法都把外部调用替换成你能控制的桩。
第一选项是 pytest 自带的 monkeypatch fixture——函数作用域,测试结束自动恢复,无需手动 teardown:
def test_fetch_latest_price(monkeypatch):
def fake_get(url):
class R:
def json(self):
return {"price": 1820.50}
return R()
monkeypatch.setattr("xyzprice.fetch.requests.get", fake_get)
assert fetch_latest_price("XYZ001.SH") == 1820.50
第二选项是标准库的 unittest.mock.patch,附带调用记录与参数校验:
from unittest.mock import patch
def test_fetch_latest_price_calls_endpoint():
with patch("xyzprice.fetch.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"price": 100.0}
assert fetch_latest_price("XYZ001.SH") == 100.0
mock_get.assert_called_once_with("https://md.example/quote?ticker=XYZ001.SH")
一句话取舍:在 pytest 项目里默认用 monkeypatch,因为它是 pytest-native 且无需手动 teardown;当你需要记录调用、断言参数(assert_called_once_with),或维护 unittest 历史代码时切换到 unittest.mock.patch。国内团队在 pytest 项目里通常优先选 monkeypatch,旧仓库才会沿用 unittest.mock。HTTP 测试堆栈大到一定规模时,responses 这类专为 requests 设计的库会比裸 monkeypatch 更顺手——属于「真的需要时再请进来」的工具。
延伸阅读:pytest-cov 文档(英文);hypothesis 文档(英文)与社区博客「Python 属性测试入门」中文导读一句话;Python 官方文档 unittest.mock 中文版;Brian Okken《Python Testing with pytest》第 9–10 章。
练习
Exercise
给定函数 def discounted_present_value(cashflows: list[float], rate: float) -> float,它返回 sum(c / (1 + rate) ** (i + 1) for i, c in enumerate(cashflows)),并在 rate <= -1.0 时抛 ValueError。请写三个测试:
- 用
@pytest.mark.parametrize写一个驱动覆盖率的 pytest 测试,分别覆盖空列表、单条现金流、多条现金流三种情形。 - 用 hypothesis 写一个属性测试,使用
@given(st.lists(st.floats(min_value=0, max_value=1e6)), st.floats(min_value=0, max_value=0.5))验证不变量:当r >= 0时,discounted_present_value(xs, r) <= sum(xs)。 - 用
monkeypatch.setattr把假想的xyzprice.rates.get_market_rate()调用桩成返回固定0.05,然后断言discounted_present_value([100.0], get_market_rate()) == pytest.approx(100.0 / 1.05)。
提示
@pytest.mark.parametrize 列出三组 (cashflows, rate, expected);第二题在 @given 后给形参 xs, r 并在 body 写不变量;第三题在测试签名里加 monkeypatch 形参。提示
0.0,单条 100.0/1.05;浮点比较一律用 pytest.approx。monkeypatch.setattr 第一个参数是被替换对象的完整点分路径字符串 "xyzprice.rates.get_market_rate",第二个参数是返回 0.05 的 lambda。衔接下一课
到这里你的测试套件已经能告诉你哪些代码没被测过、能自己挖出你没想到的反例、也不再依赖任何外部网络。最后一课把这套绿色的 xyzprice 包推过最后一道门:构建 wheel 与 sdist、用语义化版本号打 tag,并发布到(内网或公开的)PyPI——把一个能跑、能测的代码库变成别人一行 pip install 就能拿到的产物。