← 返回模块
3.1.3.2beta 可读 · 未来付费内容校验中内容版本 2026-05-27

pytest 基础

3.1.3 · Python 打包与测试 · 编程

周一开盘前一刻钟,你在私募的研究服务器上 merge 了一段对 mean_price 的「无害重构」——只是把 sum(...) / len(...) 拆成两步,方便在中间加日志。脚本照常跑完,回测照常出图。下午两点你才发现 PnL 报表上 XYZ001.SH 的当日均价对不上:你在重构时把 sumlen 的参数搞反了,函数对所有非空输入都返回 1。一条 5 行的 pytest 测试,原本可以在 commit 之前就把这一刀挡下来。本课从空文件开始,把 pytest 拼到能拦住这类事故的程度。

项目布局与测试发现

把测试放进仓库根目录下的 tests/ 文件夹,与上一课里 xyzprice 包的源码并排。pytest 的测试发现(test discovery)规则简短:文件名匹配 test_*.py*_test.py,函数名以 test_ 开头,类名以 Test 开头且不带 __init__。把第一个测试文件命名为 tests/test_xyzprice.py,pytest 在仓库根目录运行时会自动找到它,不需要任何注册步骤。

三条最常用的调用

pytest
pytest -v
pytest -k mean_price

第一条从仓库根目录跑全部测试,输出形如 ..F.. 的紧凑摘要——绿点代表通过,红色 F 代表失败。-v 把每个测试名展开成一行,方便定位。-k mean_price 按子串筛选,只跑名字里含 mean_price 的测试;调试单个用例时还可以写 pytest tests/test_xyzprice.py::test_mean_price_single_tick

断言重写

pytest 的核心心智模型只有一条:直接用 Python 原生 assert,pytest 在导入测试文件时重写字节码,让失败信息同时印出等号两侧的实际值。你不再需要 unittest.TestCase.assertEqual 那一套仪式:

from xyzprice import mean_price, Tick


def test_mean_price_single_tick():
    assert mean_price([Tick('XYZ001.SH', 1820.50, 200)]) == 1820.50

失败时 pytest 会打印 assert 1.0 == 1820.50 并把两侧展开;对结构更复杂的对象,pytest 还会按属性逐项 diff,让你不用反复猜测哪一字段出错。unittest 本身仍在标准库里,作为 pytest 的前辈在老代码库里随处可见;新代码已基本被 pytest 取代,读懂即可。

夹具与作用域

测试常常要共享一份「干净的输入数据」。pytest 用 @pytest.fixture 表达这一点:把构造逻辑写成函数、加上装饰器,然后在测试函数的形参里写同名变量,pytest 会自动把它注入:

import pytest
from xyzprice import mean_price, Tick


@pytest.fixture
def sample_ticks():
    return [Tick('XYZ001.SH', 1820.50, 200), Tick('ABC002.SZ', 12.34, 1500)]


def test_mean_price_two_ticks(sample_ticks):
    assert mean_price(sample_ticks) == 916.42

scope= 决定 fixture 的生命周期:默认 function 每个测试重新构造一次,最安全;module 在同一测试文件里只构造一次,适合数据库连接、临时目录等较贵的资源;session 整次 pytest 运行只构造一次,慎用——它会让跨测试状态泄漏。一开始统一用默认值即可。fixture 这个词在 code review 时大家依旧读作 fixture,不翻译。

参数化:一张表,多组用例

@pytest.mark.parametrize 把同一段测试体复用到一张参数表上:

@pytest.mark.parametrize("price,volume,expected", [(1.0, 100, 1.0), (2.0, 200, 2.0)])
def test_mean_price_parametrized(price, volume, expected):
    assert mean_price([Tick('XYZ001.SH', price, volume)]) == expected

pytest 把这一行展开成两个测试,名字分别是 test_mean_price_parametrized[1.0-100-1.0]test_mean_price_parametrized[2.0-200-2.0]——表格行号变成可定位失败的坐标。表驱动(table-driven)写法在扫边界条件时比手写多个函数节省大量重复,加新一行远比再贴一段函数体便宜。需要给单条用例起更可读的名字时,可以传 ids=["small", "large"] 让输出里直接出现可点的标签。

异常路径与浮点近似

业务函数往往要求错误输入时显式抛异常。pytest 用 pytest.raises 把「期望抛出某个异常」写成一个上下文管理器:

import pytest
from xyzprice import mean_price


def test_mean_price_empty_raises():
    with pytest.raises(ValueError):
        mean_price([])

mean_price([]) 没有抛 ValueError,测试失败;抛了别的异常类型同样失败。需要进一步断言异常消息时改写为 with pytest.raises(ValueError) as exc_info:,再检查 exc_info.value.args

浮点比较是另一个高频坑:千万别写 assert x == 0.1 + 0.2,IEEE 754 累加误差会在大多数 CPU 上让它失败。正确的写法是 assert mean_price(ticks) == pytest.approx(100.5, rel=1e-9),其中 rel=1e-9 表示允许 10⁻⁹ 量级的相对误差,比死板的小数位四舍五入稳健得多。

几个「先知道存在」的扩展

跨多个测试文件共享的 fixture 放进 conftest.py——pytest 会自动发现,不需要 import。两个内置 fixture 先记下名字:tmp_path 给一个独占的临时目录,capsys 捕获被测函数往 stdout / stderr 写的内容;完整用法留到下一课。日后写 numpy / pandas 测试时,请改用 numpy.testing.assert_allclosepandas.testing.assert_frame_equal 代替裸 ==,完整治法在 3.2 主题展开。异步测试用 pytest-asyncio,并行执行用 pytest-xdist,属于「插件生态」,3.3.1 讲并发时会再提一次。

延伸阅读:pytest 官方文档中文版(社区翻译)与英文版;廖雪峰单元测试小节(讲 unittest,作对照);Brian Okken 的「Python Testing with pytest」英文版目前最完整。

练习

Exercise

给定函数 def rolling_mean(values: list[float], window: int) -> list[float],它在固定窗口上返回滚动均值,并在 window <= 0 时抛 ValueError。写四个 pytest 测试:

  1. 基础用例——用裸 assert 断言 rolling_mean([1.0, 2.0, 3.0, 4.0], 2) == [1.5, 2.5, 3.5]
  2. 定义一个名为 sample_values 的 fixture,返回 [1.0, 2.0, 3.0, 4.0],并写一个消费它的测试。
  3. @pytest.mark.parametrize 覆盖窗口 123 各自对应的期望输出。
  4. pytest.raises 断言 rolling_mean([1.0, 2.0], 0) 会抛 ValueError
提示
复制 tests/test_xyzprice.py 的导入与目录布局,把 mean_price 换成 rolling_mean,先写一行裸 assert 比较两个列表把基础用例跑通,再补其余三条。
提示
fixture 函数体只需 return [1.0, 2.0, 3.0, 4.0];参数化时把 expected 自己手算后填进元组;异常用例用 with pytest.raises(ValueError): 包住调用即可,不必断言消息。

衔接下一课

到这里你已经拥有一个会运行、会失败、会重跑、能筛选的测试套件。但「跑过了」与「覆盖了」并不是一回事——下一课把 coverage.py 接到 pytest 上,告诉你哪些行从未被任何测试触达,再用 hypothesis 自动生成上千组边界输入,把你没想到的失败也挖出来。