面向对象:Python 开发者、工业数采/后端/数据链路测试工程师 配套语言:Python 3.8+(推荐 3.11+)
1. 背景:为什么是 pytest
1.1 Python 测试生态的历史痛点
Python 标准库自带 unittest(xUnit 风格),但工程实践中有四类长期痛点:
| 痛点 | unittest 表现 | 工程后果 |
|---|---|---|
| 样板代码 | 必须写类 + 方法 + setUp/tearDown | 小型测试也要 10+ 行骨架 |
| 断言能力 | assertEqual/assertTrue/assertRaises 命名割裂 | 记忆成本高、失败信息弱 |
| 参数化 | 依赖 subTest 或手写循环 | 用例可读性与失败定位差 |
| 插件/生态 | 无统一扩展点 | 覆盖率、Mock、异步各自为政 |
pytest 的设计哲学是「纯函数式收集 + 断言重写 + fixture 依赖注入 + 钩子插件体系」,一次解决以上问题:
- 零样板:一个普通函数 + assert 即测试用例;
- 断言自省:assert x == y 失败时自动展开表达式细节,不需要 assertEqual 之类专用断言;
- fixture 依赖注入:参数名即依赖声明,作用域/销毁/复用由框架管理,替代 setUp/tearDown;
- mark 标记体系:skip/xfail/parametrize/timeout/自定义标签;
- 插件生态:pytest-cov、pytest-mock、pytest-asyncio、pytest-xdist 等数百个插件,钩子函数可深度定制;
- 与 unittest 兼容:unittest.TestCase 子类可直接被 pytest 收集执行。
运行时最强、编译期最弱的一环:没有类型系统辅助,但断言自省与 fixture 注入大幅降低编写成本,尤其适合数据管道、接口、配置类测试。
2. 核心概念与设计哲学
2.1 五个核心心智模型
- 收集(Collection):pytest 按目录/文件/函数名规则自动发现测试,test_*.py 文件、test_* 函数、Test* 类中的 test_* 方法;
- 断言重写(Assert Rewriting):pytest 在导入测试模块时用 AST 改写 assert 语句,失败时展示操作数与原因,而非裸 AssertionError;
- fixture 依赖注入:测试函数参数名匹配 fixture 名,框架负责创建/缓存/销毁,支持 scope 与 autouse;
- mark 标记:元数据标签驱动行为(跳过、预期失败、参数化、超时、分组);
- 钩子(Hook):conftest.py 中定义 pytest_* 钩子函数可介入收集、执行、报告全流程。
2.2 与 unittest 的对照迁移表
| 概念 | unittest | pytest |
|---|---|---|
| 用例 | class TestX(unittest.TestCase) + 方法 | 任意 def test_*() 函数 |
| 初始化/清理 | setUp/tearDown | fixture yield(前/后两段) |
| 断言 | self.assertEqual(a, b) | assert a == b |
| 异常断言 | assertRaises(Exc, fn) | pytest.raises(Exc) 上下文管理器 |
| 参数化 | subTest | @pytest.mark.parametrize |
| 跳过 | @unittest.skip | @pytest.mark.skip |
| 临时目录 | 手写 | tmp_path fixture |
| Mock | unittest.mock | unittest.mock(monkeypatch 或 pytest-mock) |
3. API 说明
3.1 命令行(CLI)API
pytest # 收集当前目录并运行 pytest tests/test_foo.py # 指定文件 pytest -k "login and not slow" # 表达式过滤用例名 pytest -m smoke # 按 mark 过滤 pytest -x # 首个失败即停 pytest --lf --last-failed # 只跑上次失败 pytest --ff # 失败优先 pytest -p no:cacheprovider # 禁用缓存插件 pytest -s # 显示 print 输出(不捕获) pytest --tb=short/long/native # 回溯模式 pytest --cov=src --cov-report=term-missing # 覆盖率(需 pytest-cov) pytest -n 4 # 并行(需 pytest-xdist) pytest --maxfail=2 # 最多失败 N 个后停止 pytest --collect-only # 只收集不执行 pytest --setup-show # 展示 fixture 实例化/销毁顺序3.2 fixture 体系
| API | 说明 |
|---|---|
| @pytest.fixture(scope=..., autouse=..., params=..., ids=...) | 声明 fixture;scope ∈ function(默认)/class/module/package/session |
| yield 模式 | yield 前为 setup,yield 后为 teardown;yield 可返回值 |
| request 内置 fixture | 访问 request.param(参数化 fixture)、request.module/request.session、request.addfinalizer |
| tmp_path / tmp_path_factory | 每测试唯一临时目录(pathlib.Path);factory 可用于 session 级 |
| monkeypatch | setattr/setenv/delattr/delenv/setchmod/undo,测试结束自动还原 |
| capsys / capfd | 捕获 stdout/stderr(capsys.readouterr()) |
| caplog | 捕获 logging 记录,caplog.at_level() / caplog.records |
| recwarn | 捕获 warnings |
| pytestconfig | 访问命令行配置与 ini 选项 |
| cache | 跨运行缓存(cache.get/set),供 --lf 使用 |
fixture 解析规则:同名覆盖(参数名 = fixture 名);同 scope 同参数同一测试会话内共享实例;autouse=True 无需显式声明即注入;依赖可嵌套(fixture 依赖 fixture)。
3.3 mark 标记体系
| mark | 用途 |
|---|---|
| @pytest.mark.parametrize("a,b", [(1,2),(3,4)]) | 参数化,支持 ids 定制用例名,支持 indirect=True 注入 fixture |
| @pytest.mark.skip(reason=...) | 无条件跳过 |
| @pytest.mark.skipif(condition, reason=...) | 条件跳过 |
| @pytest.mark.xfail(strict=..., raises=..., reason=...) | 预期失败;strict=True 时意外通过算失败 |
| @pytest.mark.timeout(5) | 超时(需 pytest-timeout) |
| @pytest.mark.asyncio | 异步测试(需 pytest-asyncio / anyio) |
| @pytest.mark.usefixtures("fix") | 仅注入 fixture 不取返回值 |
| @pytest.mark.filterwarnings("error") | 把警告升级为错误 |
自定义 mark 需在 pytest.ini/pyproject.toml/conftest.py 注册:
# pytest.ini [pytest] markers = smoke: 冒烟用例 regression: 回归用例3.4 断言与异常 API
| API | 说明 |
|---|---|
| pytest.raises(Exc, match=...) | 断言抛出异常;match 正则匹配异常信息;上下文内可访问 excinfo.value |
| pytest.warns(Warning, match=...) | 断言警告 |
| pytest.fail(reason, pytrace=True) | 显式失败 |
| pytest.skip(reason) / pytest.xfail(reason) | 运行中跳/预期失败 |
| pytest.approx(expected, rel=, abs=) | 浮点近似断言(NaN/±inf 处理) |
| pytest.deprecated_call() | 断言弃用警告 |
| pytest.exit() | 立即退出测试会话 |
3.5 conftest.py 与钩子函数
conftest.py 是目录级配置中心,向下递归生效;可定义 fixture、插件注册、钩子函数。常用钩子:
| 钩子 | 触发时机 |
|---|---|
| pytest_configure(config) | 会话启动配置(注册 mark、自定义 ini 项) |
| pytest_collection_modifyitems(session, config, items) | 收集完成后改动用例(排序、按 mark 分组) |
| pytest_runtest_setup/call/teardown(item) | 每个用例执行前/中/后 |
| pytest_addoption(parser) | 添加自定义命令行参数 |
| pytest_fixture_setup/fixture_post_finalizer | fixture 创建/销毁钩子 |
| pytest_report_teststatus / pytest_terminal_summary | 定制输出 |
4. 详细使用说明(8 个可运行示例)
4.1 最小可运行示例:函数式断言
# tests/test_math.py def test_add(): assert 1 + 1 == 2 def test_list_contains(): fruits = ["apple", "banana"] assert "apple" in fruits def test_dict_key(): cfg = {"host": "192.168.1.10", "port": 502} assert cfg["port"] == 502运行 pytest tests/test_math.py -v,失败时 pytest 展示左右操作数与完整 diff。
4.2 fixture:工业数采「连接器」复用(module 级)
模拟一个 Modbus/MC 协议采集客户端的连接生命周期:
# tests/conftest.py import pytest @pytest.fixture(scope="module") def plc_conn(): """每个模块共享一个伪 PLC 连接""" print("\n[setup] 建立连接") conn = {"connected": True, "calls": 0} yield conn # 测试期间可用的对象 print("\n[teardown] 关闭连接") conn["connected"] = False @pytest.fixture(autouse=True) def trace(request): """autouse:每个用例自动执行,用于计时/标记""" start = time.perf_counter() yield print(f"{request.node.name} 耗时 {time.perf_counter() - start:.4f}s")# tests/test_conn.py def test_read_holding_register(plc_conn): plc_conn["calls"] += 1 assert plc_conn["connected"] assert plc_conn["calls"] == 1 def test_read_again(plc_conn): plc_conn["calls"] += 1 assert plc_conn["calls"] == 2 # module 级共享,验证状态延续4.3 参数化:协议帧解析
import pytest def parse_frame(data: bytes) -> dict: """模拟 MC 协议 3E 帧响应解析:固定头 9 字节 + 结束代码 2 字节 + 数据""" if len(data) < 11: raise ValueError("帧过短") return {"len": len(data), "end_code": int.from_bytes(data[9:11], "little")} @pytest.mark.parametrize( "payload,expected", [ (b"\xD0\x00" + b"\x00" * 7 + b"\x00\x00" + b"\x01\x02", {"len": 13, "end_code": 0}), (b"\xD0\x00" + b"\x00" * 7 + b"\x00\x00" + b"\x01\x02\x03\x04", {"len": 15, "end_code": 0}), ], ids=["正常短帧", "正常长帧"], ) def test_parse_frame_ok(payload, expected): assert parse_frame(payload) == expected def test_parse_frame_too_short(): with pytest.raises(ValueError, match="帧过短"): parse_frame(b"\xD0\x00\x00")4.4 monkeypatch + capsys:替换外部依赖
import time def send_to_kafka(topic: str, data: dict) -> str: # 真实实现会连 Kafka,测试时不希望触发网络 raise NotImplementedError def collect_and_send(value: int) -> str: payload = {"value": value, "ts": time.time()} return send_to_kafka("plc.data", payload) def test_collect_and_send(monkeypatch, capsys): fake_calls = [] def fake_send(topic, data): fake_calls.append((topic, data)) return "ok" monkeypatch.setattr("tests.test_app.send_to_kafka", fake_send) result = collect_and_send(42) assert result == "ok" assert fake_calls[0][0] == "plc.data" assert fake_calls[0][1]["value"] == 424.5 临时目录与文件:CSV 落盘测试
def write_csv(path, rows): with open(path, "w") as f: f.write("ts,value\n") for r in rows: f.write(f"{r[0]},{r[1]}\n") def test_write_csv(tmp_path): target = tmp_path / "points.csv" write_csv(target, [(1700000000, 1.5), (1700000001, 2.5)]) assert target.exists() content = target.read_text() assert "ts,value" in content assert content.count("\n") == 34.6 异步测试:pytest-asyncio
import pytest async def fetch_ok(url: str) -> int: return 200 @pytest.mark.asyncio async def test_async_fetch(): code = await fetch_ok("http://fake") assert code == 200 # 或使用 anyio 风格: # @pytest.mark.anyio # async def test_async_anyio(): ...配置(pyproject.toml):
[tool.pytest.ini_options] asyncio_mode = "auto" # 自动把 async 测试函数当作异步执行4.7 数据库/数据链路测试:内存 SQLite 与事务隔离
import sqlite3 @pytest.fixture def db(tmp_path): conn = sqlite3.connect(tmp_path / "test.db") conn.execute("CREATE TABLE points(ts INTEGER PRIMARY KEY, value REAL)") yield conn conn.close() def test_insert_point(db): db.execute("INSERT INTO points VALUES (?, ?)", (1, 1.5)) assert db.execute("SELECT COUNT(*) FROM points").fetchone()[0] == 1 def test_count_isolated(db): # 每个用例独立 tmp_path -> 自动隔离,无相互污染 assert db.execute("SELECT COUNT(*) FROM points").fetchone()[0] == 04.8 工业数采场景综合示例:采集函数回归测试
import pytest from pytest import approx def normalize_channel(raw: bytes, span: int) -> list[float]: """把 16bit 原始采集值归一化到 [0,1]""" out = [] for i in range(0, len(raw), 2): v = int.from_bytes(raw[i:i+2], "big", signed=False) out.append(round(v / 65535, 4)) return out[:span] @pytest.mark.parametrize("raw,span,expected", [ (b"\x00\x00", 1, [0.0]), (b"\x7f\xff", 1, [approx(0.5, abs=1e-4)]), (b"\xff\xff", 1, [approx(1.0)]), (b"\x00\x00\x80\x00\xff\xff", 2, [0.0, approx(0.5, abs=1e-4)]), ]) def test_normalize(raw, span, expected): assert normalize_channel(raw, span) == expected def test_normalize_span_limit(): raw = b"\x00\x00\x80\x00\xff\xff" assert len(normalize_channel(raw, 1)) == 15. 底层实现剖析
5.1 收集器(Collector)流水线
关键点:收集是导入驱动的,模块顶层代码会真实执行,因此不要在测试模块顶层做重活(网络、数据库、长循环),否则 --collect-only 都会卡住。
5.2 断言重写机制
pytest 通过 AssertionRewritingHook(导入钩子)改写测试模块中的 assert:
assert x == y # 被改写成近似: if not x == y: from _pytest.assertion.util import _assert_eq_actual raise AssertionError(_assert_eq_actual(x, y))失败信息里能展示左右值、in/not in、is、比较链、函数调用的展开结果。代价:测试模块的字节码被改写,因此:
- 不要在测试模块中依赖 __file__ 外的 linecache 精确行为(有兼容处理但要注意);
- 性能敏感断言仍可关闭重写(--assert=plain)。
5.3 fixture 解析与作用域缓存
fixture 由 FixturesManager 维护一棵依赖图:按参数名解析 → 拓扑排序 → 按 scope 分层缓存 → 用例执行完按 LIFO 逆序 finalize。同 scope 缓存 key 由 (fixture 名, 参数) 组成,session 级 fixture 只创建一次。
5.4 执行与报告
- 每个测试 Item 包在 CallInfo 状态机里:setup/call/teardown 各自收集 outcome;
- 失败/跳过/xfail 由 Outcome 机制统一上报;
- -x / --maxfail 通过全局 Session 计数器实现;
- --lf 依赖 cache 插件将上次失败用例 ID 写入 .pytest_cache/v/cache/lastfailed。
6. 常错点/坑(22 条)
| 坑 | 现象 | 解决 | |
|---|---|---|---|
| 1 | fixture 参数名拼错 | fixture 'xx' not found | fixture 名 = 参数名;检查 conftest 导入路径 |
| 2 | fixture scope 误用 | 状态跨用例污染 | 明确 function/module/session 语义 |
| 3 | yield fixture 忘 yield | fixture 返回 None 或语法错误 | 需要返回值时 yield obj |
| 4 | teardown 代码放错位置 | yield 之后的代码才是 teardown | 确保 teardown 在 yield 后 |
| 5 | assert 字符串拼接比较 | 失败信息不直观 | 直接 assert a == b 让重写器展开 |
| 6 | 用 == 比浮点 | 偶发失败 | pytest.approx |
| 7 | 测试顶层做网络/数据库 | --collect-only 卡死 | 顶层只放导入与常量 |
| 8 | fixture 返回可写全局共享对象 | 测试间串数据 | 每次返回新对象 / 用 factory fixture |
| 9 | tmp_path 误当 str 用 | TypeError | 它是 pathlib.Path,用 str(p) 转 |
| 10 | monkeypatch 修改生产模块路径写错 | 补丁不生效 | 必须 patch使用处的名字(app.module.send_to_kafka),不是定义处 |
| 11 | 忘记 monkeypatch.undo(异常路径) | 环境残留 | fixture 自动还原,但不要在测试中手动 sys.modules 乱改 |
| 12 | 参数化对象无法 == | 失败信息不可读 | 提供 ids,对象定义 __eq__/__repr__ |
| 13 | xfail 不设 strict | 意外通过不报错 | 能确定必失败用 strict=True |
| 14 | 自定义 mark 未注册 | PytestUnknownMarkWarning | 在 ini 或 pytest_configure 注册 |
| 15 | -k 表达式写错 | 用例被意外过滤 | 表达式支持 and/or/not,用 -k "a and b" 加引号 |
| 16 | 异步测试忘装插件 | 用例被当作普通函数返回协程(PASSED 假象) | 装 pytest-asyncio/anyio,正确用 mark |
| 17 | 捕获断言日志用 print | 看不见 | 用 capsys/caplog,或 -s |
| 18 | 断言 warnings 不设 match | 规则过宽 | 尽量 match= 精确匹配 |
| 19 | 大量 fixture 依赖链深 | 定位慢 | --setup-show 查看实例化顺序 |
| 20 | 测试文件名不以 test_ 开头 | 不被收集 | 用 python_files ini 项扩展 |
| 21 | conftest 放错层级 | fixture 不可见 | conftest 对当前目录及子目录生效,父级不向上 |
| 22 | 并行(xdist)与共享文件冲突 | 偶发失败 | 并行时用 tmp_path_factory.mktemp 唯一目录,避免写共享路径 |
7. 性能优化与测试工程实践
7.1 加速策略清单
- 最小化 fixture scope:session 级尽量少,避免大对象常驻;
- 按需参数化:参数组合爆炸用 ids + -k 过滤运行子集;
- 并行执行:pytest-xdist -n auto(注意共享资源隔离);
- 缓存复用:session 级数据库/连接在 CI 与本地都受益;
- 失败优先:--lf 快速重跑失败,--ff 失败先行;
- 跳过重活:网络/外部服务用 pytest-timeout + skipif 环境标记。
7.2 工程规范建议
# pytest.ini [pytest] testpaths = tests addopts = -q --strict-markers --tb=short --maxfail=5 markers = smoke: 冒烟 slow: 慢测试 filterwarnings = error::DeprecationWarning# pyproject.toml(现代项目推荐) [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q --strict-markers" asyncio_mode = "auto"7.3 CI 集成要点
- GitHub Actions / 流水线中 pip install -e .[test] + pytest --cov=src --cov-report=xml;
- 上传 coverage.xml 到质量平台做增量门禁;
- 分 job 跑 smoke 与 slow 两类用例,冒烟先跑、全量兜底。
8. 插件生态速览与选型
| 插件 | 用途 | 安装 |
|---|---|---|
| pytest-cov | 覆盖率统计(term/xml/html 报告) | pip install pytest-cov |
| pytest-mock | 提供 mocker fixture 包装 unittest.mock | pip install pytest-mock |
| pytest-asyncio / anyio | 异步测试支持 | pip install pytest-asyncio |
| pytest-xdist | 多进程/多机并行 | pip install pytest-xdist |
| pytest-timeout | 用例级超时 | pip install pytest-timeout |
| pytest-order | 用例执行顺序控制 | pip install pytest-order |
| pytest-html | HTML 报告 | pip install pytest-html |
| pytest-benchmark | 基准对比断言 | pip install pytest-benchmark |
| pytest-django / pytest-flask | Web 框架集成(Fixture/Client) | pip install pytest-django |
选型建议:默认五件套pytest + pytest-cov + pytest-mock + pytest-asyncio + pytest-xdist 覆盖绝大多数 Python 工程;性能敏感项目加 pytest-benchmark;Web 项目按框架选集成插件。
9. 总结
pytest 以「函数式用例 + 断言重写 + fixture 注入 + 钩子插件」四支柱,成为 Python 生态事实标准的测试框架。核心收获五条:
- 用例即函数:def test_xxx() + assert,收集零配置;
- fixture 替代 setUp/tearDown:作用域 + 依赖注入 + yield 分段,状态管理清晰;
- 断言重写让失败可读:assert a == b 自动展开 diff;
- mark 与插件生态:skip/xfail/parametrize/异步/覆盖率/并行开箱即用;
- 工程化配套:ini/pyproject 配置、CI 集成、--lf/--ff/-x 快速迭代。
与 C++ gtest/Catch2、Go testing 相比,pytest 更「运行时友好」,适合数据管道、接口、配置、工业数采链路(帧解析、归一化、连接生命周期、DB 落库)的高密度回归测试。
10. FAQ 速查表
Q1:pytest 和 unittest 可以共存吗?可以。unittest.TestCase 子类会被 pytest 收集执行;混用断言也合法(pytest 的 raises 与 assertRaises 互不排斥)。但新代码推荐纯 pytest 风格。
Q2:fixture 参数化(params)怎么用?
@pytest.fixture(params=[1, 2, 3]) def num(request): return request.param每参数生成一组用例;ids 可定制显示名。
Q3:怎么跳过依赖特定环境的用例?
@pytest.mark.skipif(os.name != "nt", reason="仅 Windows") def test_com_port(): ...Q4:pytest 和 pytest-asyncio 的 asyncio_mode=auto 与 strict 区别?auto 自动把 async 测试函数当异步跑;strict 要求显式 @pytest.mark.asyncio。
Q5:--lf 为什么有时不生效?--lf 依赖 .pytest_cache;clean CI 或删除缓存目录后失效。需配合 -p cacheprovider 启用缓存。
Q6:如何只跑最近修改过的测试?pytest --lf --co 看上次失败列表;或结合 -k 与 --deselect 精确控制。
Q7:xdist 并行时 fixture 里写共享文件怎么处理?用 tmp_path_factory.mktemp("name") 为每个 worker/测试创建唯一目录;禁止 session 级共享写路径。
Q8:如何定制失败后的输出(比如只打印 diff 不打印堆栈)?--tb=line(每行失败一行)、--tb=short(截断堆栈)、--tb=native(原始 traceback)。
Q9:测试里如何临时修改环境变量并自动恢复?monkeypatch.setenv("KAFKA_BROKERS", "localhost:9092"),用例结束自动还原。
Q10:如何接入覆盖率门槛(如 <80% 失败)?pytest-cov 支持 --cov-fail-under=80;CI 中结合 --cov-report=xml 上传质量平台。
Q11:fixture 抛异常时 teardown 会执行吗?yield 前异常 -> teardown 段不执行(因为还没进入 yield);yield 后异常 -> teardown 段照常执行并叠加报错。
Q12:参数化与 fixture 混用(indirect)怎么理解?@pytest.mark.parametrize("user", ["a","b"], indirect=True) 让参数值走 user fixture 加工,而不是直接注入原值。
Q13:多个 conftest 同名 fixture 覆盖顺序?最近目录(最内层)的 conftest 优先覆盖外层同名 fixture。
Q14:如何验证不产生任何警告?-W error 或 filterwarnings = error 把警告升级为错误;配合 pytest.warns 精确断言预期警告。
Q15:测试数据文件放哪?项目内 tests/data/ 或 tests/fixtures/,用 pathlib.Path(__file__).parent / "data" 定位;大数据不提交仓库时用缓存/生成器。