Pytest 中那些"约定大于配置"的设计
你为什么不需要写 main 函数
很多 pytest 新手会有一个困惑:我明明只写了一个 def test_foo(),到底是"谁"找到了它、调用了它?
答案是:你不需要主动告诉 pytest 任何事。只要你按它的规矩来,它自己会找。
规矩很简单——文件名以 test_ 开头就行。没有注册清单、没有 XML 配置文件、没有任何地方声明"下面这些是测试"。pytest 启动后从当前目录开始扫描,发现符合命名约定的文件就收集,发现文件里符合约定的函数就跑。
这不是运气,这是一套精密的"约定大于配置"体系。它的哲学很简单:如果这件事有一个显而易见的做法,那就把它变成默认,别让用户每次都要说一遍。
下面我们一层层拆开看,pytest 究竟藏了多少这样的约定。
第一层:测试发现的命名约定
三步法则
你把测试文件放到项目目录下,pytest 就知道去哪找:
project/
├── src/
│ └── calculator.py
└── tests/
├── test_basic.py ← test_ 开头 → 被收集
├── helper.py ← 不符合约定 → 被忽略
├── unit/
│ ├── test_add.py ← 子目录也会递归扫描
│ └── conftest.py ← 约定名,自动加载(后面细讲)
└── data/
└── sample.json ← 非 Python 文件 → 忽略
约定规则:
# tests/test_calculator.py — 约定示例
def test_add():
"""函数以 test_ 开头 → 自动成为测试用例"""
assert 1 + 1 == 2
def helper_function():
"""不以 test_ 开头 → 只是普通函数,不会被当成测试"""
return "I'm invisible to pytest"
class TestSubtract:
"""类以 Test 开头,且没有 __init__ → 类内方法自动收集"""
def test_positive(self):
assert 5 - 3 == 2
def test_negative(self):
assert 3 - 5 == -2
def helper(self):
"""这个方法不以 test_ 开头 → 也不会被当成测试"""
pass
运行 pytest tests/ -v,输出会是:
tests/test_calculator.py::test_add PASSED
tests/test_calculator.py::TestSubtract::test_positive PASSED
tests/test_calculator.py::TestSubtract::test_negative PASSED
你没有在任何地方注册。你没有写 @unittest.skip。你没有继承 TestCase。你只是把函数命名对了。
可配置,但几乎没人配
当然,约定可以被覆盖:
# pytest.ini — 改变发现规则
[pytest]
python_files = check_*.py # 改文件命名约定(不推荐)
python_classes = Check* # 改类命名约定(不推荐)
python_functions = check_* # 改函数命名约定(不推荐)
但说实话,在长达十年的 pytest 历史中,"自定义发现规则"这个功能几乎没人用。因为约定本身已经足够好,改它的代价是让后来加入的人一脸懵。
第二层:conftest.py 的级联约定
比 import 更优雅的共享方式
多个测试文件需要共享同样的 fixture 或者 hook,其他框架的做法是:
# unittest/JUnit 的做法:
import shared_fixtures from some.module # 显式导入
setup_module() # 手动声明
pytest 呢?
你只需要把一个文件命名为 conftest.py,放在测试目录里。pytest 自动发现、自动加载、自动限定作用域。
project/
├── tests/
│ ├── conftest.py ← 作用域:tests/ 及所有子目录
│ │
│ ├── unit/
│ │ ├── conftest.py ← 作用域:仅 unit/ 及子目录
│ │ ├── test_core.py ← 能看到两个 conftest
│ │ └── test_utils.py
│ │
│ └── integration/
│ └── test_api.py ← 只看 tests/conftest.py
# tests/conftest.py — 全局级
import pytest
@pytest.fixture(scope="session")
def db_connection():
"""会话级数据库连接,所有测试共享"""
conn = create_connection("postgres://...")
yield conn
conn.close()
@pytest.fixture
def sample_user(db_connection):
"""函数级 fixture,每个测试拿到独立用户"""
user = db_connection.create_user(name="alice")
yield user
db_connection.delete_user(user.id)
# tests/unit/conftest.py — 仅单元测试专用
import pytest
@pytest.fixture
def mock_http_client(mocker):
"""懒得真发 HTTP,单元测试用 mock"""
return mocker.patch("src.http.Client")
# 注意:这里没有 import tests/conftest.py
# fixture 自动向上继承
# tests/unit/test_core.py — 随便用,不需要 import
def test_user_creation(sample_user, mock_http_client):
# sample_user 来自 tests/conftest.py(继承)
# mock_http_client 来自 tests/unit/conftest.py(本地)
assert sample_user.name == "alice"
这里发生了什么:
test_core.py 的参数 sample_user 和 mock_http_client —— pytest 通过参数名匹配 fixture 名称,而不是通过 import
- conftest.py 之间自动级联:子目录的测试能继承父目录 conftest 中定义的所有 fixture 和 hook
- 子目录的 conftest.py 可以覆盖父目录的同名 fixture(后加载的优先)
这就是为什么 pytest 项目里你几乎看不到 from some_fixture import ... —— conftest 约定让导入变得多余。fixture 在哪里定义不重要,重要的是它在测试函数目录层级中的 conftest 链上。
conftest 的另一个身份:Hook 实现
conftest.py 不止能放 fixture,它还能直接实现 pytest 的 hook:
# tests/conftest.py
def pytest_collection_modifyitems(config, items):
"""hook 函数:修改收集到的测试列表"""
# 函数名 = hook 名,不需要任何装饰器
for item in items:
item.add_marker("slow") # 给每个测试打上 slow 标记
def pytest_addoption(parser):
"""hook 函数:添加命令行参数"""
parser.addoption("--env", action="store", default="dev")
没有任何注册步骤。只要函数名和 pytest 定义的 hook 名一致,conftest 加载时自动生效。
第三层:配置文件的自动查找
pytest 不需要 --config 参数。你只需把文件命名对,放在对的位置:
pytest.ini ← 优先级最高
pyproject.toml ← 现代项目首选
tox.ini ← 如果已经用了 tox
setup.cfg ← 旧项目常见
pytest 启动时自动从当前目录向上查找,找到第一个就停。
# pyproject.toml — 最推荐的方式
[tool.pytest.ini_options]
minversion = "8.0" # 最低 pytest 版本
testpaths = ["tests"] # 扫描哪些目录
addopts = "-ra -q -p no:warnings" # 默认命令行参数
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration",
]
filterwarnings = ["error"]
发现优先级:pytest.ini > pyproject.toml > tox.ini > setup.cfg
pytest 按照这个顺序逐一查找,找到就停。所以你放在 pyproject.toml 里最安全——不会被同目录下的 tox.ini 意外覆盖。
第四层:插件——pip install 即生效
这是最"约定大于配置"的一部分,也是 pluggy 在背后起关键作用的一层。
为什么 pip install 完就能用?
当你执行 pip install pytest-cov:
- pip 把
pytest_cov 安装到 site-packages
- pytest 启动时调用
pm.load_setuptools_entrypoints("pytest11")
- pluggy 扫描所有已安装包的
entry_points,找到 group 为 pytest11 的条目
- import 对应的模块并
pm.register(module) 注册为插件
- 插件中任何与 hook 同名的函数自动成为 hook 实现
整个过程:安装 → 自动发现 → 自动注册 → 自动生效。你不需要在 pytest.ini 中声明 plugins = pytest-cov,也不需要手动 import。
# pytest-cov 的 setup.cfg 里声明了这个:
[options.entry_points]
pytest11 =
pytest_cov = pytest_cov.plugin
# ↑ 模块路径 ↑ entry_point 名
三个插件来源
pytest 会自动加载三种来源的插件:
# conftest.py — 显式声明额外插件
pytest_plugins = [
"tests.plugins.auth", # 加载自定义插件模块
"tests.plugins.db_fixtures",
]
第五层:Hook 函数的隐身术
回到文章开头的问题——def test_foo 是怎么被找到的?实际上,所有 pytest 插件间的交互也都是通过同一种"隐身术"完成的。
名字即契约
以 pytest_collection_modifyitems 为例。一个第三方插件(比如 pytest-ordering)想要调整测试执行顺序,它只需要这样做:
# pytest_ordering/plugin.py
def pytest_collection_modifyitems(config, items):
"""函数名等于 hook 名 → 自动Hook实现"""
items.sort(key=lambda item: item.get_marker_value("order", 0))
没有 @hookimpl?实际上 conftest.py 和 entry_points 加载的模块中,pytest 内部会自动给所有符合 hook 名约定的函数打上 @hookimpl 标记。这是 pytest 在 pluggy 基础上的第二层封装——你写普通函数就好,pytest 帮你做标记。
如果回到 bare pluggy 代码,手动版本是这样的:
# 如果直接使用 pluggy,需要手动标记
import pluggy
hookimpl = pluggy.HookimplMarker("pytest")
class MyPlugin:
@hookimpl # ← 必须显式标记
def pytest_collection_modifyitems(self, config, items):
items.sort(key=lambda x: x.name)
pytest 把这个 @hookimpl 藏起来了——如果模块没有任何显式标记,pytest 会在注册阶段用前缀匹配自动补上。对插件作者来说,你只需要知道 hook 叫什么名字。
调用顺序
pytest 自身也是由几十个内置插件拼接而成——_pytest.capture、_pytest.fixtures、_pytest.assertion 等等。每个模块都作为插件注册到同一个 PluginManager。
测试执行时 hook 调用链(以 pytest_runtest_call 为例):
1. _pytest.capture (wrapper setup) ← 开始捕获 stdout/stderr
2. _pytest.logging (wrapper setup) ← 设置日志捕获
3. _pytest.fixtures (普通实现) ← 注入 fixture
4. pytest-cov (普通实现) ← 覆盖率统计
5. 你的 conftest.py (普通实现) ← 你的自定义逻辑
6. _pytest.logging (wrapper teardown) ← 还原日志
7. _pytest.capture (wrapper teardown) ← 结束捕获
每个插件只关心自己的事,通过 hook 协议串联起来。各模块之间不直接 import 调用——这是 pluggy 提供的松耦合。
第六层:fixture 注入——参数即依赖
所有测试框架都有 setup/teardown,但 pytest 把它们变成了参数注入:
# 传统方式(unittest)
class TestUser(unittest.TestCase):
def setUp(self):
self.db = create_db() # 每一个测试方法都得写 self.db
def test_name(self):
assert self.db.get_user(1).name == "alice"
# pytest 方式
def test_name(db): # 参数名叫 db → pytest 去找名叫 db 的 fixture
assert db.get_user(1).name == "alice"
不需要 setUp、不需要 self.xxx、不需要继承。参数名就是依赖声明。
作用域约定
fixture 支持四级作用域:
@pytest.fixture(scope="function") # 默认:每个测试函数一个实例
def user(): ...
@pytest.fixture(scope="class") # 一个测试类内共享
def class_db(): ...
@pytest.fixture(scope="module") # 一个测试文件内共享
def module_config(): ...
@pytest.fixture(scope="session") # 一次 pytest 运行全程共享
def session_db(): ...
pytest 按约定自动处理生命周期——你不需要手动在 setUpClass 里创建、tearDownClass 里销毁。用 yield 声明清理逻辑:
@pytest.fixture(scope="module")
def db_conn():
conn = create_connection()
yield conn # ← 这里返回 fixture 值
conn.close() # ← 模块所有测试跑完后自动执行
第七层:断言——只用 assert
这是 pytest 最"暴力"的约定:别用 self.assertEqual,直接用 assert。
# unittest 风格
self.assertEqual(a, b)
self.assertIn(item, list)
self.assertRaises(ValueError, func)
# pytest 风格
assert a == b
assert item in list
with pytest.raises(ValueError):
func()
pytest 在测试收集阶段对你的测试文件做了 AST 重写——把 assert a == b 改写成携带详细上下文信息的版本。当断言失败时,pytest 会自动展示失败的具体值和差异:
def test_user():
user = {"name": "Alice", "age": 30}
> assert user == {"name": "Bob", "age": 30}
E AssertionError: assert {'name': 'Alice', 'age': 30} == {'name': 'Bob', 'age': 30}
E Differing items:
E {'name': 'Alice'} != {'name': 'Bob'}
不需要写 self.assertEqual(user["name"], "Bob", msg="用户名不匹配")。直接 assert,pytest 自己会把差异给你拆开。
拼起来
最后我们用一套完整的示例,把上面每一层都串起来。
假设你有一个 user_service 模块,你想给它写测试。同时你装了一个现成的测试覆盖率插件。
目录结构
myproject/
├── src/
│ └── user_service.py
├── tests/
│ ├── conftest.py ← 全局 fixture 和 hook
│ ├── test_user.py ← 测试文件(命名约定·第一层)
│ └── integration/
│ ├── conftest.py ← 集成测试专用配置(级联约定·第二层)
│ └── test_api.py
└── pyproject.toml ← 配置文件(自动查找·第三层)
各文件内容
# src/user_service.py
class UserService:
def __init__(self, db):
self.db = db
def get_user_name(self, user_id):
user = self.db.get_user(user_id)
return user["name"] if user else None
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
# tests/conftest.py
import pytest
@pytest.fixture
def db():
"""模拟数据库"""
data = {}
def get_user(uid):
return data.get(uid)
class MockDB:
get_user = staticmethod(get_user)
def add_user(self, uid, name):
data[uid] = {"id": uid, "name": name}
return MockDB()
@pytest.fixture
def service(db):
"""注入带模拟数据库的服务"""
from src.user_service import UserService
return UserService(db)
def pytest_collection_modifyitems(config, items):
"""Hook:所有测试打标记(第五层·Hook隐身术)"""
for item in items:
print(f" 收集到: {item.nodeid}")
# tests/test_user.py
def test_get_existing_user(service, db): # 参数 = fixture 名(第六层·参数注入)
db.add_user(1, "Alice")
assert service.get_user_name(1) == "Alice" # 直接用 assert(第七层)
def test_get_nonexistent_user(service):
name = service.get_user_name(999)
assert name is None # 不是 self.assertIsNone
# tests/integration/conftest.py
import pytest
@pytest.fixture
def db():
"""覆盖父级 conftest:换成真实数据库"""
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INT, name TEXT)")
yield conn
conn.close()
# service fixture 从 tests/conftest.py 自动继承
# tests/integration/test_api.py
def test_real_insert(service, db):
db.execute("INSERT INTO users VALUES (1, 'Alice')")
# service fixture 来自 tests/conftest.py(继承)
# db fixture 来自 tests/integration/conftest.py(覆盖)
assert service.get_user_name(1) == "Alice"
运行 pytest -v:
$ pip install pytest-cov ← 自动加载(第四层·插件约定)
$ pytest -v
收集到: tests/test_user.py::test_get_existing_user
收集到: tests/test_user.py::test_get_nonexistent_user
收集到: tests/integration/test_api.py::test_real_insert
tests/test_user.py::test_get_existing_user PASSED
tests/test_user.py::test_get_nonexistent_user PASSED
tests/integration/test_api.py::test_real_insert PASSED
整个过程中,你没有写一行多余的配置代码:
- 不需要
TestRunner / TestSuite / @pytest.mark
- 不需要
from tests.conftest import db
- 不需要在
pytest.ini 声明 plugins = pytest_cov
- 不需要在
tox.ini 或 setup.cfg 加任何东西
约定本身即为配置。
关键结论
pytest 的"约定大于配置",不是"设几条命名规则就够了",而是一套由 pluggy 的 hook 协议支撑的、层层递进的匹配体系:
如果你要设计一个自己框架的插件系统,pluggy 已经帮你做好了 hook 注册、验证、调用的全部重活——你只需要定义 hookspec,剩下的就是让插件作者享受命名即注册的快乐。