使用 pytest 和 fixture 在 Python 中编写测试

1. 概述

随着测试数量的增加,数据准备部分(创建客户、订单、数据库连接等)往往会在各处重复。pytest 通过 fixture 解决了这个问题:fixture 是一个函数,为测试提供数据或资源,并在测试运行后自动清理。

本文从第一个 pytest 测试开始,讲解带 yield 的 fixture、scope、conftest.py、内置 fixture 和参数化。读完后,你能够组织一套精简、易读、代码重复少的 Python 测试套件。

2. 安装 pytest

你应该在虚拟环境中安装 pytest:

python -m venv .venv
source .venv/bin/activate
pip install pytest

本文中使用的项目结构如下:

shop/
├── shop.py
└── tests/
    ├── conftest.py
    ├── test_order.py
    └── test_repository.py

pytest 自动查找名为 test_*.py 或 *_test.py 的文件。在每个文件中,它运行以 test_ 开头的函数。

要从 tests 目录导入 shop,你需要在 pyproject.toml 中添加配置:

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

3. 第一个测试

这是 shop.py 中的业务代码。价格用 int 类型表示(越南盾),以避免 float 的精度问题:

from dataclasses import dataclass, field


@dataclass
class Customer:
    name: str
    is_vip: bool = False


@dataclass
class Order:
    customer: Customer
    items: list[tuple[str, int, int]] = field(default_factory=list)

    def add_item(self, sku: str, quantity: int, price: int) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.items.append((sku, quantity, price))

    def total(self) -> int:
        subtotal = sum(qty * price for _, qty, price in self.items)
        return subtotal * 90 // 100 if self.customer.is_vip else subtotal

VIP 客户获得 10% 的折扣。在 tests/test_order.py 中对这个逻辑进行测试:

import pytest

from shop import Customer, Order


def test_total_for_regular_customer():
    order = Order(Customer("An"))
    order.add_item("BOOK-01", 2, 50_000)

    assert order.total() == 100_000


def test_add_item_rejects_zero_quantity():
    order = Order(Customer("An"))

    with pytest.raises(ValueError, match="quantity must be positive"):
        order.add_item("BOOK-01", 0, 50_000)

你只需要两个要点:

  • Python 的纯 assert 语句。你不需要 assertEqual 或专门的 assertion 库。pytest 重写 assert 语句来报告详细的错误。
  • pytest.raises 检查一段代码是否抛出正确类型的异常。参数 match 是与异常消息相匹配的正则表达式。

运行测试:

pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED              [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED          [100%]

============================== 2 passed in 0.01s ===============================

如果你故意将预期值改为 90_000,pytest 会打印出两边的值:

>       assert order.total() == 90_000
E       assert 100000 == 90000
E        +  where 100000 = total()

4. 基本 fixture

4.1. 声明和使用 fixture

上面的两个测试都创建了 Customer("An") 和 Order。当有数十个测试时,这段代码会重复很多次。我们将其移到 fixture 中:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)


def test_total_for_regular_customer(order):
    order.add_item("BOOK-01", 2, 50_000)
    assert order.total() == 100_000


def test_empty_order_has_zero_total(order):
    assert order.total() == 0

工作机制:

  • @pytest.fixture 标记一个函数为 fixture。
  • 测试声明与 fixture 同名的参数。pytest 调用 fixture 并将其返回值传入测试。这是按名称进行的依赖注入。
  • fixture order 接收 fixture customer。Fixture 可以依赖其他 fixture,pytest 会自动解决顺序。

默认情况下,每个测试都会获得一个新的实例。因此,第一个测试中的 add_item 不会改变第二个测试的 order。

4.2. Factory fixture

有时一个测试需要多个具有不同参数的订单。fixture 可以返回一个创建对象的函数,而不是返回对象本身:

@pytest.fixture
def make_order():
    def _make(name="An", is_vip=False, items=()):
        order = Order(Customer(name, is_vip))
        for sku, qty, price in items:
            order.add_item(sku, qty, price)
        return order
    return _make


