本节目标:掌握 pytest 的自动发现规则、断言重写、异常断言与标记系统,并能用配置和命令行开关组织一次可重复的测试运行。
适用版本:Python 3.12+(实测 3.14.6)
14.1 pytest 基础与断言
第 13 章我们把异步程序跑通了,也踩遍了 asyncio 的坑。但「跑通」不等于「改不坏」——13.3 节那些陷阱(忘了 await、任务被吞掉)最容易在后续重构里悄悄回归。要防住回归,靠人眼复查是撑不住的,得让机器替你反复核对。这就是测试。本节先讲最基础的 pytest,下一节讲 fixture 与 mock,再下一节把它接进 CI。
14.1.1 为什么需要测试
一段没有测试的代码,它的正确性只存在于「上次我手动点过一遍」的记忆里。测试把这份记忆变成可执行的断言:任何人、任何时候跑一次,就能知道行为有没有变。它带来的不是「保证没 bug」,而是改代码时的安全感——这正是重构能持续下去的前提。
14.1.2 安装与第一次运行
pytest 不在标准库里,需要安装(版本号来自本机实测):
pip install "pytest==9.1.1"
装完后,pytest 命令即可用。写一个被测函数和一个测试文件:
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("除数不能为零")
return a / b
# test_calculator.py
from calculator import add, divide
import pytest
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide_ok():
assert divide(10, 2) == 5
def test_divide_by_zero():
with pytest.raises(ValueError, match="除数不能为零"):
divide(1, 0)
在文件所在目录直接敲 pytest:
$ pytest -q
.... [100%]
4 passed in 0.14s
四个点代表四个测试通过。你不需要写 if __name__ == "__main__",也不需要继承任何基类——这是 pytest 最舒服的地方。
14.1.3 unittest 与 pytest 的写法对比
标准库自带的 unittest 是 xUnit 风格,测试必须放进类、继承 TestCase、用 self.assertEqual 之类的方法:
import unittest
class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(2 + 3, 5)
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
(1).__truediv__(0)
两者最大的差别在断言。unittest 只能用预先定义好的 assertXxx 方法,而 pytest 让你直接用 Python 的 assert 关键字。为什么裸 assert 就够?因为 pytest 会在导入测试模块时把字节码里的断言重写,注入一份能打印中间值的版本。
| 维度 | unittest | pytest |
|---|---|---|
| 组织 | 继承 TestCase 的类 | 普通函数即可 |
| 断言 | self.assertEqual(a, b) | assert a == b |
| 失败信息 | 各方法自带格式 | 断言重写,逐元素 diff |
| 参数化 | 手写循环或子测试 | @pytest.mark.parametrize |
| 插件 | 少 | 生态丰富 |
14.1.4 自动发现规则
pytest 不会盲扫所有文件,它按固定模式收集:
- 文件名必须匹配
test_*.py或*_test.py; - 函数名必须以
test_开头; - 类名必须以
Test开头,且类里不能有__init__。
把不符合规则的文件放进目录,pytest 会直接无视。下面这个目录里只有 test_ok.py::test_one 被收集:
$ pytest --collect-only -q
test_ok.py::test_one
1 test collected in 0.04s
check_missing_prefix.py(文件名不以 test_ 开头)和 check_three(函数名不以 test_ 开头)都没进列表。发现规则是约定,不是魔法:把测试放在符合命名的文件里,剩下的交给 pytest。
14.1.5 裸 assert 与断言重写
断言重写是 pytest 最值钱的能力。写一个故意失败的列表比较:
def test_lists_equal():
got = ["apple", "banana", "cherry"]
want = ["apple", "banner", "cherry"]
assert got == want
普通 assert 只会抛一句 AssertionError,什么也不说。而 pytest 的输出是:
> assert got == want
E AssertionError: assert ['apple', 'banana', 'cherry'] == ['apple', 'banner', 'cherry']
E At index 1 diff: 'banana' != 'banner'
E Use -v to get more diff
它直接告诉你第几个元素不一样、两边各是什么。加 -vv 还能看到完整的对齐 diff:
E Full diff:
E [
E 'apple',
E - 'banner',
E ? ^^
E + 'banana',
E ? + ^
E 'cherry',
E ]
对比一下:如果关掉重写(-p no:assertion),同一段测试只剩一行光秃秃的 AssertionError,没有任何线索。这就是「裸 assert 也能用」背后的代价与收益——收益只在重写开启时才兑现,而 pytest 默认开着。
14.1.6 pytest.raises:断言异常
要测「这段代码应当抛异常」,用 pytest.raises。match= 参数接收正则,用来核对异常信息:
def test_divide_by_zero():
with pytest.raises(ValueError, match="除数不能为零"):
divide(1, 0)
想拿到异常对象本身,用 as excinfo:
def test_excinfo():
with pytest.raises(ValueError) as excinfo:
divide(1, 0)
assert "除数" in str(excinfo.value)
print(type(excinfo.value).__name__, "->", excinfo.value)
match 不匹配时会失败,并打印期望与实际:
E AssertionError: Regex pattern did not match.
E Expected regex: '不存在的文案'
E Actual message: '除数不能为零'
如果代码根本没有抛异常,pytest 也会明确报错:
E Failed: DID NOT RAISE ValueError
match 是区分大小写的正则,写 match="除数" 可以,但别把正则元字符(. * + ?)当普通字符用。
14.1.7 pytest.approx:浮点比较
浮点数不能直接用 ==。0.1 + 0.2 在二进制下并不精确等于 0.3:
def test_approx():
assert 0.1 + 0.2 == pytest.approx(0.3)
pytest.approx 默认相对误差 1e-6,也可以显式给 abs=1e-9 或 rel=1e-3。凡是比较浮点结果,一律套 approx,否则测试会随机地红。
14.1.8 标记:skip、skipif、xfail
有些测试暂时不该跑,用标记声明意图:
import sys
import pytest
@pytest.mark.skip(reason="功能尚未实现")
def test_not_ready():
assert False
@pytest.mark.skipif(sys.version_info < (3, 12), reason="需要 3.12+")
def test_version_gated():
assert sys.version_info >= (3, 12)
@pytest.mark.xfail(reason="已知 bug #42,暂未修复")
def test_known_bug():
assert 1 == 2
跑一遍看状态(-rxX 显示跳过与预期失败的原因):
test_marks.py::test_approx PASSED [ 20%]
test_marks.py::test_not_ready SKIPPED (功能尚未实现) [ 40%]
test_marks.py::test_version_gated PASSED [ 60%]
test_marks.py::test_known_bug XFAIL (已知 bug #42,暂未修复) [ 80%]
test_marks.py::test_unexpected_pass XPASS (预期失败,但居然通过了) [100%]
2 passed, 1 skipped, 1 xfailed, 1 xpassed in 0.17s
三者的区别很关键:skip 是主动不跑;skipif 按条件不跑;xfail 是跑,但预期它失败——如果它竟然通过了,会标成 XPASS(提示你「bug 可能已经修好,该摘掉这个标记了」)。默认 XPASS 不算失败,加 strict=True 可以把它变成失败。
14.1.9 测试文件放哪里:src 布局 vs 平铺
最简单的做法是让测试文件和源码同级平铺。项目一大,推荐 src/ 布局:
project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── orders.py
└── tests/
└── test_orders.py
src/ 布局强制「先安装再导入」,能避免测试意外地依赖当前工作目录。要让 from shop.orders import ... 在测试里生效,在 pyproject.toml 里加 pythonpath:
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
14.1.10 pyproject.toml 里的 pytest 配置
[tool.pytest.ini_options] 是官方推荐配置位置(旧的 pytest.ini 也还能用)。最常配的两项:
testpaths:不指定路径时去哪儿找测试;addopts:每次运行都追加的命令行参数。
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
配好之后,在项目根目录裸跑 pytest 会自动进 tests/:
collected 1 item
tests/test_smoke.py . [100%]
1 passed in 0.11s
--strict-markers 会让「用了未注册的自定义标记」直接报错,防止打错标记名却浑然不知。
14.1.11 常用命令行开关
以下开关都在本机实跑过:
| 开关 | 作用 | 实测效果 |
|---|---|---|
-v | 逐条列出用例名 | 每个 test_xxx PASSED/FAILED |
-q | 精简输出 | 只留进度点与汇总 |
-k <expr> | 按名字表达式筛选 | -k alpha → 2 passed, 2 deselected |
-x | 首个失败即停 | 1 failed, 3 passed 后停止 |
--lf | 只重跑上次失败的 | 1 failed, 3 deselected |
-s | 不捕获输出 | print 直接进终端 |
--collect-only | 只收集不运行 | 用于确认发现规则 |
-k alpha 的实际输出:
test_flags.py .. [100%]
2 passed, 2 deselected in 0.08s
--lf 依赖 .pytest_cache/ 记住上次的失败清单,非常适合「改一处、只重跑红的那几条」的循环。
小结
- pytest 用普通函数 + 裸
assert就能写测试,靠test_*.py/test_前缀自动发现。 - 断言重写是它的核心竞争力:失败时能给出逐元素 diff,这是裸
assert或unittest都换不来的。 pytest.raises(..., match=...)断言异常,pytest.approx比较浮点。skip/skipif/xfail表达「暂时不跑 / 条件不跑 / 预期失败」,XPASS提醒你该摘标记了。src/布局 +[tool.pytest.ini_options]的testpaths、addopts把「怎么跑」固化进仓库,-v/-k/-x/--lf负责日常调试。
延伸阅读:若想按主题(而非按本书教学顺序)深挖测试,可看专题 Python 测试与质量工程 ,它把 pytest、mock、覆盖率、doctest、Hypothesis 等单点集中在一处。
本节把「写一条测试」讲透了,但真实测试很少是孤立的函数——它往往要准备数据、连资源、替换外部依赖。下一节就讲 fixture、参数化与 mock 这三件让测试「可复用、可组合」的工具。
阅读导航:上一节:异步生态与常见陷阱 · 下一节:fixture、参数化与 mock 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。