跳到正文

目录

pytest:Python 测试框架的事实标准,从 assert 反射到 fixture 体系

pytest:Python 测试框架的事实标准,从 assert 反射到 fixture 体系

pytest 的入门门槛很低,一个 assert 就能写测试。但真正让它难以替代的是 fixture 体系、parametrize、conftest.py 加上 1300 多个插件——从一行 demo 到十万级用例的测试工程都能撑住。新手常把它当 unittest 替代品,老手把它当测试编排框架,两种用法并不冲突。

这篇文档按"先会跑、再拆机制、后避坑"的顺序展开。 读完你会得到三样东西:一是能独立装好 pytest 并把第一个用例跑通(含常见 CLI 参数的用途);二是能说清 fixture、parametrize、conftest.py、marker 四套机制各自解决什么问题、彼此怎么配合,而不是只背 API;三是能看懂一个完整项目的测试是怎么被组织起来的,并知道测试变慢、用例互相污染时该往哪儿查。前四章适合初次上手的人从头读,第五章开始进入机制,已有基础的人可以直接跳到对应章节。

一、项目坐标

字段
仓库pytest-dev/pytest
主语言Python(支持 Python 3.10+ 或 PyPy3)
Stars14.4k
Forks3.3k
LicenseMIT
创始人Holger Krekel(2004 年至今)
插件数1300+ 外部插件(官方收录列表见 plugin_list

数据核验于 2026-08(GitHub API 与 PyPI);当前最新版为 9.1.x(9.1.1 发布于 2026-06-19)。

pytest 的 README 开篇就是定位:“makes it easy to write small tests, yet scales to support complex functional testing for applications and libraries”。“easy → scales” 这条承诺贯穿后续所有设计。

二、为什么 pytest 成了事实标准

Python 生态里测试框架不止一个:unittest(标准库)、nose2doctest 都能写测试。pytest 在十几年里几乎一统江湖,原因有三。

1. 反射式 assert 失败信息。 这是新手最先感知到的不同。unittest 要求你写 self.assertEqual(a, b),pytest 允许直接写 assert a == b。断言失败时,pytest 通过 AST(抽象语法树,Abstract Syntax Tree)改写拿到 a、b 的实际值,再求值对比,打印出"哪个变量、什么值、为什么不等"。

官方 README 给的最小例子:

# content of test_sample.py
def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 5

运行 pytest 的输出不是干巴巴的 AssertionError,而是:

>       assert inc(3) == 5
E       assert 4 == 5
E        +  where 4 = inc(3)

where 4 = inc(3) 这一行是关键:pytest 把 inc(3) 重新求值一遍,告诉你"4 是从哪来的"。这种内省(introspection)在处理嵌套表达式、长链调用、复杂数据结构比较时尤其救命。

2. 自动发现。 不需要写 suite、不需要继承 TestCase、不需要注册。pytest 默认扫描当前目录及子目录下文件名匹配 test_*.py*_test.py 的文件,把其中以 test_ 开头的函数和 Test 开头的类里的 test_ 方法自动收集为测试用例。这套约定让"加一个测试 = 加一个函数"成为可能。

3. 插件架构。 pytest 把几乎所有非核心能力都做成插件——pytest-cov(覆盖率)、pytest-mock(mock 包装)、pytest-django(Django 集成)、pytest-asyncio(异步测试)、pytest-xdist(并行执行)——这 1300+ 插件构成了扩展生态,单元测试到端到端测试都有对应的工具。

合起来看,unittest 在新项目里基本退到"兼容性选项"的位置。


三、五分钟快速上手

3.1 安装

前置条件:Python 3.10+(或 PyPy3)。建议在虚拟环境里装,避免污染系统解释器。

pip install pytest

验证安装:

pytest --version
# pytest 9.1.1

3.2 第一个测试

# test_sample.py
def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 4   # 故意写对,演示通过
$ pytest
============================= test session starts ==============================
collected 1 item

test_sample.py .                                                          [100%]

============================== 1 passed in 0.01s ===============================

== 4 改成 == 5,再跑一次,你会看到前面那段 where 4 = inc(3) 的反射输出。

3.3 常用命令行参数

参数作用
pytest递归扫描当前目录
pytest test_x.py只跑指定文件
pytest -k "login"按名字关键字过滤
pytest -m slow按 marker 过滤(需要先注册 marker)
pytest -x遇到第一个失败就停
pytest --maxfail=3最多累计 N 个失败后停
pytest -v详细模式(显示每个测试名)
pytest -s不捕获 print--capture=no
pytest --tb=short控制 traceback 详细度(long/short/line/no
pytest -p no:cacheprovider禁用某个插件
pytest --lf只重跑上次失败的用例(--last-failed
pytest --durations=10列出耗时最长的 10 个用例

日常用到的 CLI 参数基本就是这 12 个,剩下的能力都通过 fixture 和插件暴露。

四、pytest 机制总览

在深入 fixture 之前,先看一眼 pytest 的机制全貌。表里每套机制都标了它解决什么问题、作用范围、跟谁配合。

机制解决什么问题作用范围与谁配合
fixture测试资源准备与清理(setup/teardown)通过 scope 控制,从 function 到 sessionparametrize、conftest.py
parametrize把一组数据展开成独立测试用例测试函数级(通过 @pytest.mark.parametrizefixture(参数名引用 fixture)
conftest.py跨文件共享 fixture 和 hook,无需 import目录级(对该目录及子目录所有测试可见)fixture、hook
marker给测试打标签,按标签筛选执行测试函数/类级(通过 @pytest.mark.xxxCLI -m 参数
hook在 pytest 执行流程的特定节点插入自定义逻辑会话级(通过 conftest.py 或插件注册)conftest.py、插件
插件扩展 pytest 核心能力(覆盖率、mock、并行等)会话级(通过 pip install + entry_points 注册)hook、fixture

遇到"这个 fixture 在哪些文件里可见"、“marker 和 parametrize 的区别是什么"这类问题时,回来看这张表比翻 API 文档快。

五、Fixture 体系

assert 和自动发现让你愿意用 pytest;fixture 决定了你能不能把它用深。

5.1 fixture 是什么

fixture 是一个由 @pytest.fixture 装饰的函数,职责是"准备测试需要的资源,并在测试结束后清理”。测试函数通过同名参数声明自己需要哪个 fixture。pytest 负责在调用测试前执行 fixture,结束后按依赖逆序清理。

import pytest

@pytest.fixture
def db():
    print("连接数据库")
    yield {"conn": "fake-conn"}
    print("关闭连接")   # yield 之后的代码就是 teardown

def test_query(db):
    assert db["conn"] == "fake-conn"

yield 是 fixture 的分界点:之前是 setup,之后是 teardown。一个函数同时承载 setup 和 teardown,比 unittest 的 setUp/tearDown + setUpClass/tearDownClass 两对方法要清晰。

5.2 scope:fixture 的生命周期

fixture 默认 scope="function",意味着每个测试函数都拿一份全新的实例。pytest 提供 5 个 scope:

scope含义典型场景
function(默认)每个测试函数一份临时对象、轻量 mock
class每个测试类一份类内共享状态
module每个 .py 文件一份整个文件共用一个 client
package每个包(带 __init__.py)一份跨文件共享但要包级隔离
session整个测试会话一份数据库连接池、HTTP client

scope 越大,setup 次数越少,测试越快,但要警惕"测试间状态污染"。session 级 fixture 几乎一定要配合"只读 + 内部复制"使用,否则后续测试会被前面的副作用拖死。

from sqlalchemy import create_engine  # 此处以 SQLAlchemy 为例

@pytest.fixture(scope="session")
def engine():
    # 全测试会话只启动一次重型资源
    eng = create_engine("sqlite:///:memory:")
    yield eng
    eng.dispose()

5.3 autouse:隐式 fixture

如果某个 fixture 必须对所有测试都生效,可以加 autouse=True,pytest 会自动调用它,测试函数不用写参数。

@pytest.fixture(autouse=True)
def reset_config():
    Config.load_defaults()
    yield
    Config.clean()

autouse 是把双刃剑:好处是少写一行参数,代价是新人读测试时不知道背后跑了什么。团队代码风格上,autouse 只建议用在"环境级别的副作用清理"(如重置全局配置、清空 mock 缓存),业务准备逻辑用显式参数。

5.4 factory fixture:参数化资源

有时候测试需要"一组"资源,而不是"一个"资源。factory fixture 的模式:fixture 返回一个工厂函数,测试调用这个函数创建新实例。

@pytest.fixture
def make_user(db):
    created = []
    def _make(name, role="user"):
        u = db.create_user(name=name, role=role)
        created.append(u)
        return u
    yield _make
    # 清理:删除所有创建的 user
    for u in created:
        db.delete_user(u)

def test_admin(make_user):
    admin = make_user("alice", role="admin")
    assert admin.can_publish()

factory 模式把"fixture 管理资源生命周期"和"测试控制资源内容"解耦——fixture 只管怎么建和怎么删,测试只管建什么和怎么用。

5.5 fixture 之间的依赖

fixture 可以依赖其他 fixture——参数列表写名字就行。

@pytest.fixture
def db():
    return Database(":memory:")

@pytest.fixture
def user_repo(db):
    return UserRepository(db)

def test_create(user_repo):
    user_repo.create("bob")
    assert user_repo.count() == 1

pytest 会先算依赖图,按拓扑序逐层 setup。这样可以把"重型资源 → 业务封装 → 业务对象"逐层封装,测试只用关心"我需要哪个业务对象",不用关心它依赖什么。

六、parametrize——数据驱动测试

fixture 解决"怎么准备环境",parametrize 解决"怎么准备数据"。

import pytest

@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert a + b == expected

跑一次,pytest 会展开成 4 个独立测试用例,每个用例会显示自己的 nodeid(如 test_add[1-2-3]),失败时精确定位到具体数据行。

给每条数据起个可读的名字,CI 日志会更友好:

@pytest.mark.parametrize("a,b,expected", [
    pytest.param(1, 2, 3, id="positive"),
    pytest.param(0, 0, 0, id="zero"),
    pytest.param(-1, 1, 0, id="mixed-sign"),
])
def test_add(a, b, expected):
    assert a + b == expected

输出会变成 test_add[positive] test_add[zero],一眼就能看到是哪类用例挂掉。

parametrize 还能和 fixture 叠加:把 fixture 名字写进参数列表,pytest 会先把 fixture 跑起来,再用 parametrize 展开数据。fixture × parametrize 的笛卡尔积是数据驱动测试的常用组合。

七、conftest.py——跨文件共享

写测试多了,很多 fixture 在多个文件里都要用。把 fixture 写进 conftest.py,pytest 会自动让它对该目录及子目录下的所有测试可见,不需要 import

project/
├── conftest.py           # 公共 fixture,对整个 project 可见
├── tests/
│   ├── conftest.py       # tests 层 fixture,对 tests/ 下可见
│   ├── unit/
│   │   ├── conftest.py
│   │   └── test_foo.py
│   └── integration/
│       ├── conftest.py
│       └── test_bar.py

conftest.py 的可见性规则:fixture 树和目录树同构,靠近根的 conftest 越"通用",靠近叶子的 conftest 越"专用"。这是大型项目里 fixture 能铺开的原因。

不要在 conftest.py 里写测试函数——pytest 不会收集它们,文件名虽然带 test 也无效。conftest.py 只放 fixture、hook、plugin 配置。

八、内置常用 fixture

pytest 自带了一批"开箱即用"的 fixture,覆盖测试 90% 的副作用处理需求:

fixture作用
tmp_path提供一个唯一的临时目录(pathlib.Path 类型),测试结束自动清理
tmp_path_factorysession 级临时目录工厂
monkeypatch临时修改属性/环境变量/字典项,测试结束自动还原
capsys / capfd捕获 print / 文件输出
caplog捕获 logging 输出
pytester给插件作者用,跑"会产出测试的测试"
request提供当前测试的元信息(函数名、参数、marker)

举例——monkeypatch 是写单元测试时改环境变量最干净的方式:

import mypkg.config  # 假设 mypkg 已安装,config 模块有 DEBUG 属性

def test_debug_mode(monkeypatch):
    monkeypatch.setenv("DEBUG", "1")
    monkeypatch.setattr("mypkg.config.DEBUG", True)
    assert mypkg.config.DEBUG is True
    # 测试结束,DEBUG 自动还原

tmp_path 也是必备:

def test_write_report(tmp_path):
    target = tmp_path / "report.txt"
    data = "## 标题\n正文内容"
    write_report(target, data)  # 假设 write_report 已定义
    assert target.read_text().startswith("##")

capsys / caplog 让"测 print 输出"和"测 log 输出"成为可能,避免在测试里塞一堆 print 调试。

九、Marker 系统——给测试打标签

marker 用来给测试"打标签"——跳过、加预期失败、按标签分组执行。

import pytest

@pytest.mark.skip(reason="还没修")
def test_broken():
    assert False

@pytest.mark.xfail(reason="已知 bug")
def test_known_bug():
    assert 1 + 1 == 3

@pytest.mark.slow
def test_heavy():
    import time; time.sleep(10)

命令行按 marker 过滤:

pytest -m slow          # 只跑慢测试
pytest -m "not slow"    # 跑所有非慢测试

自定义 marker 必须在 pytest.ini / pyproject.toml 里注册,否则 pytest 8+ 会发出 PytestUnknownMarkWarning

[tool.pytest.ini_options]
markers = [
    "slow: 标记耗时较长的集成测试",
    "integration: 跨服务集成测试",
]

marker 的命名应该表达"为什么跳过/为什么分组",而不是"测试在做什么"。前者是"业务维度"(slow / integration / smoke),后者是"测试名"(test_login)——后者已经能通过测试名表达,再加 marker 就是冗余。

十、运行 unittest

pytest 兼容 unittest——所有继承 unittest.TestCase 的类 pytest 都能跑。

import unittest

class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual("foo".upper(), "FOO")

pytest 直接执行。unittestsetUp/tearDown@unittest.skip 装饰器全部生效,断言失败时同样享受 pytest 的反射式输出。

这条兼容性的实际意义:老项目可以渐进式迁移——不用一次性把 unittest.TestCase 全改成 def test_xxx(),新写的测试用 pytest 风格,老测试保持原样,逐步替换。

十一、一次完整的测试执行过程

前面把 fixture、parametrize、conftest、marker 拆开讲了,这里用一个完整案例把它们串起来。以下面这个项目结构为例:

calculator/
├── pyproject.toml      # 注册 slow marker(见第九节)
├── conftest.py
├── src/
│   └── calc.py
└── tests/
    ├── conftest.py
    └── test_operations.py

pyproject.toml 里至少要包含 marker 注册,否则 @pytest.mark.slow 会触发 PytestUnknownMarkWarning

[tool.pytest.ini_options]
markers = ["slow: 耗时较长的集成测试"]

calculator/src/calc.py(被测代码):

class Calculator:
    def __init__(self, db_url):
        self.db_url = db_url
        self._result = 0

    def clear(self):
        self._result = 0

    def add(self, a, b):
        self._result = a + b
        return self._result

    def multiply(self, a, b):
        self._result = a * b
        return self._result

    def result(self):
        return self._result

calculator/conftest.py:

import pytest

@pytest.fixture(scope="session")
def db_url():
    """session 级:整个测试会话只需要一个数据库地址"""
    return "sqlite:///:memory:"

calculator/tests/conftest.py:

import pytest
from src.calc import Calculator

@pytest.fixture
def calc(db_url):  # 依赖上层的 db_url
    """每个测试函数都拿到一个全新 Calculator 实例"""
    c = Calculator(db_url)
    c.clear()
    yield c
    c.clear()

calculator/tests/test_operations.py:

import pytest

@pytest.mark.parametrize("a,b,expected", [
    pytest.param(1, 2, 3, id="positive"),
    pytest.param(-1, 5, 4, id="mixed"),
    pytest.param(0, 0, 0, id="zero"),
])
def test_add(calc, a, b, expected):
    assert calc.add(a, b) == expected

@pytest.mark.slow
def test_complex_calc(calc):
    calc.add(1, 2)
    calc.multiply(3, 4)
    assert calc.result() == 12

当运行 pytest 时,pytest 内部经历了以下步骤:

1. 测试发现
   ├── 扫描 tests/ 目录下匹配 test_*.py 的文件
   ├── 找到 test_operations.py
   └── 从中收集 test_add 和 test_complex_calc

2. fixture 依赖解析
   ├── test_add(calc, a, b, expected) → 需要 fixture: calc
   │   └── calc(db_url) → 需要 fixture: db_url
   ├── test_complex_calc(calc) → 需要 fixture: calc
   │   └── same chain
   └── 构建依赖图: db_url → calc → [test_add, test_complex_calc]

3. parametrize 展开
   ├── test_add 展开为 3 个独立用例:
   │   ├── test_add[positive]
   │   ├── test_add[mixed]
   │   └── test_add[zero]

4. fixture setup(按拓扑序)
   ├── db_url (scope=session): 只运行一次,返回 "sqlite:///:memory:"
   ├── calc (scope=function): 每个测试函数运行一次
   │   ├── test_add[positive]: setup calc → 测试 → teardown calc
   │   ├── test_add[mixed]:    setup calc → 测试 → teardown calc
   │   ├── test_add[zero]:     setup calc → 测试 → teardown calc
   │   └── test_complex_calc:  setup calc → 测试 → teardown calc

5. marker 筛选(如果指定了 -m)
   └── pytest -m slow → 只跑 test_complex_calc

6. 报告输出
   ├── test_add[positive] .     [25%]
   ├── test_add[mixed]    .     [50%]
   ├── test_add[zero]     .     [75%]
   └── test_complex_calc  .     [100%]

这个流程把 conftest 的层级作用域、fixture scope 的执行次数、parametrize 的展开和 marker 的过滤时机全部织在一起。排查"为什么某个 fixture 跑了太多次"或"为什么子目录的测试看不到这个 fixture"时,对照上面 6 步往回推。

十二、插件机制——pytest 的"长尾"

pytest 本身只做三件事:测试发现、fixture 调度、报告输出。其他能力(覆盖率、mock、并行、Django 集成、asyncio 集成)全部由插件完成。

一个最简插件就是带 hook 实现的 conftest.py

# conftest.py
def pytest_runtest_makereport(item, call):
    if call.when == "call":
        print(f"[done] {item.nodeid}")

更复杂的插件打包成 pytest-<name>,通过 pip install 安装,通过 entry_points 注册到 pytest。

pytest --trace-config 可以看到当前会话加载了哪些插件;pytest -p no:cacheprovider 可以临时禁用某个插件——这两条命令是排查"为什么测试突然变慢了 / 为什么某个 fixture 行为不对"的首选工具。

新人最容易踩的坑是"上来就装一堆插件"。先把 pytest 内置的 tmp_path / monkeypatch / capsys 用熟,再按需引入 pytest-mock(替代手写 mock)、pytest-cov(覆盖率)、pytest-xdist(并行)。其他插件多数是"特定框架的桥接"(Django/FastAPI/airflow 等),遇到再说。

十三、典型反模式与避坑建议

反模式后果修正
在 fixture 里做 import 大对象、scope=function每个测试都重导入,测试变慢改成 scope="module"session
pytest.fail() 抛异常代替 assert失去 assert 反射信息改用 assert 或 pytest 内置断言
测试里 time.sleep(N) 等异步事件慢且不稳定改用 monkeypatch / 事件注入
在 conftest.py 里写测试函数pytest 不会收集测试放到 test_*.py 文件
测试间共享可变全局状态后运行的测试受前面副作用影响用 fixture scope 隔离
写超长参数列表的 parametrize失败信息像天书pytest.param(..., id="...") 起可读名字
assert 后不写消息失败时只看到"AssertionError"assert x == y, "期望 y 因为 xxx"

十四、常见问题与排查

测试一个都没跑起来。 先确认文件名和函数名符合发现规则:文件叫 test_*.py*_test.py,函数以 test_ 开头。写进 conftest.py 的函数不会被收集,同理 Test 类里的非 test_ 方法也不会。用 pytest --collect-only 只看发现结果,能省去反复运行的时间。

报错 fixture 'xxx' not found 多数是三种情况:参数名拼错;fixture 定义在别处(见第七节 conftest 可见性);或者想在同一个函数里引用参数化数据,却把 fixture 名也写进了 parametrize 的参数列表。前两种对照依赖关系排查,第三种看第五节和第六节的配合方式。

提示 PytestUnknownMarkWarning 自定义 marker(如 @pytest.mark.slow)没有在配置里注册。第九节给了注册位置;警告本身不阻断执行,但意味着拼写错误的 marker 会静默失效——@pytest.mark.sloww 不会报错,只会被当成一个未知标签忽略,这是最常见的一种"测试没按预期跑"的原因。

测试之间互相影响。 症状是单个测试单独跑通过、整体跑挂掉。先怀疑可变全局状态:查哪些测试共享了同一个对象、模块变量或环境变量。修复方向按第五节 scope 的语义来——把共享资源收敛到合适 scope,或在 autouse fixture 里重置。session 级 fixture 只读 + 内部复制是稳妥做法。

想只看失败、不重跑全部。 pytest -x 在第一个失败处停住,适合改一处验证一处;pytest --maxfail=2 允许累计少量失败后停。CI 里想拿全量失败列表就都不要加,让一次运行报完。

慢测试难定位。pytest --durations=10 列出耗时最长的 10 个测试。配合第三节的 --tb=short,能把"哪个用例慢 + 挂在哪一步"同时看清楚。定位到慢的 fixture 后,回到第五节按 scope 调整实例次数。

用例多、输出太长。 pytest -q 每条用例只打一个字符,失败再展开详情;-v 则是完整函数名。CI 日志用 -q 配合 --durations 通常比全量 -v 更可读。

十五、什么时候用 pytest,什么时候用别的

pytest 不是银弹,少数场景下别的工具更合适:

场景推荐
纯标准库、小型工具unittest(标准库不需要额外依赖)
纯文档示例验证doctest
行为驱动(BDD,Given-When-Then)pytest-bdd(保留 pytest 生态)或 behave
大规模端到端(E2E)Playwright / Cypress(pytest 只做编排)
性能基准pytest-benchmark / locust(不要和功能测试混在一个 suite)
强类型契约测试hypothesis(基于属性的测试,pytest 风格但独立库)

十六、采用顺序建议

把 pytest 引入一个 Python 项目,按下面这个顺序铺:

  1. 第一周:装上 pytest,给现有 unittest 测试加上 pytest 跑通,CI 改成 pytest。这一阶段不重写任何东西,只是切换工具链。
  2. 第二周:开始用 assert 替代 self.assertEqual,把简单的 setUp 改成 @pytest.fixture。完成 30% 的迁移就够,剩下 70% 看团队节奏。
  3. 第一月:把跨文件共享的 setup 拆进 conftest.py,引入 pytest-cov 看覆盖率。
  4. 第二月:按业务需要引入 pytest-mock(替换 unittest.mock 的复杂语法)、pytest-xdist(并行执行,CI 时间减半)、业务框架对应的桥接插件(pytest-django / pytest-asyncio 等)。
  5. 持续:自定义团队 marker(smoke / regression / slow),写一份 conftest.py 编码规范,新人入职照着改。

十七、小结

  • pytest 的入门成本是"一个 assert + 一行 pytest",不要被 1300+ 插件的生态规模吓到。
  • 真正决定 pytest 能不能用好的是 fixture 体系:yield 分 setup/teardown、scope 控制生命周期、factory 模式做参数化资源、conftest.py 做跨文件命名空间。
  • parametrize 是数据驱动测试的标配;marker 是测试分组的标配;monkeypatch / tmp_path / capsys 是副作用处理的三件套。
  • pytest 兼容 unittest,老项目可以渐进式迁移,不需要一次性重写。
  • 1300+ 插件是"按需引入",不是"全装上"——内置 fixture 才是 80% 场景的主力。

当你开始给 fixture 配 scope、往 conftest 分层、给用例打 marker,pytest 就不只是"跑测试的命令"了——它变成一套在测试会话层面做编排的工具。这几件事是在同一个层面发生的:scope 决定资源何时建、conftest 决定资源对谁可见、marker 决定用例按什么维度分组。把它们一起想,比单记某个 API 参数更容易用对。


参考链接

参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。