第 2 章 · 02 测试发现规则与运行控制
本章目标:搞清楚 pytest 从哪里找测试、怎么精确地"只跑我想跑的那几个",并读懂每次运行结束时的退出码。
2.1 测试发现的完整规则
第 1 章我们只记住了两条命名约定,但 pytest 的收集(collection)过程其实有明确的递归规则,全部来自官方文档的 test discovery 一节:
- 不带参数运行时,从
testpaths配置(若配置了)或当前目录开始收集; - 递归进入子目录,除非目录名匹配
norecursedirs配置(默认值包含*.egg、.*、_darcs、build、CVS、dist、node_modules、venv等); - 目录中只把
test_*.py或*_test.py形式的文件当作测试模块导入; - 从这些文件中收集:
- 类外部的
test_前缀函数; Test前缀且没有__init__方法的类里的test_前缀方法。
- 类外部的
用一段代码把"收与不收"对照清楚:
# 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): # (随类一起被忽略)
...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),格式是 文件路径::类名::函数名,参数化用例还会追加 [参数]。有了它就能指哪打哪:
# 运行一个文件里的所有测试
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 的输出里复制:
$ pytest --collect-only -q
tests/test_mod.py::test_func
tests/test_mod.py::TestClass::test_method
2 tests collected in 0.02s2.3 关键字表达式 -k 与标记表达式 -m
-k 按名字做子串匹配(大小写不敏感),匹配范围包括文件名、类名、函数名。先看一组用于演示的测试:
# 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 组合:
pytest -k "valid and not invalid" # 只跑 TestLogin::test_valid_user
pytest -k "TestLogin" # 类名匹配,类内两条全跑
pytest -k "rate_limit" # 命中模块级函数 test_login_rate_limit按标记筛选用 -m(标记在第 4 章展开):
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 | 先跑上次失败的用例,再跑其余 |
# CI 里常用的组合:失败即停 + 简短 traceback
pytest -x --tb=short
# 本地调试:先看上次挂了什么
pytest --lf -v2.5 退出码:让脚本知道发生了什么
先用一个必挂的测试看看 traceback 级别选项的效果差异:
# tests/test_tb_demo.py
def divide(a, b):
return a / b
def test_divide():
assert divide(6, 3) == 3 # 故意写错:正确值是 2pytest tests/test_tb_demo.py --tb=short # 精简 traceback,只留关键行
pytest tests/test_tb_demo.py --tb=line # 每个失败只给一行
pytest tests/test_tb_demo.py --tb=no # 完全不显示 tracebackCI 日志里 --tb=short 是平衡可读性与噪音的常用选择;本地深挖时再用默认的 long 模式。
pytest 结束后会返回退出码,CI 流水线和 shell 脚本靠它判断成败:
| 退出码 | 含义 |
|---|---|
0 | 全部收集到的测试通过 |
1 | 有测试失败或出错 |
2 | 执行被用户中断(如 Ctrl-C) |
3 | pytest 内部错误 |
4 | 命令行用法错误 |
5 | 没有收集到任何测试 |
退出码 5 最容易被忽视:如果你的命令拼错了文件名导致什么都没跑,pytest 并不算"成功",CI 应该把它当失败处理。
在 Python 代码中调用 pytest 时,退出码会作为返回值而不是抛出 SystemExit:
# 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,下列说法正确的是?
🛠️ 动手实践
- 在你的项目里运行
pytest --collect-only -q,数一数共收集到多少条用例,然后分别用节点 ID 和-k各执行其中一条,比较两种方式的体验。 - 制造一次"空收集":运行
pytest 一个不存在的目录,确认退出码为 5(echo $?查看),再写一行 shell 脚本把这个情况检测出来。 - 使用
pytest -x --tb=line跑一遍现有测试,故意在其中加一个必挂的断言,观察"第一个失败即停止"的效果,最后恢复代码。
熟练控制"跑哪些测试"之后,下一章我们来深挖断言本身的表达力。