def test_vip_gets_ten_percent_discount(make_order):
    regular = make_order(items=[("BOOK-01", 1, 200_000)])
    vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])

    assert regular.total() == 200_000
    assert vip.total() == 180_000

这种模式使准备部分保持简洁,同时允许测试自己决定数据。

5. 使用 yield 进行 setup 和 teardown

fixture 在处理需要清理的资源(如数据库连接、文件或模拟服务器)时真正有用。在 shop.py 中添加一个使用 SQLite 的 repository:

import sqlite3


class OrderRepository:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    def save(self, customer_name: str, total: int) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (customer, total) VALUES (?, ?)",
            (customer_name, total),
        )
        return cur.lastrowid

    def count(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]

使用 yield 代替 return 的 fixture:

import sqlite3

import pytest

from shop import OrderRepository


@pytest.fixture
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    return OrderRepository(db_connection)


def test_save_order_returns_id(repository):
    order_id = repository.save("An", 100_000)

    assert order_id == 1
    assert repository.count() == 1

yield 前面的部分是 setup。yield 后的值被传入测试。yield 后面的部分是 teardown,总是运行,即使测试失败也是如此。你不需要 try/finally。

标志 --setup-show 显示清楚的顺序:

pytest tests/test_repository.py --setup-show
tests/test_repository.py
        SETUP    F db_connection
        SETUP    F repository (fixtures used: db_connection)
        tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
        TEARDOWN F repository
        TEARDOWN F db_connection

字母 F 表示 scope 为 function。Teardown 按与 setup 相反的顺序运行。

注意: 如果 exception 发生在 yield 之前,teardown 部分将不会运行。原因是 fixture 还没有完成 setup。你应该保持 setup 部分简短,或者如果 setup 包含多个可能出错的步骤,应该自己包装 try/finally。

6. Fixture 的 scope

为每个测试创建昂贵的资源(真实数据库连接、启动容器等)会使测试套件变慢。参数 scope 决定了 fixture 的生命周期:

Scope 何时重新创建
function(默认) 每个测试
class 每个测试类
module 每个测试文件
package 每个包
session 整个运行中一次

例如,对整个文件使用相同的连接,但在每个测试后删除数据:

@pytest.fixture(scope="module")
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    yield OrderRepository(db_connection)
    db_connection.execute("DELETE FROM orders")


def test_first_save(repository):
    repository.save("An", 100_000)
    assert repository.count() == 1


def test_second_save_starts_clean(repository):
    repository.save("Binh", 50_000)
    assert repository.count() == 1

db_connection 只创建一次。repository 的 scope 为 function,因此在每个测试后清理表。这样第二个测试仍然看到一个空表。

有一个重要的规则:fixture 只能使用 scope 相等或更宽的 fixture。如果一个 session scope 的 fixture 使用 function scope 的 fixture,pytest 会报错:

ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.

7. 通过 conftest.py 共享 fixture

Fixture customer 和 make_order 对许多测试文件都很有用。你可以把它们放在 tests/conftest.py 中:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)

pytest 会自动加载 conftest.py。该目录及其子目录中的所有测试都可以使用这些 fixture,不需要导入。你可以在多个目录级别中放置 conftest.py。更靠近测试的级别上的 fixture 会覆盖外层同名的 fixture。

注意: 不要在测试文件中 import conftest。这样做可能会导致 fixture 被注册两次,造成难以预测的行为。

要查看哪些测试使用哪些 fixture 以及这些 fixture 在哪里定义,运行:

pytest --fixtures-per-test tests/test_order.py

8. 内置 fixture

pytest 提供了许多方便的内置 fixture。最常用的三个是 tmp_path、monkeypatch 和 capsys。

假设 shop.py 中还有两个函数:

import os
from pathlib import Path


def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
    lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
    path.write_text("\n".join(lines), encoding="utf-8")


class PaymentService:
    def __init__(self):
        self.api_key = os.environ["PAYMENT_API_KEY"]

    def charge(self, order: "Order") -> str:
        print(f"Charging {order.total()} VND")
        return "SUCCESS"

对它们的测试:

