KernelRanch
返回文章列表

pytest 9.2 实战:fixture、参数化,和三条真实失败输出

2026-11-10 #Python

写完一个函数想验证它,最省事的做法是 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_proj9.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] 配置。这些都没有在本机验证。