1. 先把 pytest 这件事说透:它到底帮你省了什么力气
1.1 从"手动跑一遍"到"机器替我跑一百遍"
大部分 Python 小白的测试起点,是写完一段代码之后手动改参数、加print、看终端输出、用眼睛对比结果。这一步在函数只有三五行的阶段完全够用,但一旦代码长到几十个函数、参数组合达到十几种,手动验证的成本会呈指数级爆炸:改一次参数要重跑一遍脚本,改一处逻辑要回头把之前验证过的场景全部再验一次,人脑记不住上个月的边界条件,也扛不住连续两小时重复操作后的注意力下降。pytest 要解决的正是"验证动作无法复用"这个问题——它把你的验证步骤写成一个可以被反复执行、自动判定成败、失败时能立刻定位的函数,然后把"跑一遍"这件事交给机器。
我见过不少人把测试理解成"给老板看的覆盖率数字",这其实跑偏了。测试最直接的受益人是写代码的人自己:你改完一个函数,敲一行pytest,三秒钟之内就能知道有没有把别的地方改坏。这种即时反馈带来的心理安全感,是任何代码规范都换不来的。尤其是新手,写代码时最怕的不是报错,而是"看起来没报错但结果不对",pytest 恰好能把这类静默错误变成一行红色的失败记录。
所以这个内容适合谁?适合所有已经能写几十行 Python 函数、但还没有形成测试习惯的人;适合被"改 A 坏 B"折磨过的人;也适合需要把一段脚本交付给别人、希望对方能自己验证正确性的人。整套东西学下来不需要你懂设计模式,只需要你懂函数、模块和命令行,门槛比想象中低得多。
1.2 选 pytest 而不是 unittest 的四个现实理由
Python 标准库自带unittest,理论上你不需要装任何东西就能写测试。但我依然建议新手直接上 pytest,不是因为unittest不好,而是它的使用成本对新手不友好。unittest要求你把测试写成类,继承TestCase,用self.assertEqual、self.assertTrue这类专门的方法来代替 Python 原生的比较运算符。这意味着你得额外记住一套断言词汇表,写起来啰嗦,读起来也隔了一层。
pytest 的第一个优势是断言直接用assert。Python 的assert本来就是语言的一部分,你在学习基础语法时早就见过了,写assert add(1, 2) == 3没有任何心智负担。pytest 在背后对 assert 语句做了重写,失败时能打印出参与比较的具体值,这一点在后面的章节会详细讲。
第二个优势是不需要继承任何基类。一个以test_开头的普通函数就是一条用例,一个以Test开头的普通类就是一个用例组,没有任何强制继承关系。第三个优势是fixture 机制,它比setUp/tearDown灵活太多,可以按函数、按模块、按会话共享准备与清理逻辑,还能互相依赖。第四个优势是生态:并行执行、覆盖率统计、HTML 报告、随机顺序、时间冻结,几乎你能想到的测试需求都有对应的插件,安装即用。
一句话概括选型逻辑:unittest是"框架味"很重的一套约定,pytest 是"贴着 Python 原生写法走"的一套工具。新手少记一套 API,就能把精力放在"该测什么"这件更重要的事情上。
2. 环境准备:十分钟把测试骨架搭起来
2.1 安装与最省心的目录约定
安装只有一条命令,建议在虚拟环境里执行,避免污染全局解释器:
python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install pytest装完之后验证一下版本:
pytest --version接下来是目录结构。新手最容易犯的错是把测试文件和源码混在一个目录里,跑着跑着就分不清哪个是业务代码、哪个是测试。我推荐一个几乎能套用到所有小项目的结构:
my_project/ ├── src/ │ └── calc.py ├── tests/ │ ├── conftest.py │ └── test_calc.py ├── pytest.ini └── requirements.txtsrc放业务代码,tests放测试代码,两者平级。conftest.py是 pytest 的共享配置文件,后面会专门讲。pytest.ini是项目级配置,用来告诉 pytest 去哪里找测试、用什么命名规则。
注意:不要在
tests目录下手动创建__init__.py,除非你明确知道自己在做什么。很多人为了"让它像个包"而加了__init__.py,结果触发了模块同名冲突,报出import file mismatch这种让人一头雾水的错误。
2.2 第一个测试文件与三种运行方式
假设src/calc.py里有一个加法函数:
# src/calc.py def add(a, b): return a + b对应的测试文件tests/test_calc.py:
# tests/test_calc.py from src.calc import add def test_add_two_positive_numbers(): assert add(1, 2) == 3 def test_add_negative_number(): assert add(-1, 1) == 0在项目根目录执行pytest,终端会输出收集到的用例数量、执行结果和耗时。三种运行方式我都试过,各有适用场景:
| 运行方式 | 命令 | 适用场景 |
|---|---|---|
| 直接调用 | pytest | 全局装了 pytest,日常最快 |
| 模块方式 | python -m pytest | 多虚拟环境、导入路径出问题时首选 |
| 指定文件 | pytest tests/test_calc.py::test_add_negative_number | 只调试单条用例 |
这里有个很多人踩过但说不清原因的点:当你的测试出现ModuleNotFoundError时,优先改用python -m pytest。原因是python -m会把当前工作目录自动加进sys.path的最前面,而直接敲pytest依赖的是可执行脚本所在路径,虚拟环境切换频繁的机器上很容易指向错误的解释器。实测下来,这个改动能解决新手八成的导入报错。
2.3 pytest.ini 里值得先配上的四项
配置文件不是必须的,但配上之后项目会稳定很多。一个够用的最小版本:
[pytest] testpaths = tests python_files = test_*.py python_functions = test_* addopts = -v --tb=short --strict-markers逐项解释一下为什么要这么写。testpaths限定了搜索范围,避免 pytest 在虚拟环境或构建产物目录里瞎逛,大项目上这个配置能把收集时间从十几秒压到一两秒。python_files和python_functions定义了文件与函数的命名规则,虽然默认值就是这个,但显式写出来相当于给团队立了规矩。addopts里的-v让输出更详细、--tb=short让报错栈更聚焦、--strict-markers则是把所有未注册的自定义标记直接变成报错,防止拼错标记名之后静默失效。
提示:
pytest.ini、pyproject.toml、tox.ini、setup.cfg都能承载配置,但一个项目只认其中一个,且优先级从高到低。新手统一用pytest.ini最不容易出错,别在四个文件里各写一半配置。
3. 断言与用例组织:让失败信息自己说话
3.1 原生 assert 为什么在 pytest 里就够用了
这是 pytest 最容易被低估的设计。Python 的assert语句在语法层面只接受一个表达式,标准实现下失败时只会告诉你AssertionError,什么上下文都没有。pytest 在导入测试模块时,会用一个自定义的导入钩子把模块里的 assert 语句重写一遍,把参与比较的左右值、中间变量、容器内容都记录下来。所以下面这段代码失败时:
def test_user_payload(): payload = {"name": "A同学", "age": 18, "tags": ["new"]} assert payload == {"name": "A同学", "age": 18, "tags": ["old"]}pytest 会直接告诉你差异在哪一个 key、哪一个值上,而不是干巴巴一句AssertionError。这个能力带来的实际影响是:你不需要为了获得可读的失败信息而去记一堆assertEqual、assertIn、assertDictEqual。坚持用原生assert,配合 pytest 的重写机制,就是最优解。
不过有一个坑必须提前说清楚:绝对不要用python -O去跑测试。-O参数会开启优化模式,把__debug__置为 False,Python 解释器会直接把所有 assert 语句整条删掉。结果就是测试文件里的断言全部消失,用例"全部通过",你以为代码没问题,实际上什么都没验证。pytest 官方文档专门对此做了警告,我也建议你在 CI 配置里检查一遍有没有手滑带上-O。
3.2 异常、近似值与容器断言的具体写法
断言不只是==。日常最常用的还有三类场景,各有对应写法。
第一类是验证抛异常。不要用 try/except 自己包,用pytest.raises:
import pytest def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): div(1, 0) def test_invalid_input_message(): with pytest.raises(ValueError, match="must be positive"): parse_age(-1)match参数支持正则,建议至少匹配关键片段而不是整句,否则改一句文案就得同步改测试,维护成本陡增。
第二类是浮点数比较。0.1 + 0.2 == 0.3在 Python 里是 False,用pytest.approx处理:
def test_float_sum(): assert 0.1 + 0.2 == pytest.approx(0.3, rel=1e-6)rel是相对容差,abs是绝对容差。涉及金额时用绝对容差更稳,涉及科学计算时用相对容差更合适。
第三类是容器包含关系。判断某元素在列表里,直接assert item in result;判断字典子集,用循环断言或者assert expected.items() <= actual.items()。判断两个列表元素顺序无关,用sorted()包一层,别指望 assert 帮你判断集合相等。
注意:不要在 for 循环里连续写多个 assert 然后指望定位到第几个元素出错。真要批量校验,用
pytest.mark.parametrize展开成多条用例,失败时 pytest 会明确告诉你哪一组参数挂了。
3.3 用例命名与分层:写给人看的第一行文档
测试函数的命名决定了半年之后你还认不认得它。我坚持的规则是:函数名要读起来像一句话,包含被测对象、输入特征和预期结果。test_add太含糊,test_add_two_positive_ints_returns_sum才是有信息量的名字。当测试失败时,失败列表里那一行的函数名往往就是最直接的定位线索。
分层上,我一般按被测的粒度拆目录:
tests/ ├── unit/ # 纯函数、单模块,不碰网络和数据库 ├── integration/ # 多模块协作,可能读写文件或本地服务 └── e2e/ # 端到端,跑完整流程分开的价值在于执行速度差异巨大。单元测试全量跑完通常在一秒内,集成测试可能要几秒到几十秒。日常开发只跑pytest tests/unit,提交前跑全量,这种分层节奏能让测试真正融进工作流,而不是变成每次都要下决心的负担。
4. fixture:pytest 真正拉开差距的地方
4.1 一次理解作用域与生命周期
fixture 是 pytest 最核心也最容易学歪的部分。它的定位是"为测试提供前置资源和后置清理",取代了unittest里setUp和tearDown的固定配对。基本写法:
import pytest @pytest.fixture def sample_user(): return {"name": "A同学", "age": 18} def test_user_name(sample_user): assert sample_user["name"] == "A同学"关键在于:fixture 是通过参数注入的。测试函数只要在参数列表里写上 fixture 的名字,pytest 就自动把它返回的值传进来。不需要 import,不需要手动调用,这种"要什么就声明什么"的方式让依赖关系变得极其清晰。
作用域(scope)决定了 fixture 多久重新执行一次,这是性能与隔离之间的权衡:
| 作用域 | 执行时机 | 典型用途 |
|---|---|---|
function | 每条用例一次(默认) | 临时数据、mock 对象 |
class | 每个测试类一次 | 类内共享的初始化 |
module | 每个测试文件一次 | 文件级配置对象 |
package | 每个包一次 | 包级资源 |
session | 整次运行一次 | 数据库连接池、容器启动 |
新手最常见的误区是"为了快,所有 fixture 都写 session"。这在单线程下看似没问题,一旦上了并行插件就会出乱子——每个工作进程都会各自执行一次 session 级 fixture,你以为只建了一个连接,实际上建了 N 个。更严重的风险是状态污染:session 级 fixture 返回的可变对象被某条用例改脏,后面几百条用例全部受牵连,而失败信息完全指不到真正的元凶。
4.2 yield 清理与依赖注入的正确姿势
需要清理的 fixture 用yield替代return,yield之前是准备,之后是清理:
@pytest.fixture def temp_config_file(tmp_path): config = tmp_path / "config.json" config.write_text('{"mode": "test"}', encoding="utf-8") yield config config.unlink(missing_ok=True)这段代码里有两个细节值得说。第一,tmp_path是 pytest 内置 fixture,提供每个用例独立的临时目录,用完自动清理,比自己拼tempfile路径省事得多。第二,yield后面的清理代码无论用例成功还是失败都会执行,这正是它比手写 try/finally 更可靠的地方。但要注意,如果yield之前的准备阶段就抛了异常,yield之后的清理不会执行,所以准备逻辑本身要足够健壮。
fixture 之间可以互相依赖,这是它比setUp强大的第二点:
@pytest.fixture def db_conn(): conn = connect() yield conn conn.close() @pytest.fixture def user_repo(db_conn): return UserRepo(db_conn)这种链式注入自动解决了执行顺序问题,你不用操心谁先谁后,pytest 会按照依赖图拓扑排序。调试时加--setup-show参数,能看到每一步 fixture 的实际执行顺序和用时,排查"为什么这个 fixture 没跑"特别高效。
注意:作用域只能"向内收缩",不能"向外扩张"。session 级 fixture 请求 function 级 fixture 会直接抛
ScopeMismatch。设计时要先把资源分层:连接池、进程、容器用 session,每条用例的数据用 function。
4.3 conftest.py 的层级共享机制
conftest.py是 pytest 的自动加载文件。放在tests/目录下,该目录及其所有子目录的测试都能直接用里面的 fixture,不需要任何 import。这一点非常反直觉,也是新手最容易犯错的环节——很多人习惯性在测试文件顶部写from conftest import db_conn,结果要么报导入错误,要么导致 fixture 被重复注册。
它的作用域严格遵循目录层级。可以这样组织:
tests/ ├── conftest.py # session 级容器启动、全局配置 ├── unit/ │ └── conftest.py # 单元测试专用的轻量 mock └── integration/ └── conftest.py # 集成测试专用的数据库连接子目录的conftest.py可以覆盖父目录的同名 fixture,实现"默认一套、局部特化"的效果。这个特性在需要区分本地环境和流水线环境时特别好用:父目录放通用逻辑,子目录按需覆盖。
一个容易忽视的点是conftest.py的位置必须在根目录之下。如果你把它放在项目根目录,它会对所有测试生效,包括你可能不想影响的三方库测试;如果放在测试目录之外,pytest 根本不会加载它。我的做法是永远放在tests根下,职责清晰。
4.4 参数化 fixture 与间接参数化的区别
除了@pytest.mark.parametrize,fixture 本身也能参数化,用来在多套环境下跑同一批用例:
@pytest.fixture(params=["sqlite", "postgres"]) def db(request): conn = connect(request.param) yield conn conn.close() def test_insert(db): assert db.insert({"id": 1}) == 1这会让所有用到db的用例自动跑两遍,request.param拿到当前的参数值。适合"同一逻辑、多套后端"的场景。
间接参数化(indirect=True)则更进阶,它允许你把参数传给 fixture 内部处理,而不是直接传给测试函数:
@pytest.mark.parametrize("user_status", ["active", "banned"], indirect=True) def test_login(user_status): assert check_login(user_status) in (True, False)这两者的区别你要分清楚:普通 parametrize 是给测试函数换输入,fixture 参数化是给准备逻辑换环境,间接参数化是把参数"塞进"准备逻辑。选错工具会让测试结构变别扭,我一般的原则是——参数影响的是"被测行为"就用普通 parametrize,影响的是"运行环境"就用 fixture 参数化。
5. 参数化与数据驱动:把重复用例压成一行
5.1 parametrize 的写法与 id 命名
参数化是把"同一个逻辑、不同输入"的用例合并的标准手段。最基础的写法:
import pytest @pytest.mark.parametrize( "a, b, expected", [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, -100, 0), ], ids=["positive", "zeros", "opposite", "large"], ) def test_add(a, b, expected): assert add(a, b) == expected这里的ids参数我强烈建议加上。不写 id 时,pytest 会用参数值自动生成标识,遇到中文、长字符串或复杂对象时会生成一串难以阅读的内容,输出里全是test_add[param0]、test_add[param1],失败时根本不知道是哪一组。手写语义化的 id,等于给每组参数起了名字,报错时一眼就能定位。
参数化的威力在于一条失败不会影响其他参数组。上例中如果large那组挂了,其余三组照常执行、照常报结果,你不需要像循环写法那样一条挂了整段中断。而且 pytest 会为每组参数单独生成一条用例记录,配合-v输出,可读性比手写循环好太多。
一个数量上的经验值:单条用例的参数组控制在 20 组以内,超了就说明这个函数的输入空间已经复杂到需要单独建数据文件了,别硬塞在装饰器里。
5.2 测试数据该放哪:内联、文件与生成器
数据量小的时候,内联在装饰器里最直观,改起来也方便。但当参数组超过二十组,或者数据需要跟业务同事对齐时,内联就变成了维护灾难。我通常按数据规模分三档:
| 数据规模 | 存放方式 | 理由 |
|---|---|---|
| 3~10 组 | 装饰器内联 | 就近可读,改逻辑不用跳文件 |
| 10~100 组 | 独立 JSON / YAML 文件 | 可被非开发同学维护 |
| 上百组或需要组合 | 生成器 + 参数化 | 避免手写重复,可程序化覆盖边界 |
读文件的版本大概长这样:
import json import pathlib import pytest DATA_FILE = pathlib.Path(__file__).parent / "data" / "login_cases.json" def load_cases(): return json.loads(DATA_FILE.read_text(encoding="utf-8")) @pytest.mark.parametrize( "case", load_cases(), ids=lambda c: c["case_id"], ) def test_login(case): result = login(case["username"], case["password"]) assert result["code"] == case["expect_code"]ids可以接受一个函数,这里用case_id字段作为标识,输出会非常干净。这种写法的额外好处是:测试数据变成了一份可以评审的资产,业务方看 JSON 就能确认用例覆盖了哪些场景,不需要读懂 Python。
注意:数据文件的读取用
__file__相对定位,不要用相对当前工作目录的路径。否则在项目根跑和在测试目录里跑,行为会不一致,排查起来非常费时间。
6. 标记、跳过与可控执行:让长时间测试听你指挥
6.1 自定义标记的正确注册方式
当测试规模上来之后,"我只想跑跟用户模块相关的用例"就成刚需了。pytest 提供@pytest.mark.xxx来自定义分类:
import pytest @pytest.mark.slow def test_big_data_export(): ... @pytest.mark.user def test_user_profile(): ...关键在于必须在配置文件里注册:
[pytest] markers = slow: 耗时较长的用例,默认日常不跑 user: 用户模块相关 smoke: 冒烟用例,提交前必跑不注册也能跑,但会冒出一堆PytestUnknownMarkWarning。更麻烦的是,一旦标记名拼错(比如@pytest.mark.sloww),pytest 会把它当成一个全新的标记,-m slow就永远选不中这条用例,而你还以为它被正常执行了。所以前面配置里我写了--strict-markers,把未注册标记直接变成错误,从根上杜绝这类静默失败。
6.2 skip、skipif 与 xfail 的取舍
三种"不执行"的语义完全不同,搞混了会让测试报告失真。
skip是无条件跳过,适合功能已下线但用例暂时保留的情况。skipif带条件,适合按环境跳过:
import sys import pytest @pytest.mark.skipif(sys.platform == "win32", reason="该功能仅支持类 Unix 系统") def test_signal_handler(): ...xfail表示"预期会失败"。它和 skip 的本质区别是:xfail 的用例仍然会执行,执行失败算"预期内"(报告里标记为 xfailed),执行成功反而标记为 xpassed 提醒你——这个 bug 可能已经被修好了,该把标记摘掉了。这个反向提醒机制非常实用,我遇到过好几次因为 xfail 而发现修复早已生效的情况。
@pytest.mark.xfail(reason="已知问题,等待修复", strict=True) def test_legacy_export(): ...strict=True的作用是:如果用例意外通过了,直接判定为失败。这比默认的宽松模式更严谨,能强制推动你清理过期标记。
注意:不要把 skip 当成"暂时不管这个报错"的挡箭牌。被跳过的用例在报告里是绿色的,很容易被彻底遗忘。我的做法是定期用
pytest -rs把所有跳过用例的原因列出来过一遍,清理掉已经失效的。
6.3 命令行筛选:-k、-m 与失败快速定位
日常开发真正高频的是几条筛选命令:
pytest -k "login and not slow" # 按名字筛选,支持 and/or/not pytest -m "smoke" # 按标记筛选 pytest -x # 遇到第一个失败立即停止 pytest --maxfail=3 # 失败三个就停 pytest -l # 失败时显示局部变量 pytest --durations=10 # 列出最慢的十条用例-x和--maxfail在调试阶段极其好用。默认行为是跑完全部用例再汇总,但当你在改一个底层函数时,前三条用例全挂了,后面两百条的执行毫无意义,纯属浪费时间。--durations则是我每次优化测试速度的起点——先看谁慢,再看为什么慢,通常会发现是某个 fixture 在每条用例里都重建了数据库连接。
7. 常用插件与工程化落地
7.1 三个立刻能提升效率的插件
pytest 的生态是它长期占优的重要原因。下面三个是我在新项目里必装的:
| 插件 | 安装命令 | 解决的问题 |
|---|---|---|
| pytest-cov | pip install pytest-cov | 统计哪些代码没被测试覆盖 |
| pytest-xdist | pip install pytest-xdist | 多进程并行,缩短全量耗时 |
| pytest-randomly | pip install pytest-randomly | 打乱执行顺序,暴露隐藏依赖 |
覆盖率的使用方式:
pytest --cov=src --cov-report=term-missing --cov-report=htmlterm-missing这个参数特别关键,它会在终端直接列出每一行没被覆盖的代码行号,比只给一个百分比有用一百倍。至于覆盖率目标,我的建议是别追 100%。追高覆盖率会诱导人写出大量只走通路径、不断言结果的"假测试"。合理的做法是核心业务逻辑看到 80% 以上,工具函数和胶水代码放过,同时重点盯住"分支覆盖"而不是"行覆盖"——一个 if/else 只测了 if 分支,行覆盖率可能很好看,但 else 里的逻辑完全没验证。
并行执行只需要加-n auto:
pytest -n auto但这里有个必须知道的坑:session 级 fixture 在每个工作进程里都会执行一次。如果你的 fixture 负责启动一个固定端口的本地服务,并行跑就会端口冲突。解决办法是让端口随进程变化,或者干脆把这类用例排除在并行之外(用标记配合-m分离)。
随机顺序插件pytest-randomly的价值在于暴露用例之间的隐式依赖。很多新手写的测试暗含执行顺序假设——前一条用例往全局字典里塞了数据,后一条用例直接读。单独跑都能过,换个顺序就挂。随机化之后这类问题会立刻现形,而且它会打印出本次使用的随机种子,方便你复现。
7.2 与流水线的衔接:提交前跑什么
测试最终要有人跑,光靠手动执行迟早会荒废。我的实践是把检查分成三层:
第一层是本地保存时,用编辑器的保存钩子只跑当前文件的测试,响应时间控制在两秒内。第二层是提交前,跑pytest tests/unit -x -q,也就是全部单元测试、失败即停、安静输出,通常几秒完成。第三层是流水线,跑全量测试加覆盖率统计,命令大致是:
python -m pytest --cov=src --cov-report=xml --cov-fail-under=70 -n auto--cov-fail-under让覆盖率低于阈值时直接判定失败,把"别忘了写测试"这件事变成硬约束。这里我不建议一上来设 80%,新项目代码量还小的时候,覆盖率会剧烈波动,阈值太高会逼着人写凑数的测试。从 60% 起步、随着项目稳定逐步往上调,是更可持续的路径。
提示:流水线里一定要用
python -m pytest而不是裸pytest。容器环境里经常出现 pytes 装在了 A 解释器、项目代码在 B 解释器下的情况,用模块方式可以保证两者一致。
8. 常见问题与排查实录
8.1 收集不到用例:最常见的五种原因
"我明明写了测试,pytest 说 collected 0 items"——这是新手遇到最多的第一道坎。按出现频率排一下:
| 现象 | 原因 | 解决 |
|---|---|---|
| collected 0 items | 文件名不以test_开头 | 改名或配置python_files |
| collected 0 items | 函数不以test_开头 | 改名或配置python_functions |
| collected 0 items | 测试类不继承 object 但名字不以Test开头 | 类名改TestXxx |
| collected 0 items | 测试类里写了__init__方法 | 删掉,pytest 不允许测试类有构造函数 |
| 文件被跳过 | 目录不在testpaths内 | 检查配置或显式传路径 |
排查时先跑pytest --collect-only -q,它只收集不执行,能一秒看清到底抓到了哪些用例。如果连文件都没列出来,问题一定在命名或路径上;如果文件列出来了但函数没列出来,问题在函数命名或装饰器上。这个命令是我排查收集问题的第一手段,比反复猜快得多。
还有一个隐蔽情况:文件放在tests目录的子目录里,但子目录名以.开头或叫build、dist这类名字,pytest 默认会忽略。遇到"文件明明在却收集不到",先cd进那个目录单独跑一次,能快速排除路径问题。
8.2 fixture 相关报错的三类根因
fixture 'xxx' not found这个报错信息很简短,但根因至少有三种,得分开看。
第一种是拼写不一致。fixture 函数名是db_conn,测试参数写成db_con,报错里会明确写找不到db_con,这时候直接对照拼写即可。
第二种是conftest.py 位置不对。fixture 只在它所在的目录及子目录生效。如果你的conftest.py放在tests/integration/下,而用例在tests/unit/里,那就找不到。解决方式是把共用的 fixture 上提到tests/conftest.py。
第三种是作用域冲突,报错形如ScopeMismatch。function 级用例请求了 session 级 fixture 里又请求了 function 级 fixture,链路上作用域倒挂了。这类错误不会告诉你具体哪一行有问题,需要顺着 fixture 依赖链一层层往上查。用--setup-show可以看到实际尝试的执行顺序,定位效率高很多。
8.3 随机失败:最折磨人的那一类问题
比"稳定失败"更难搞的是"跑十次挂一次"。这类问题通常有四个来源:
时间依赖。测试里用了datetime.now(),某个跨日、跨月的边界会突然改变结果。解决办法是把时间抽象成一个可注入的函数,测试时传固定值;或者用时间冻结类插件把系统时间定格。
随机数依赖。代码里用了random却忘了设种子。测试开始时显式random.seed(42),或者把随机源注入进去,在测试里换成确定性实现。
顺序依赖。前面说的pytest-randomly就是干这个的。一旦随机化之后大面积失败,就说明存在共享可变状态,得回头把每条用例改成自给自足。
并发与外部资源。并行执行时多个进程抢同一个文件、同一个端口、同一张表。这类问题的特征是"本地单跑必过、流水线必挂",看到这个模式就该怀疑资源竞争。
我的处理套路是:先把种子固定下来复现,拿到稳定的失败现场,再逐步缩小范围。最忌讳的做法是"重跑一次过了就算解决",那只是把问题往后推。
8.4 排查命令速查表
下面这张表我基本是贴在显示器旁边的,遇到问题先扫一眼:
| 需求 | 命令 |
|---|---|
| 只看收集了哪些用例 | pytest --collect-only -q |
| 看 fixture 执行顺序与耗时 | pytest --setup-show |
| 看有哪些可用 fixture | pytest --fixtures |
| 看已注册的标记 | pytest --markers |
| 显示失败时的局部变量 | pytest -l |
| 只看失败摘要 | pytest --tb=line |
| 找最慢的用例 | pytest --durations=10 |
| 只看跳过的及原因 | pytest -rs |
| 显示所有用例的耗时排序 | pytest -v --durations=0 |
--tb系列参数值得单独说一句。默认的完整回溯会展开三层以上的库内部调用,对新手极其劝退。--tb=short只保留最相关的几帧,--tb=line直接压缩成一行,--tb=native则回到标准库风格。我平时用short,线上排查用line,两者结合基本够用。
9. 我在带新人时反复强调的几条经验
写测试最容易走偏的方向,是把测试写成"给代码拍张快照"——验证函数返回了某个具体值,但这个值对不对、业务上意味着什么,没人关心。我见过一个函数被测了二十遍,全都只断言"返回值不为 None",覆盖率漂亮得很,但真实场景里出过一次金额算错的线上问题,二十条用例一条都没拦住。好的测试断言的是业务含义,不是实现细节:金额要断言精确数值,状态要断言枚举值,列表要断言长度和排序规则,而不是含糊的"非空"。
第二个体会是关于测试的独立性。我接手过一个项目,三百条用例必须按字母序执行才能全绿,因为前面用例往一个模块级字典里塞了配置。这种测试的维护成本比不写还高——每次加新用例都得先搞清楚它会不会被别人影响。我的硬性要求是:任意一组用例单独跑、倒序跑、并行跑,结果必须一致。这条规则听起来苛刻,但它是判断测试是否健康的唯一标准,做不到就说明共享状态没清理干净。
第三个是关于调试效率。新手最常见的循环是"改代码、跑全量、看输出、再改"。全量跑一次三十秒,改十次就是五分钟,一天下来大半时间在等。正确的做法是先pytest -x定位第一个失败,改用例名精确定位到单条,再配合--tb=short和-l看现场。我常用的组合是pytest tests/unit/test_calc.py::test_add -x -l --tb=short,一条命令直接落到具体函数的失败现场,响应在一秒内。
最后一个建议是别贪多。刚开始写测试的人容易一次性给整个项目补测试,写到一半就没动力了。更现实的做法是从最近改动的那个函数开始,改一次就补一组测试,改三次就积累了三组。测试是随着代码演进长出来的,不是一次性工程。你今天补的这组用例,下次改这个函数时就能立刻还你回报——这种正反馈一旦建立起来,测试习惯就不需要靠意志力维持了。