Skip to content

第 2 章 · 02 测试发现规则与运行控制

本章目标:搞清楚 pytest 从哪里找测试、怎么精确地"只跑我想跑的那几个",并读懂每次运行结束时的退出码。

2.1 测试发现的完整规则

第 1 章我们只记住了两条命名约定,但 pytest 的收集(collection)过程其实有明确的递归规则,全部来自官方文档的 test discovery 一节:

  1. 不带参数运行时,从 testpaths 配置(若配置了)或当前目录开始收集;
  2. 递归进入子目录,除非目录名匹配 norecursedirs 配置(默认值包含 *.egg.*_darcsbuildCVSdistnode_modulesvenv 等);
  3. 目录中只把 test_*.py*_test.py 形式的文件当作测试模块导入;
  4. 从这些文件中收集:
    • 类外部的 test_ 前缀函数;
    • Test 前缀且没有 __init__ 方法的类里的 test_ 前缀方法。

用一段代码把"收与不收"对照清楚:

python
# tests/test_discovery_rules.py

def test_addition():            # ✔ 收集:test_ 前缀函数
    ...


def helper_add():               # ✘ 不收集:不是 test_ 前缀
    ...


def add_test():                 # ✘ 不收集:前缀必须是开头而非结尾
    ...


class TestCart:                 # ✔ 收集类内 test_ 方法
    def test_add_item(self):    # ✔
        ...

    def _setup_items(self):     # ✘ 不收集:非 test_ 前缀
        ...


class CartTests:                # ✘ 整个类不收集:类名不以 Test 开头
    def test_something(self):   # (随类一起被忽略)
        ...
text
project/
├── src/                # 源码目录,不会被收集(不匹配测试文件名模式)
├── venv/               # 默认 norecursedirs 跳过
├── tests/
│   ├── conftest.py     # 不是 test_*.py,不会作为测试收集(但会被自动加载)
│   ├── test_app.py     # ✔ 收集
│   └── helpers.py      # ✘ 文件名不符合模式
└── pyproject.toml

常见坑:helper 也被当成测试

如果你在 tests/ 下放了一个 utils_test.py 放辅助函数,pytest 会尝试导入并收集它。反过来,想写的测试没被发现时,第一件事就是检查文件名和函数名前缀。

2.2 用节点 ID 精确选择测试

pytest 给每个测试分配一个节点 ID(node id),格式是 文件路径::类名::函数名,参数化用例还会追加 [参数]。有了它就能指哪打哪:

bash
# 运行一个文件里的所有测试
pytest tests/test_mod.py

# 只运行某个函数
pytest tests/test_mod.py::test_func

# 运行整个测试类
pytest tests/test_mod.py::TestClass

# 运行类中的某个方法
pytest tests/test_mod.py::TestClass::test_method

# 运行参数化用例中的某一条
pytest tests/test_mod.py::test_func[x1,y2]

节点 ID 可以直接从 pytest --collect-only -q 的输出里复制:

bash
$ pytest --collect-only -q
tests/test_mod.py::test_func
tests/test_mod.py::TestClass::test_method

2 tests collected in 0.02s

2.3 关键字表达式 -k 与标记表达式 -m

-k 按名字做子串匹配(大小写不敏感),匹配范围包括文件名、类名、函数名。先看一组用于演示的测试:

python
# tests/test_search_demo.py
class TestLogin:
    def test_valid_user(self):
        ...        # 节点 ID: test_search_demo.py::TestLogin::test_valid_user

    def test_invalid_password(self):
        ...


def test_login_rate_limit():
    ...

对这组用例,-k 表达式支持 and / or / not 组合:

bash
pytest -k "valid and not invalid"   # 只跑 TestLogin::test_valid_user
pytest -k "TestLogin"               # 类名匹配,类内两条全跑
pytest -k "rate_limit"              # 命中模块级函数 test_login_rate_limit

按标记筛选用 -m(标记在第 4 章展开):

