For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /blog/2026-09-22.md.

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 文件 → 忽略

约定规则:

规则匹配不匹配
文件名test_*.py*_test.pyhelper.py, utils.py
函数名test_*init_db(), helper()
类名Test* (且无 __init__MyTest(Object), class Foo
包目录需要 __init__.py 才被当作包扫描普通目录不递归进包逻辑
# 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"

这里发生了什么:

  1. test_core.py 的参数 sample_usermock_http_client —— pytest 通过参数名匹配 fixture 名称,而不是通过 import
  2. conftest.py 之间自动级联:子目录的测试能继承父目录 conftest 中定义的所有 fixture 和 hook
  3. 子目录的 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

  1. pip 把 pytest_cov 安装到 site-packages
  2. pytest 启动时调用 pm.load_setuptools_entrypoints("pytest11")
  3. pluggy 扫描所有已安装包的 entry_points,找到 group 为 pytest11 的条目
  4. import 对应的模块并 pm.register(module) 注册为插件
  5. 插件中任何与 hook 同名的函数自动成为 hook 实现

整个过程:安装 → 自动发现 → 自动注册 → 自动生效。你不需要在 pytest.ini 中声明 plugins = pytest-cov,也不需要手动 import

# pytest-cov 的 setup.cfg 里声明了这个:
[options.entry_points]
pytest11 =
    pytest_cov = pytest_cov.plugin
#               ↑ 模块路径    ↑ entry_point 名

三个插件来源

pytest 会自动加载三种来源的插件:

来源加载方式示例
entry_points(主流)pytest11 grouppip install pytest-xdist
conftest 中的 pytest_plugins变量约定pytest_plugins = ["myplugins.foo"]
模块名 pytest_*-p 参数或 PYTEST_PLUGINS 环境变量-p pytest_custom
# 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.pyentry_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.inisetup.cfg 加任何东西

约定本身即为配置。


关键结论

pytest 的"约定大于配置",不是"设几条命名规则就够了",而是一套由 pluggy 的 hook 协议支撑的、层层递进的匹配体系:

层面约定匹配置信度
发现test_* 文件名/函数名/类名固定前缀
共享conftest.py 文件名 + 目录层级固定文件名
配置pyproject.toml / pytest.ini固定路径
插件pytest11 entry point group固定组名
Hook函数名 = hook 名名称匹配
Fixture参数名 = fixture 名名称匹配
断言assert 语句Python 关键字

如果你要设计一个自己框架的插件系统,pluggy 已经帮你做好了 hook 注册、验证、调用的全部重活——你只需要定义 hookspec,剩下的就是让插件作者享受命名即注册的快乐。

声明:本站所有文章,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。-- mikigo