从零读懂 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 怎么工作?

  1. @pytest.fixture 标记这是一个 fixture
  2. 测试函数把 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 dyield 前后可以分成两段:

  • 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 把每个测试的名字和结果一行行打出来

就这么简单。

posted @ 2026-05-19 15:56  剪水行舟154  阅读(19)  评论(0)    收藏  举报