from shop import PaymentService, export_orders_csv


def test_export_orders_csv(tmp_path):
    output = tmp_path / "orders.csv"

    export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)

    assert output.read_text(encoding="utf-8").splitlines() == [
        "customer,total",
        "An,100000",
        "Binh,50000",
    ]


def test_payment_service_charges_order(monkeypatch, capsys, order):
    monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
    order.add_item("BOOK-01", 1, 75_000)

    result = PaymentService().charge(order)

    assert result == "SUCCESS"
    assert capsys.readouterr().out == "Charging 75000 VND\n"
  • tmp_path 是一个 pathlib.Path,指向每个测试私有的临时目录。测试不会污染项目目录。
  • monkeypatch 在单个测试的范围内修改环境变量、属性或函数。所有更改会在测试结束时自动撤销。除了 setenv,你还可以使用 monkeypatch.setattr(obj, "name", value)。
  • capsys 捕获打印到 stdout 和 stderr 的内容。readouterr() 返回从测试开始或上次调用以来打印的内容。

9. 参数化测试

9.1. @pytest.mark.parametrize

当需要用多个数据集测试同一逻辑时,你不应该复制测试。使用 parametrize:

import pytest


@pytest.mark.parametrize(
    ("is_vip", "price", "expected"),
    [
        (False, 100_000, 100_000),
        (True, 100_000, 90_000),
        (True, 15, 13),
    ],
    ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
    order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
    assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED

每个参数集都是一个单独的测试,由于有 ids,ID 易于阅读。注意你可以将 fixture(make_order)与参数结合使用。vip-rounding-down 这个案例清楚地记录了 // 运算的向下舍入行为。

9.2. 带参数的 fixture

如果你想让所有使用某个 fixture 的测试都用多个变体运行,你可以参数化这个 fixture 本身:

@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
    return Customer("An", is_vip=request.param)


def test_total_is_never_negative(any_customer):
    order = Order(any_customer)
    order.add_item("BOOK-01", 3, 10_000)
    assert order.total() >= 0

request 是一个特殊的 fixture。request.param 包含当前的值。上面的测试会自动运行两次。

10. 常见错误和何时不应该使用

  • fixture 名称错误。 参数名称与任何 fixture 都不匹配会导致错误 fixture 'oder' not found。pytest 会立即列出可用的 fixture,仔细阅读它们来修复。
  • 直接调用 fixture。 在测试中写 customer() 会报错 Fixture "customer" called directly。应该将其声明为参数。
  • 宽 scope 与可变数据。 一个 scope 为 module 的 Order,如果被这个测试添加了项目,会导致另一个测试出错。结果取决于运行顺序,非常难以调试。只对昂贵且状态不变的资源使用宽 scope,或者像第 6 节那样在 function scope 的 fixture 中清理状态。
  • 滥用 autouse=True。 自动应用于所有测试的 fixture 很方便,不需要声明,但读者看不到它在哪里。只应该用于真正全局的事情,比如阻止网络访问。
  • fixture 链过深。 当 order 依赖 customer,customer 依赖 address,address 依赖 country 等时,读者必须在多个文件之间跳转才能理解测试。对于简单数据,直接在测试中创建或使用 factory fixture 通常更易读。

注意: Fixture 是准备环境的工具,不是包含检查逻辑的地方。把 assert 放在测试函数中,以便每个测试都能清楚地表达它在检查什么。

11. 结论

你已经涉及了 pytest 的核心内容。纯 assert 和 pytest.raises 用于检查结果。按名称注入的 fixture 有助于消除重复的准备代码。yield 提供安全的 setup/teardown,而 scope 有助于平衡速度和隔离性。conftest.py 在多个文件中共享 fixture。tmp_path、monkeypatch、capsys 等内置 fixture 处理文件、环境和输出。最后,parametrize 和带 params 的 fixture 可以覆盖多个情况,而不需要复制测试。

当测试套件增长时,你应该从 fixture scope function 开始。只有在测量后确认速度确实是问题时,才应该扩大 scope。

pythonpytesttestingfixtureunit-test