使用 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接收 fixturecustomer。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 节那样在functionscope 的 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。