pip install pytest这条命令我大概敲过几百次,每次带新人也是从它开始。很多刚学 Python 的朋友写测试的方式是这样的:在脚本末尾加一堆print,跑一遍,用眼睛比对输出,觉得对了就把 print 注释掉;或者写个if __name__ == "__main__":里塞几个assert,跑完没报错就觉得万事大吉。项目小的时候这套办法确实能用,等到函数一多、分支一复杂,改一行代码就要手动核对半小时,人就开始偷懒了——而偷懒的代价通常在两周后以线上问题的形式还回来。pytest 解决的就是这件事:它把"跑测试"从一件需要意志力的苦差事,变成一条命令、几秒钟出结果、失败原因直接摊在你面前的事。这篇内容面向刚接触 Python 测试、听说过 pytest 但还没真正跑起来的人,也适合那些写过几个测试但一直停留在assert层面、不知道怎么组织用例和公共代码的朋友。我会从最基础的项目结构讲到 fixture、参数化、配置文件和常见报错,把每一步"为什么这么做"讲清楚,让你看完当天就能给自己手上的小项目补上一套能跑的测试。
1. 小白上手 pytest 之前该想明白的三件事
1.1 手写测试脚本到底卡在哪里
我见过太多"测试脚本"长这样:一个test.py,里面从上到下几十个assert,中间夹杂print和time.sleep,跑一次要连数据库、读本地文件、还会顺手改掉测试数据。这种脚本第一天能跑,第三天就开始骗人。原因不复杂——它没有隔离、没有组织、没有反馈。隔离指的是每个用例之间互相污染,前一个用例改了全局变量,后一个用例就在错误的前提下通过;组织指的是你没法只跑其中几条,只能全量重来;反馈指的是失败时你看到的只有AssertionError,不知道是哪个输入、期望什么、实际什么。
pytest 的价值不在于它"能断言",断言 Python 自带的assert就能做。它的价值在于把用例收集、依赖准备、失败展示、批量执行这几件事标准化了。你按它的规矩命名文件,它就自动找到用例;你按它的方式声明 fixture,它就帮你准备和清理资源;你写普通assert,它在失败时自动展开两边的值做对比。这三点是小项目最需要的,也是手搓脚本最难做好的。
还有一个常被忽略的点:测试代码是要长期维护的。你写业务代码可能三个月就重构一次,测试代码却要一直跟着跑。所以"写得快"不如"改得动"。pytest 的命名约定、fixture 复用、参数化这些机制,本质上都是在降低测试代码的维护成本。
1.2 什么项目值得引入,什么项目先别急
不是所有项目都需要 pytest。判断标准很朴素:这个项目会不会被反复修改,且修改后的正确性靠人眼难以确认。一个只跑一次的数据清洗脚本、一个几十行的临时工具,你把输入输出打印出来看一眼就行了,引入测试框架反而是负担。
反过来,以下几类情况我认为值得马上补测试:一是纯函数多的模块,比如金额计算、字段校验、时间格式转换,这类代码输入输出明确,测试写起来回报率极高;二是会被多个地方调用的公共组件,改一处影响十处,没测试就是裸奔;三是有明确业务规则的逻辑,比如折扣叠加、权限判断、状态机流转,这些规则用文字写在需求文档里没人看,写成测试用例反而成了活的文档;四是修 bug 的场景,先写一个能复现 bug 的失败用例,再改代码让它变绿,这个习惯一旦养成,你会发现自己再也不会"改好了又坏回去"。
至于一个刚起步、接口天天变的原型项目,可以先放一放。等接口稳定下来,把关键路径补上测试,比一开始就追求覆盖率要实在得多。
1.3 和 unittest、doctest 的取舍
Python 生态里常被拿来对比的有三个:内置的unittest、标准库的doctest、以及第三方库pytest。
unittest是标准库自带,不用装任何东西,写法是类继承unittest.TestCase,断言用self.assertEqual、self.assertTrue这类方法。它的优势是零依赖、和 Java 的 JUnit 风格接近,很多老项目在用。劣势是样板代码多,一个最简单的用例也要定义一个类、写一个方法,而且断言方法有几十个,新人记不住,经常出现assertEqual和assertTrue(a == b)混用的情况。
doctest是把文档字符串里的交互式示例当测试跑,适合给工具函数写示例。但它对输出格式极其敏感,多一个空格就失败,不适合承载真正的业务逻辑验证。
pytest的定位是"能跑 unittest 用例,但写起来更省事"。它用普通函数加普通assert,失败信息由框架负责解释,fixture 机制解决依赖注入,插件生态覆盖覆盖率、并行、超时、mock 等常见需求。我的建议很直接:新项目直接上 pytest,老项目如果有 unittest 用例,pytest 也能一把跑起来,不需要迁移。
2. 从零跑通第一个用例:环境与目录约定
2.1 安装方式与虚拟环境的选择
安装本身只有一条命令:
pip install pytest但我强烈建议装在虚拟环境里,不要往系统 Python 里塞。原因很实际:pytest 本身没什么依赖,可它周边插件多,不同项目可能要求不同版本的插件,全局安装迟早打架。用venv就够了:
python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install pytest装完验证一下:
pytest --version能看到版本号就说明通了。如果你的机器上有多个 Python 版本,务必用python -m pytest而不是直接敲pytest,前者能保证用的是当前解释器对应的那份,后者可能指向另一个环境里的可执行文件,这个坑我在 CI 上踩过不止一次——本地跑得好好的,流水线上一片红,最后发现是 PATH 里的 pytest 属于另一个 Python。
还有一个细节:如果你的项目用了src布局(源码放在src/下),安装时建议用pip install -e .把项目装成可编辑模式,否则测试文件 import 不到你的包。这是新手最常见的第一个卡点。
2.2 命名规则:pytest 靠什么找到你的用例
pytest 的自动收集不是玄学,它有一套明确的默认规则:
- 文件名以
test_开头,或以_test.py结尾; - 文件里的函数名以
test_开头; - 类名以
Test开头,且类里不能有__init__方法。
满足这三条,pytest命令一敲就能自动找到。为什么要定这套规则而不是"扫描所有文件"?因为测试执行要快,扫描全项目会把你的业务代码也 import 一遍,触发各种副作用。约定优于配置,代价是你必须遵守命名习惯。
目录结构上,小项目我一般这么放:
myproject/ ├── src/ │ └── myproject/ │ ├── __init__.py │ └── calc.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ └── test_calc.py └── pyproject.tomltests/下放一个空的__init__.py是有讲究的:它让测试目录成为一个包,避免不同测试文件里的同名模块互相覆盖。当你有tests/test_api.py和tests/unit/test_api.py两个同名文件时,没有__init__.py就会出现"模块已存在"的诡异报错。
提示:如果你不想在每个测试目录都放
__init__.py,可以在配置里开启--import-mode=importlib,它用标准的 importlib 机制导入,能规避同名冲突。不过对小白来说,加空文件更直观。
2.3 第一个测试文件与三种运行姿势
假设calc.py里有个加法函数:
def add(a, b): return a + b对应测试文件tests/test_calc.py:
from myproject.calc import add def test_add_two_positive_numbers(): assert add(1, 2) == 3 def test_add_negative_and_positive(): assert add(-1, 1) == 0运行有三种常用姿势,用途不同:
pytest # 跑全量 pytest tests/test_calc.py # 只跑某个文件 pytest tests/test_calc.py::test_add_two_positive_numbers # 只跑某一条再补两个我天天用的参数:-v让输出列出每个用例的名字和结果,-s关闭输出捕获、让print真的打印出来。调一个失败用例时,我基本是pytest -v -s tests/test_calc.py::xxx起手,先看清楚它到底跑没跑、打印了什么。
2.4 为什么普通 assert 就够用
这是从 unittest 转过来的人最不适应的一点:不用self.assertEqual,直接assert a == b。原因在于 pytest 会重写测试模块里的 assert 语句,在失败时把两边的值展开对比。你写:
assert add(1, 2) == 4失败时看到的是:
E assert 3 == 4 E + where 3 = add(1, 2)比AssertionError三个字有用得多。对于列表、字典这类结构,它还会做逐项 diff,把哪一行不同标出来。
由此衍生出两条实用建议。第一,断言尽量写"期望值 == 实际值"这种能一眼看出差别的形式,别写assert result这种只判断真假的裸表达式,失败了完全看不出实际返回了什么。第二,一个用例里断言数量不要太多,三到五条以内为宜;如果一条用例里塞了二十条 assert,第一条失败后面的都不执行,你就得反复改、反复跑,效率极低。
3. 核心机制拆解:fixture 才是 pytest 的灵魂
3.1 fixture 解决的是什么问题
先看不用 fixture 会写成什么样。假设三个用例都要连一个临时数据库:
def test_query_user(): conn = create_conn() ... conn.close() def test_query_order(): conn = create_conn() ... conn.close()重复的初始化代码散落在每个用例里,改一次连接参数要改十处,而且很容易漏掉close。fixture 就是把这部分抽出来:
import pytest @pytest.fixture def conn(): c = create_conn() yield c c.close() def test_query_user(conn): assert conn.query("user") is not None核心逻辑是:函数参数名和 fixture 名对上,pytest 就自动调用它、把返回值注入进来。yield之前是准备,之后是清理,即使用例中途抛异常,清理代码也保证执行。这个"准备—使用—清理"的结构,把资源管理从"靠人记得写"变成"框架保证执行",是它最值钱的地方。
我第一次真正体会到 fixture 的好处,是在一个要读配置文件的模块上。以前每个用例开头都json.load一遍,后来改成 session 级 fixture 只读一次,测试总耗时从 40 秒掉到 12 秒。这不是微优化,是能用和好用之间的差距。
3.2 作用域选择与清理顺序的门道
fixture 有个scope参数,决定它多久重建一次,可选function(默认,每条用例一次)、class、module、package、session(整个测试会话一次)。
怎么选?看这个资源"重不重建会影响正确性"。数据库连接、临时文件句柄这类,每条用例重建最安全;只读的配置、启动很慢的外部客户端,用session级共享更划算。用大作用域换速度,代价是必须保证它内部状态不被用例污染——一个 session 级 fixture 如果返回的是可变对象,前一条用例往里塞了数据,后一条用例就会看到脏数据,这种 bug 排查起来极其痛苦。
清理顺序也有讲究:多个 fixture 之间存在依赖时,pytest 按照依赖关系的逆序执行清理,先进后出,跟栈一样。所以 A 依赖 B,则 B 先建、A 后建;清理时 A 先清、B 后清。理解这一点,你才不会写出"连接已经关了还在上面提交事务"的代码。
还有个小技巧:autouse=True可以让 fixture 不写在参数里也自动生效,适合做全局的日志配置、环境变量设置。但我建议慎用,一个项目里 autouse 的 fixture 超过两三个,新人就很难搞清楚用例到底依赖了什么,可读性会明显下降。
3.3 parametrize:一条用例覆盖一组输入
参数化是我认为性价比最高的特性。同一个逻辑要验证十组输入,不用参数化就得写十个函数,用了参数化只要一个:
import pytest @pytest.mark.parametrize("a, b, expected", [ (1, 2, 3), (0, 0, 0), (-1, -1, -2), (10**18, 1, 10**18 + 1), ]) def test_add(a, b, expected): assert add(a, b) == expected执行时每条数据独立成一条用例,失败只影响对应那条,其他照跑。输出里会带上[1-2-3]这样的 id,一眼能看出是哪组数据挂的。想更可读可以加ids=["正整数", "零值", "负数", "超大数"]。
这里有个我踩过的坑:参数化传可变对象要小心。如果你在参数表里写了一个列表,所有用例共享的是同一个列表对象,某条用例原地修改了它,后面的用例就受影响了。解决办法是传工厂函数,或者在 fixture 内部深拷贝一份。
另外一个常被忽视的问题是边界值。新手参数化往往只写"正常输入",一通全绿,上线后炸在空字符串和None上。我的习惯是每组参数至少包含一个下界、一个上界、一个非法值,宁可多写三行,别让测试变成"假装很安全"。
3.4 conftest.py 的共享边界
多个测试文件都要用同一个 fixture 怎么办?放在conftest.py里。这个文件不用 import,pytest 会自动发现,作用范围是它所在目录及其子目录。
这就引出一个重要的组织原则:fixture 放在最靠近使用者的那一层。只给tests/unit/用的 fixture 就放tests/unit/conftest.py,别一股脑全塞根目录。根目录的 conftest 会变成事实上的"全局变量表",谁都能改、谁都不敢动,两年后没人说得清里面哪个 fixture 还在被使用。
还有个细节:conftest.py里不要写from conftest import xxx。pytest 是以插件方式加载它的,重复 import 会导致同一个模块被加载两次,出现 fixture 找不到或者PluginValidationError的怪问题。需要共享的普通工具函数,老老实实放到一个utils.py里被 import。
3.5 配置文件:把常用参数固定下来
每次都敲一长串参数不现实,配置写在pyproject.toml里(新项目推荐)或者pytest.ini、setup.cfg里。以pyproject.toml为例:
[tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --strict-markers --tb=short" markers = [ "slow: 运行较慢的用例", "integration: 需要外部依赖的集成用例", ]testpaths限定了默认扫描范围,避免 pytest 把项目根的临时脚本也当成测试;--strict-markers让未注册的 marker 直接报错,防止手滑打错@pytest.mark.slwo却静默通过;--tb=short精简回溯信息,长堆栈里只留有用的几帧。
marker 的用法是这样的:
@pytest.mark.slow def test_heavy_computation(): ...然后pytest -m "not slow"就能跳过慢用例。这套机制在 CI 里特别有用——提交时只跑快用例,合并前再跑全量。没有 marker 之前,我们的集成测试和单元测试混在一起,每次本地跑都要等半分钟,人是会烦的,烦了就会跳过测试,跳过测试就等于没有测试。
4. 拿一个小项目把流程走一遍
4.1 被测算例的代码
光看概念容易飘,我拿一个虚构的小模块走一遍完整流程。假设要做一个简单的优惠计算:
# src/shop/discount.py from decimal import Decimal, ROUND_HALF_UP def calc_total(price, count, coupon=None): if price <= 0 or count <= 0: raise ValueError("price and count must be positive") raw = Decimal(str(price)) * count if coupon: raw = raw - Decimal(str(coupon)) if raw < 0: raw = Decimal("0") return raw.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)这段代码里有几个明确的验证点:正常乘法、优惠券抵扣、抵扣后不能为负、金额保留两位小数、非法参数抛异常。这五行就是测试要覆盖的核心。
顺带说下为什么要用Decimal而不是浮点。0.1 + 0.2在浮点里是0.30000000000000004,涉及金额时这类误差会累积成实际的对账差异。测试里如果还用浮点断言,就会出现assert 0.3 == 0.30000000000000004这种莫名其妙的失败。这不是 pytest 的问题,是数据类型选错了。
4.2 分层写用例
第一层测正常路径,第二层测边界,第三层测异常:
import pytest from shop.discount import calc_total def test_normal_total(): assert calc_total(10, 3) == pytest.approx(30.00) @pytest.mark.parametrize("price, count, coupon, expected", [ (100, 1, 20, 80), (10, 1, 20, 0), # 抵扣超额,兜底为 0 (0.1, 3, None, 0.30), # 小数精度 ]) def test_total_with_coupon(price, count, coupon, expected): assert calc_total(price, count, coupon) == pytest.approx(expected) @pytest.mark.parametrize("price, count", [(0, 1), (10, 0), (-1, 5)]) def test_invalid_args_raise(price, count): with pytest.raises(ValueError): calc_total(price, count)三个细节值得说。第一,pytest.approx用于浮点比较,容忍极小误差,比手写abs(a - b) < 1e-9省事且意图明确。第二,pytest.raises不仅能断言抛了什么异常,还能断言异常信息:with pytest.raises(ValueError, match="positive"),这样别人改了报错文案你会立刻知道,避免测试变成"只要抛错就算过"的空壳。第三,参数化的用例名[0-1]看着像乱码,加ids会好很多,尤其是失败通知发到群里的时候。
4.3 利用率与覆盖率的正确看法
装个覆盖率插件:
pip install pytest-cov pytest --cov=src --cov-report=term-missingterm-missing会把没被执行到的行号列出来,这是它最有用的地方——不是看那个百分比数字,而是看哪些分支一次都没跑过。我一般先盯着 missing 列表,把明显的漏测分支补上,再看数字。
关于阈值,我的看法是别一开始就设 90%。一个刚补测试的项目,先把核心逻辑覆盖率做到 60% 到 70%,剩下的往往是异常处理、日志分支、防御性代码,写测试的成本高、收益低。等核心逻辑稳定了再逐步提高。硬性要求 100% 覆盖率通常会催生一批"为了覆盖而覆盖"的测试,比如调用一下函数但不做任何断言,这种测试除了让数字好看,没有任何防护作用。
4.4 三到五个够用就好的插件
插件生态是 pytest 的一大优势,但小白阶段不需要装一堆。以下四个我认为按需安装即可:
| 插件 | 解决什么问题 | 什么时候装 |
|---|---|---|
| pytest-cov | 统计覆盖率、定位漏测行 | 想量化测试充分性时 |
| pytest-xdist | 多进程并行跑用例 | 全量执行超过 30 秒时 |
| pytest-mock | 提供 mocker fixture,替代手写 patch | 需要隔离网络或数据库调用时 |
| pytest-timeout | 给用例设超时,防止挂死 | 涉及网络、子进程的用例 |
pytest-xdist的用法是pytest -n auto,auto会按 CPU 核数决定进程数。这里有个经验:进程数不是越多越好。如果用例之间有共享资源(比如都写同一个临时文件、都连同一个本地数据库),并行反而会互相干扰,出现只在并行时偶发的失败。我的做法是单元测试用并行,涉及外部资源的集成测试加 marker 单独串行跑。
pytest-mock值得展开说一句。它提供的是mocker.patch,本质是unittest.mock.patch的封装,好处是打补丁的作用域自动跟随用例结束而恢复,你不用手动stop。新人最容易犯的错是 patch 了错误的目标——记住一句口诀:patch 在使用它的地方,而不是定义它的地方。比如模块 A 里from requests import get,那么要 patch 的是A.get,不是requests.get。
5. 报错现场:新手最容易撞的六种情况
5.1 用例明明写了却一条都没跑
终端输出collected 0 items是什么感觉,我懂。排查顺序按这个来:先确认文件名和函数名符合test_前缀规则,这是九成的原因;再看是不是放在了testpaths指定的目录之外;最后检查有没有在类里写了__init__方法,pytest 会直接跳过带构造函数的测试类。
还有一种隐蔽情况:你的测试文件 import 了某个模块,而那个模块在导入时抛了异常,pytest 会把这个文件报成 error 而不是 fail,输出被淹没在滚动日志里。用pytest --collect-only -q能快速看出到底收集到了什么,比瞎猜快得多。
5.2 fixture 找不到的三种典型报错
fixture 'xxx' not found这个报错,原因无非三类。一是名字拼错,注意是函数参数名和 fixture 名精确匹配,不区分大小写之外没有任何模糊匹配。二是作用域不对,定义在tests/unit/conftest.py的 fixture,在tests/integration/下用不了,fixture 的可见范围是所在目录及子目录。三是循环依赖,A 依赖 B、B 又依赖 A,pytest 检测到会直接报错,这时候通常是设计问题,把公共部分抽成第三个 fixture 就能解开。
顺带提一句@pytest.fixture()和@pytest.fixture的区别:加不加括号都行,加了括号可以传参数。我建议统一加括号,以后想加scope参数不用改语法。
5.3 失败信息看不全、print 不显示
assert失败时只输出一行怎么办?先加-vv,它会把更详细的 diff 打出来。还是不够就上--tb=long,把完整回溯展开。反过来,如果回溯太长刷屏,用--tb=short或者--tb=line只留关键帧。
print不显示是因为 pytest 默认捕获输出,加上-s就放开了。但我不建议长期依赖 print 调试,更好的两个选择是assert里带上说明信息(assert result == expected, f"输入是 {x}"),或者用capsysfixture 断言函数确实输出了预期内容——后者对命令行工具类代码特别有用。
5.4 常见问题速查表
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
collected 0 items | 命名不符合约定 | 检查test_前缀与目录位置 |
fixture not found | 作用域或拼写问题 | 检查 conftest 位置与参数名 |
ModuleNotFoundError | 包未安装或路径不对 | pip install -e .,检查__init__.py |
| 用例顺序执行时通过、单独跑失败 | 用例间存在状态污染 | 用 fixture 隔离,禁止共享可变全局 |
| 并行时随机失败 | 共享临时文件或端口 | 用tmp_path生成独立目录 |
| 断言浮点数不相等 | 浮点精度 | 改用pytest.approx或Decimal |
| 覆盖率虚高但没测到分支 | 只调函数不断言 | 补断言,看term-missing行号 |
| 测试很慢 | 重复初始化重资源 | 提升 fixture 作用域,或按 marker 分组 |
这张表我自己贴在项目 wiki 上,新人问的问题八成都能在里面找到答案。补充两个表里没写的招:pytest --lf只重跑上次失败的用例,调 bug 时省时间;pytest --ff先跑失败的再跑其他的,同理会快不少。还有tmp_pathfixture,它给每条用例一个独立的临时目录,用完自动清理,凡是涉及写文件的测试都该用它,别自己拼/tmp/xxx,并行时必冲突。
6. 几条我认为最值钱的经验
6.1 测试命名比测试代码更重要
我审测试代码时最先看的是函数名。test_1、test_calc、test_ok这类名字等于没写,半年后失败通知弹出来,你完全不知道它想验证什么。好的命名应该是一句能读通的断言,比如test_coupon_larger_than_total_returns_zero、test_empty_username_raises_value_error。读函数名就知道预期行为,这是测试作为"活文档"的前提。
还有一个具体做法:用例内部按"准备—执行—断言"三段写,中间空行隔开。不是为了好看,是为了让你一眼看出哪部分是构造数据、哪部分是真正被验证的行为。如果一段测试的准备代码超过了十行,通常意味着这个函数职责太重,该考虑重构了——测试写起来费劲,往往是设计的信号。
6.2 别把测试写成第二套业务代码
这是我最想强调的一条。有些人的测试里写了一大堆条件判断、循环、辅助函数,最后测试代码比业务代码还长,还难懂。问题在于:测试代码本身也需要被信任,如果测试逻辑复杂到要怀疑它,那它就失去了意义。
判断标准很简单:测试里应该几乎没有if和for(参数化除外)。需要分支就用多个用例表达,需要循环就用parametrize。期望值必须是硬编码的常量,不能是"用同样的算法再算一遍"——那等于自己证明自己,算法错了测试也跟着错,这种测试叫做"同义反复",一点防护作用都没有。
再说 mock 的边界。我见过把数据库、HTTP 请求、时间、随机数全部 mock 掉的测试,跑起来飞快,全绿,但线上照样出问题。原因是 mock 描述的是"我以为的外部行为",一旦真实依赖的行为和假设不一致,测试就是自欺欺人。我的原则是:核心业务逻辑用真实对象测,只在跨网络、跨进程、不可控的时间随机这几处做替身,而且替身的返回值要尽量贴近真实响应。
6.3 让测试跑得足够快,人才会真的去跑
最后说一个很现实的问题:测试的价值取决于它被执行的概率。一套跑一次要五分钟的测试,开发者只会在提交前跑,日常改代码时不会碰;一套十秒跑完的测试,改三行就能顺手跑一次,问题在产生的当下就被发现。
提速的手段按性价比排序:第一,把慢的集成测试用 marker 分离,日常只跑单元测试;第二,用 session 级 fixture 复用重资源,别每条用例都重连一遍;第三,需要并行的上-n auto,但要注意共享资源的隔离;第四,检查是不是有不必要的sleep,很多等待可以用轮询替代。做完这四步,一个中等规模项目的测试从几分钟压到十几秒是常态。
补一句关于持续集成的建议:本地跑得再勤,也得有一条自动执行的兜底。每次推送自动跑一遍全量测试,失败了第一时间通知到人。我见过太多"本地全绿、主干红了一周没人管"的情况,原因就是没有人被明确告知失败。测试跑起来只是第一步,让它持续被跑、被看见,才算真正建立起防护。