从零读懂 pytest:用一个真实例子讲清 Python 测试
从零读懂 pytest:用一个真实例子讲清 Python 测试
用 pytest 写测试是 Python 开发的标配技能。但如果你刚接触,看到 python -m pytest tests/test_xxx.py -v 这种命令可能会一头雾水。
这篇文章用一个真实项目中的测试文件,带你一步一步拆解 pytest 到底在干什么。
测试文件长什么样?
先看我们要分析的文件 tests/test_format2nerf.py。它测试的是一个叫 dir2myjson 的函数——这个函数扫描一个目录里的文件,按规则解析文件名,生成一份描述数据结构的 JSON。
"""测试 format2nerf 模块:目录扫描与 JSON 生成。"""
import json
import os
import tempfile
from pathlib import Path
import pytest
from src.utils.format2nerf import dir2myjson
_OUTPUT_DIR = Path(os.environ.get("TEST_OUTPUT_DIR", "data/capture_data/test_data"))
@pytest.fixture
def dual_cam_dir():
"""创建模拟前后双摄数据目录。"""
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
# 相机参数
(d / "CameraParam_01.ini").write_text("[ColorIntrinsic]\nfx=600\n")
(d / "CameraParam_02.ini").write_text("[ColorIntrinsic]\nfx=600\n")
# 同步帧:cam0 和 cam1 时间戳相同
for cam in ("0", "1"):
ts = "1700000000000"
(d / f"color_{cam}_{ts}.png").write_bytes(b"")
(d / f"depth_{cam}_{ts}.raw").write_bytes(b"")
(d / f"points_{cam}_{ts}.ply").write_bytes(b"")
# 异步帧:仅有 cam0
(d / "color_0_1800000000000.png").write_bytes(b"")
(d / "depth_0_1800000000000.raw").write_bytes(b"")
(d / "points_0_1800000000000.ply").write_bytes(b"")
yield d
文件后面是 8 个以 test_ 开头的函数。
命令拆解
python -m pytest tests/test_format2nerf.py -v
python -m pytest
python -m <模块名> 的意思是:把 <模块名> 当成脚本运行。等价于直接执行 pytest 这个程序。用 -m 而不是直接敲 pytest 的原因很简单——你不需要把 Python 的 Scripts 目录加到系统 PATH 里。
tests/test_format2nerf.py
告诉 pytest 你要测哪个文件。pytest 会读这个文件,执行里面的代码。
-v
verbose(详细模式)。让 pytest 把每个测试的名字和执行结果打印出来。不加 -v 的话,只会输出一些点号,通过了几个就印几个点,比较简略:
....... [100%]
加了 -v 会变成:
tests/test_format2nerf.py::test_dir2myjson_sync_frames PASSED
tests/test_format2nerf.py::test_dir2myjson_async_frames PASSED
...
执行流程:pytest 到底干了什么?
从你敲下回车到看到结果,pytest 做了三件事:
第一步:导入(import)
pytest 执行你的测试文件,遇到 from src.utils.format2nerf import dir2myjson,Python 就会去加载这个模块。加载过程中会执行模块代码——在这个项目里,src/utils/__init__.py 会连带加载 io_utils.py,后者顶层写了 import yaml。
所以如果没装 pyyaml,导入就失败了,pytest 直接报错:
ModuleNotFoundError: No module named 'yaml'
教训:你的被测代码如果有第三方依赖,测试环境也得装上。
第二步:收集(collection)
导入成功后,pytest 扫描文件中所有以 test_ 开头的函数,把它们收集起来准备执行:
collected 8 items
pytest 的默认发现规则很简单:文件名以
test_开头或_test结尾,函数名以test_开头。你可以改,但最好别改。
第三步:执行(execution)
pytest 依次调用每个 test_ 函数。遇到 assert 语句:
- 条件为
True→ 通过 - 条件为
False→ 测试失败,pytest 打出失败信息和堆栈
每个测试函数是独立运行的,互不干扰。一个失败不影响其他。
核心概念:assert
pytest 最妙的地方是——你不用学任何特殊的断言语法。Python 自带的 assert 就是全部。
assert len(result["frames"]) == 2
assert sync_frame["cam0"]["color"] == "color_0_1700000000000.png"
assert async_frame["cam1"]["color"] is None
pytest 的聪明之处在于:当 assert 失败时,它会自动分析表达式,告诉你实际值是什么。比如 assert x == 2 失败了,它会说"x 的值是 3,不是 2"——不需要你写额外的消息。
核心概念:fixture(夹具)
fixture 是 pytest 最强大的功能之一。看这段:
@pytest.fixture
def dual_cam_dir():
"""创建模拟前后双摄数据目录。"""
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
# ... 创建一堆模拟文件 ...
yield d
fixture 解决了什么问题?
很多测试需要"先准备好环境"——比如创建临时目录、连接数据库、准备测试数据。如果每个测试都自己写一遍,代码会大量重复。fixture 就是把这些准备工作提炼出来。
fixture 怎么工作?
@pytest.fixture标记这是一个 fixture- 测试函数把 fixture 名字当参数,pytest 自动把返回值传进来
def test_dir2myjson_sync_frames(dual_cam_dir): # ← 参数名匹配 fixture 名字
out = dual_cam_dir / "out.json" # ← dual_cam_dir 就是 fixture 的返回值
result = dir2myjson(str(dual_cam_dir), str(out))
...
pytest 看到测试函数需要 dual_cam_dir,就会先去调用那个 fixture 函数,拿到返回值,再传给测试。
yield 的魔法
注意 fixture 里用的是 yield d 而不是 return d。yield 前后可以分成两段:
- yield 之前:准备环境(创建目录、写文件)
- yield 时:把准备好的东西交给测试函数
- yield 之后:清理环境(临时目录自动删除)
这样测试函数无论成功还是失败,TemporaryDirectory 的 __exit__ 都会执行,不会留下垃圾文件。
8 个测试分别测什么?
| 测试函数 | 测试场景 | 核心断言 |
|---|---|---|
test_dir2myjson_sync_frames |
两个相机时间戳相同时,数据合并到同一帧 | cam0 和 cam1 的数据都在同一帧里 |
test_dir2myjson_async_frames |
只有一个相机有数据时,另一个相机字段为 null | cam1["color"] is None |
test_dir2myjson_camera_params |
参数文件被正确列出 | "CameraParam_01.ini" in result["camera_params"] |
test_dir2myjson_sorted_by_timestamp |
帧按时间戳升序 | timestamps == sorted(timestamps) |
test_dir2myjson_output_file_exists |
JSON 确实写到了磁盘 | out.exists() + 文件内容是合法 JSON |
test_dir2myjson_empty_dir |
空目录也能正常处理 | result["frames"] == [] |
test_dir2myjson_nonexistent_dir |
不存在的目录抛异常 | pytest.raises(NotADirectoryError) |
test_dir2myjson_persistent_output |
真实数据能跑通 | 生成 frames.json 到指定目录 |
这里覆盖了几类常见测试场景:
正常路径(Happy Path)
最核心的场景——正常输入应该得到正确的输出。test_sync_frames 就是典型的 happy path。
边界情况(Edge Case)
- 空输入:
test_empty_dir— 空目录能处理吗? - 部分数据:
test_async_frames— 只有一半数据会崩溃吗?
异常情况(Error Case)
test_nonexistent_dir — 传入不存在的目录应该抛出明确的错误,而不是静默失败或报一些莫名其妙的错误。
结果验证(Output Validation)
test_output_file_exists — 不光看返回值,还要检查文件确实写到了磁盘上,而且是合法的 JSON。
三条 pytest 实用技巧
1. 只跑部分测试
# 只跑名字包含 "sync" 的测试
python -m pytest tests/test_format2nerf.py -k sync -v
# 只跑最后一个测试(持久化输出的那个)
python -m pytest tests/test_format2nerf.py::test_dir2myjson_persistent_output -v
2. 看到 print 输出
默认 pytest 会捕获 print 的输出,测试失败才显示。加 -s 让 print 直接显示:
python -m pytest tests/test_format2nerf.py -v -s
3. 只看失败摘要
python -m pytest tests/test_format2nerf.py -q
-q(quiet)只输出测试结果摘要,更简洁。
写测试的四个原则
从上面 8 个测试可以总结出几条通用原则:
1. 独立
每个测试函数不依赖其他测试的执行结果,可以单独运行,也能任意排序。
2. 可重复
用 tempfile.TemporaryDirectory 创建合成数据,不依赖真实硬件、网络、外部文件。在任何机器上都能跑出相同结果。
3. 自验证
每个测试通过 assert 自己判断对错,不需要人来检查输出。
4. 一文一事
每个测试只验证一个关注点。"同步帧合并正确"和"空目录不报错"是两件事,分别写两个测试。这样哪个出问题一目了然。
总结
pytest 的核心逻辑并不复杂:
| 概念 | 本质 |
|---|---|
python -m pytest |
启动测试框架 |
| 测试发现 | 找文件名和函数名含 test_ 的 |
assert |
自己断言,pytest 帮你分析失败值 |
@pytest.fixture |
把"准备环境"和"清理环境"抽出来复用 |
-v / -s / -k |
控制输出的详细程度 |
下次看到 python -m pytest tests/test_format2nerf.py -v,你就知道:
1. Python 启动 pytest 框架
2. 读 test_format2nerf.py,找到 8 个 test_ 函数
3. 逐个执行,每个遇到 assert False 就报错
4. -v 把每个测试的名字和结果一行行打出来
就这么简单。
浙公网安备 33010602011771号