bash
pytest -m slow              # 只跑 @pytest.mark.slow 的测试
pytest -m "not slow"        # 排除慢速测试——日常开发的常用姿势

pytest 9 新能力

新版还支持带参数的标记表达式,如 pytest -m "slow(phase=1)",可以按标记的参数值过滤。

2.4 控制运行的常用选项

选项作用
-x / --maxfail=1遇到第一个失败立即停止
--maxfail=N失败 N 个后停止
-v显示每个测试的完整节点 ID 与结果
-q / -qq安静模式,输出最少信息
--tb=short/no/line控制 traceback 详细程度
-s禁用输出捕获,直接看到 print
--lf只重跑上次失败的用例
--ff先跑上次失败的用例,再跑其余
bash
# CI 里常用的组合:失败即停 + 简短 traceback
pytest -x --tb=short

# 本地调试:先看上次挂了什么
pytest --lf -v

2.5 退出码:让脚本知道发生了什么

先用一个必挂的测试看看 traceback 级别选项的效果差异:

python
# tests/test_tb_demo.py
def divide(a, b):
    return a / b


def test_divide():
    assert divide(6, 3) == 3   # 故意写错:正确值是 2
bash
pytest tests/test_tb_demo.py --tb=short   # 精简 traceback,只留关键行
pytest tests/test_tb_demo.py --tb=line    # 每个失败只给一行
pytest tests/test_tb_demo.py --tb=no      # 完全不显示 traceback

CI 日志里 --tb=short 是平衡可读性与噪音的常用选择;本地深挖时再用默认的 long 模式。

pytest 结束后会返回退出码,CI 流水线和 shell 脚本靠它判断成败:

退出码含义
0全部收集到的测试通过
1有测试失败或出错
2执行被用户中断(如 Ctrl-C)
3pytest 内部错误
4命令行用法错误
5没有收集到任何测试

退出码 5 最容易被忽视:如果你的命令拼错了文件名导致什么都没跑,pytest 并不算"成功",CI 应该把它当失败处理。

在 Python 代码中调用 pytest 时,退出码会作为返回值而不是抛出 SystemExit

python
# run_tests.py
import sys
import pytest

if __name__ == "__main__":
    # 显式传参,返回退出码而不退出进程
    retcode = pytest.main(["-x", "-v", "tests"])
    if retcode == 5:
        print("警告:没有收集到任何测试!")
    sys.exit(retcode)

注意:同一进程内多次调用 pytest.main() 会受 Python 导入缓存影响,后续调用的结果可能不反映文件改动,官方不推荐这样做。

2.6 本章小结

  • 收集规则 = 起点(testpaths 或当前目录)→ 按 norecursedirs 剪枝 → 匹配 test_*.py / *_test.py → 收集 test_ 函数与无 __init__Test 类方法;
  • 节点 ID file::Class::func[param] 是精确定位测试的通用钥匙,--collect-only 可列出全部;
  • -k 按名字表达式筛、-m 按标记表达式筛;-x/--maxfail 快速止损;
  • 六种退出码中,5(未收集到测试)最常被误判为成功。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 以下哪个类会被 pytest 收集其中的 test_ 方法?

2. pytest 运行结束后返回退出码 5,代表什么?

3. 只想运行 tests/test_api.py 中 TestUser 类下的 test_login 方法,正确的命令是?

4. 关于 norecursedirs,下列说法正确的是?

🛠️ 动手实践

  1. 在你的项目里运行 pytest --collect-only -q,数一数共收集到多少条用例,然后分别用节点 ID 和 -k 各执行其中一条,比较两种方式的体验。
  2. 制造一次"空收集":运行 pytest 一个不存在的目录,确认退出码为 5(echo $? 查看),再写一行 shell 脚本把这个情况检测出来。
  3. 使用 pytest -x --tb=line 跑一遍现有测试,故意在其中加一个必挂的断言,观察"第一个失败即停止"的效果,最后恢复代码。

熟练控制"跑哪些测试"之后,下一章我们来深挖断言本身的表达力。