用 pytest fixture 组织可维护的测试:告别一堆重复的 setUp
写测试写到后面,你八成会遇到这个场景:二十个测试函数,每个开头都要先建一个数据库连接、塞几条测试数据、跑完再清掉。复制粘贴到第五个函数你就烦了,而且哪天连接方式变了,二十处都得改。pytest 的 fixture 就是专门治这个的------它把「准备环境」和「测试逻辑」彻底分开,而且比 unittest 的 setUp/tearDown 灵活得多。这篇把 fixture 从基础用到进阶讲透。
朴素写法的痛:每个测试都在重复搭台子
假设我们测一个简单的用户仓储类。不用 fixture 时,代码大概长这样:
python
def test_add_user():
db = Database(":memory:") # 每个测试都自己建库
db.init_schema()
repo = UserRepo(db)
repo.add(User(name="Alice"))
assert repo.count() == 1
db.close() # 每个测试都自己收尾
def test_find_user():
db = Database(":memory:") # 又抄一遍
db.init_schema()
repo = UserRepo(db)
repo.add(User(name="Bob"))
found = repo.find_by_name("Bob")
assert found.name == "Bob"
db.close()
问题一目了然:建库那三行、关库那一行,每个测试都在重复。真正的测试逻辑只有中间两三行,却被样板淹没。而且 db.close() 一旦某个测试中途 assert 失败就不会执行,资源泄漏。
fixture 基础:把「搭台子」抽出来
fixture 就是一个用 @pytest.fixture 装饰的函数,它准备好资源,通过 yield 交给测试用,yield 之后的代码就是清理逻辑(相当于 tearDown,而且 assert 失败也照样执行):
python
import pytest
@pytest.fixture
def repo():
db = Database(":memory:")
db.init_schema()
yield UserRepo(db) # yield 出去的东西就是测试拿到的值
db.close() # 测试结束后(哪怕失败)一定执行清理
测试函数只要把 fixture 的名字写进参数,pytest 会自动注入:
python
def test_add_user(repo): # 参数名 == fixture 名,自动注入
repo.add(User(name="Alice"))
assert repo.count() == 1
def test_find_user(repo):
repo.add(User(name="Bob"))
assert repo.find_by_name("Bob").name == "Bob"
现在每个测试只剩纯逻辑。更关键的是:每个测试函数拿到的都是一个全新的 repo 。test_add_user 里加的 Alice 不会污染 test_find_user------因为默认每个测试都会重新执行一遍 fixture。测试之间互相隔离,这是可维护测试的地基。
scope:控制 fixture 多久重建一次
「每个测试都重建」是默认行为(scope="function"),隔离性最好但最慢。如果你的 fixture 很重(比如启动一个真实数据库容器、加载一个大模型),每个测试都重来一遍会慢到没法忍。这时用 scope 调节:
python
@pytest.fixture(scope="session") # 整个测试会话只建一次
def db_engine():
engine = create_engine("postgresql://localhost/test")
yield engine
engine.dispose()
scope 可选值,从窄到宽:
function(默认):每个测试函数重建一次,隔离最好。class:每个测试类重建一次。module:每个.py文件重建一次。session:整个 pytest 运行只建一次。
实战里常见组合 :重的、只读的资源用 session(比如数据库引擎、连接池);会被测试改动的、需要隔离的资源用 function。比如引擎用 session 建一次,但每个测试开一个会回滚的事务来保证隔离:
python
@pytest.fixture(scope="session")
def db_engine():
engine = create_engine("postgresql://localhost/test")
yield engine
engine.dispose()
@pytest.fixture # function 级,每个测试一个干净事务
def db_session(db_engine): # fixture 可以依赖另一个 fixture
conn = db_engine.connect()
trans = conn.begin()
session = Session(bind=conn)
yield session
session.close()
trans.rollback() # 回滚,测试写入的数据不落库,天然隔离
conn.close()
注意 db_session 把 db_engine 当参数------fixture 之间可以互相依赖,pytest 会自动按依赖顺序构建。这是 fixture 比 setUp 强大的地方:setUp 只能是一整块,fixture 可以像积木一样组合。
conftest.py:让 fixture 跨文件共享
fixture 写在测试文件里,只能本文件用。当多个测试文件都要用同一个 db_session,把它挪到 conftest.py------这是 pytest 的约定文件,放在测试目录下,同目录及子目录的所有测试自动能用里面的 fixture,不需要 import:
tests/
├── conftest.py # 放公共 fixture:db_engine, db_session
├── test_users.py # 直接用 db_session,不用 import
└── api/
├── conftest.py # 放 api 专用 fixture:client
└── test_orders.py # 能用上层的 db_session + 本层的 client
python
# tests/conftest.py
import pytest
@pytest.fixture
def db_session(db_engine):
... # 同上
子目录的 conftest.py 可以有自己的 fixture,也能覆盖上层同名 fixture。这种分层让公共的放上面、专用的放下面,结构清晰。
进阶:参数化 fixture,一份逻辑测多种输入
有时你想用多组数据 跑同一个测试。fixture 的 params 能做到------pytest 会为每个参数值把测试跑一遍:
python
@pytest.fixture(params=["sqlite", "postgres"])
def repo(request): # request 是内置对象,request.param 拿到当前参数
if request.param == "sqlite":
db = Database(":memory:")
else:
db = Database("postgresql://localhost/test")
db.init_schema()
yield UserRepo(db)
db.close()
def test_add_user(repo):
repo.add(User(name="Alice"))
assert repo.count() == 1
这一个 test_add_user 会自动跑两次:一次用 SQLite、一次用 Postgres。写一份测试逻辑,覆盖多种后端。
一个常见误区:别用 return 代替 yield 还想清理
新手常犯的错:fixture 里用 return 而不是 yield。
python
# 反例:return 之后没法写清理逻辑
@pytest.fixture
def repo():
db = Database(":memory:")
db.init_schema()
return UserRepo(db) # 用 return 也能注入,但 db 永远不会 close
return 版本能用,但 return 之后的代码在 fixture 里不会有清理时机------数据库连接泄漏。只要资源需要清理(关连接、删临时文件、停容器),就用 yield ,把清理写在 yield 之后。不需要清理的纯数据 fixture(比如返回一个固定字典)用 return 才合适。
小结
- fixture 把「准备环境」从测试逻辑里剥离,用
@pytest.fixture+ 参数注入,测试函数只留纯逻辑。 yield前是准备、后是清理,且清理在测试失败时也会执行,比setUp/tearDown更可靠;纯数据用return,需清理用yield。- 默认
functionscope 保证测试间隔离;重资源用session建一次 +function级事务回滚兼顾速度与隔离。 - fixture 可以互相依赖、像积木一样组合;公共 fixture 放
conftest.py,同目录及子目录免 import 直接用。 params让一份测试逻辑跑多组输入,配合request.param覆盖多种后端。- 一句话记忆点:fixture 管「测试需要什么环境」,测试函数只管「验证什么行为」,两者一分开,测试就好维护了。