pytest 9.2 实战:fixture、参数化,和三条真实失败输出
写完一个函数想验证它,最省事的做法是 assert 加一个 if __name__ == "__main__"。用例一多就会需要:每个用例独立的临时目录、共用一次数据库连接、按参数展开同一段逻辑、只跑某几个用例。pytest 给的是这些,加上失败时把表达式的中间值打出来。如果项目只有一个脚本、三行断言,标准库的 unittest 或者干脆一个 assert 就够,装框架的收益要到几十个用例之后才显出来。
安装与实际版本
python -m venv D:\ai\cache\temp\py-pytest
D:\ai\cache\temp\py-pytest\Scripts\python.exe -m pip install pytest
Name: pytest
Version: 9.2.0
Location: D:\ai\cache\temp\py-pytest\Lib\site-packages
pytest --version 与运行时的头部信息一致,pluggy 是 1.7.0:
platform win32 -- Python 3.11.9, pytest-9.2.0, pluggy-1.7.0 -- D:\ai\cache\temp\py-pytest\Scripts\python.exe
最小可运行示例
被测代码是一个购物车,加两个 fixture:一个给每个用例独立的 SQLite 文件,一个给裸连接。
# cart.py
import sqlite3
def parse_qty(raw: str) -> int:
if not raw.strip():
raise ValueError("空字符串不是合法数量")
return int(raw)
class Cart:
def __init__(self, db_path):
self.conn = sqlite3.connect(db_path)
self.conn.execute("create table if not exists items (name text, qty int)")
def add(self, name: str, qty: int) -> None:
self.conn.execute("insert into items values (?, ?)", (name, qty))
self.conn.commit()
def total(self) -> int:
row = self.conn.execute("select coalesce(sum(qty), 0) from items").fetchone()
return row[0]
def close(self):
self.conn.close()
# tests/conftest.py
import sqlite3
import pytest
from cart import Cart
@pytest.fixture()
def cart(tmp_path):
c = Cart(tmp_path / "cart.db")
yield c
c.close()
@pytest.fixture()
def conn(tmp_path):
c = sqlite3.connect(tmp_path / "raw.db")
c.execute("create table t (x int)")
c.execute("insert into t values (1), (2)")
c.commit()
yield c
c.close()
# tests/test_cart.py
import pytest
from cart import parse_qty
def test_parse_qty_ok():
assert parse_qty(" 12 ") == 12
@pytest.mark.parametrize("raw,expected", [("1", 1), ("10", 10), ("0", 0)])
def test_parse_qty_param(raw, expected):
assert parse_qty(raw) == expected
def test_parse_qty_empty_raises():
with pytest.raises(ValueError, match="空字符串"):
parse_qty(" ")
def test_cart_total_uses_fixture(cart):
cart.add("苹果", 3)
cart.add("梨", 2)
assert cart.total() == 5
def test_float_compare():
assert 0.1 + 0.2 == 0.3
def test_float_approx():
assert 0.1 + 0.2 == pytest.approx(0.3)
跑起来:
cd D:\ai\cache\temp\work\pytest_proj
D:\ai\cache\temp\py-pytest\Scripts\python.exe -m pytest -v
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-9.2.0, pluggy-1.7.0 -- D:\ai\cache\temp\py-pytest\Scripts\python.exe
cachedir: .pytest_cache
rootdir: D:\ai\cache\temp\work\pytest_proj
collecting ... collected 15 items
tests/test_cart.py::test_parse_qty_ok PASSED [ 6%]
tests/test_cart.py::test_parse_qty_param[1-1] PASSED [ 13%]
tests/test_cart.py::test_parse_qty_param[10-10] PASSED [ 20%]
tests/test_cart.py::test_parse_qty_param[0-0] PASSED [ 26%]
tests/test_cart.py::test_parse_qty_empty_raises PASSED [ 33%]
tests/test_cart.py::test_cart_total_uses_fixture PASSED [ 40%]
tests/test_cart.py::test_tmp_path PASSED [ 46%]
tests/test_cart.py::test_float_compare FAILED [ 53%]
tests/test_cart.py::test_float_approx PASSED [ 60%]
tests/test_cart.py::test_raises_wrong_exception FAILED [ 66%]
tests/test_cart.py::test_db_with_conn PASSED [ 73%]
tests/test_cart.py::test_not_implemented_yet SKIPPED (留作后续补测) [ 80%]
tests/test_cart.py::test_add_many[1] PASSED [ 86%]
tests/test_cart.py::test_add_many[2] PASSED [ 93%]
tests/test_cart.py::test_add_many[3] PASSED [100%]
=================== 2 failed, 12 passed, 1 skipped in 0.19s ===================
collected 15 items 里有 3 个来自参数化(test_parse_qty_param 三组、test_add_many 三组),参数化在收集阶段就展开,所以百分比是逐用例算的。
常用参数与 fixture
| 名称 | 作用 | 要点 |
|---|---|---|
-v / -q | 详细/安静 | -q 只打进度点,CI 日志更短 |
-k "expr" | 按名字筛选用例 | 支持 and/not,-k "parse and not empty" |
-x | 第一个失败就停 | 本地改代码时省时间 |
--tb=short / --tb=line | 改变回溯长度 | --tb=line 一行一条,适合扫 |
tmp_path | 每个用例独立的临时目录 | 作用域是 function,pathlib.Path |
capsys / caplog | 捕获输出/日志 | 断言 print 与日志内容 |
monkeypatch | 临时改属性/环境变量 | 用例结束自动还原 |
@pytest.mark.parametrize | 参数化 | 参数名与用例签名对应 |
@pytest.fixture(scope=) | fixture 作用域 | function/module/session,跨作用域引用会报错 |
pytest.raises(Exc, match=) | 断言抛异常 | match 是正则,匹配异常消息 |
三条真实失败输出
浮点比较失败时,pytest 会重写断言并给出中间值:
_____________________________ test_float_compare ______________________________
def test_float_compare():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
tests\test_cart.py:36: AssertionError
它没有直接算出左边等于多少,因为 0.1 + 0.2 是个表达式而不是变量;把左侧先赋给变量,输出里就会带上 where 0.30000000000000004 = ... 那一行。比较浮点用 pytest.approx,同一份数据就过了。
pytest.raises 的异常类型写错时,用例失败而不是跳过:
_________________________ test_raises_wrong_exception _________________________
def test_raises_wrong_exception():
with pytest.raises(KeyError):
> parse_qty("")
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
raw = ''
def parse_qty(raw: str) -> int:
if not raw.strip():
> raise ValueError("空字符串不是合法数量")
E ValueError: 空字符串不是合法数量
cart.py:6: ValueError
期望 KeyError、实际抛 ValueError,pytest 把真实异常原样抛出,回溯指到 cart.py:6,中间还带了 raw = '' 这个局部变量。
fixture 的作用域写错时,用例在 setup 阶段就报错,连函数体都没进:
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Requesting fixture stack:
tests\test_scope.py:4: def shared(tmp_path)
Requested fixture:
..\..\py-pytest\Lib\site-packages\_pytest\tmpdir.py:328: def tmp_path(request: 'FixtureRequest', tmp_path_factory: 'TempPathFactory') -> 'Generator[Path]'
session 作用域的 fixture 想用 tmp_path(function 作用域),生命周期比它短,pytest 直接拒绝。要大范围的临时目录用 tmp_path_factory。
fixture 名字写错时,报错里会列出所有可用的:
E fixture 'cart2' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, conn, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, shared, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.
这份列表比翻文档快,也能顺便看出 conftest.py 有没有被加载(cart、conn 在列表里就是在)。
少跑和无用的用例
-k 按名字筛,配合 -q 输出很短:
4 passed, 11 deselected in 0.01s
[ 6%] 这类百分比和总收集数相关,-k 之后总数变了,百分比也会变。CI 里想固定输出格式就加 --tb=short -q,别依赖百分比。
小结
pytest 的收益集中在两处:fixture 把"每个用例独立的环境"变成参数,参数化把"同一段逻辑换输入"变成装饰器。上面 15 个用例里,cart 和 conn 两个 fixture 各自管一个临时 SQLite 文件,用例之间没有顺序依赖。
失败的三种形态值得记住:断言失败会给中间值和精确行号,pytest.raises 类型不符会把真实异常原样抛出,fixture 出问题则停在 setup 阶段并列出可用名字。看报错停在哪一段,比读回溯全文更快。
数据来源与检索时间
检索时间 2026-11-10。用例执行与报错文本为本机实测。
| 来源 | URL | 对应数据 |
|---|---|---|
| pytest 官方文档《Get Started》 | https://docs.pytest.org/en/stable/getting-started.html | 文档中 pytest --version 输出为 9.2.0;虚拟环境安装方式;pytest.raises 用法;pytest.approx 的用途与适用类型;tmp_path 是 per-test 的临时目录;pytest --fixtures 列出可用 fixture;断言重写(assertion introspection)的机制 |
| PyPI《pytest》项目页 | https://pypi.org/project/pytest/ | 包名、安装方式与当前版本 |
| 本机运行记录 | D:\ai\cache\temp\work\pytest_proj | 9.2.0 / pluggy 1.7.0;15 个用例的完整 -v 输出;浮点断言、pytest.raises 类型不符、ScopeMismatch、fixture 未定义四段原文;-k 的 4 passed / 11 deselected;--tb=line 输出 |
核验说明
- 虚拟环境
D:\ai\cache\temp\py-pytest,Python 3.11.9,pip install pytest装到 9.2.0。 - 真跑的示例:一个含 SQLite 的购物车模块,两个 fixture,参数化、
pytest.raises、tmp_path、skip各一组,-v、-q -k、--tb=line三种调用,以及故意制造的ScopeMismatch与 fixture 名错误。 - 文章里的
tests/test_cart.py只摘了关键用例,本机文件里还有test_tmp_path、test_db_with_conn、test_add_many、test_not_implemented_yet四个,所以-v输出是 15 项。 - 没测的部分:
conftest.py的分层覆盖、pytest-xdist并行、pytest-cov覆盖率、capsys/caplog/monkeypatch的实际用法、插件开发、pyproject.toml里的[tool.pytest.ini_options]配置。这些都没有在本机验证。