用 pytest fixture 组织可维护的测试:告别一堆重复的 setUp

📅 2026/7/15 11:05:46 👁️ 阅读次数 📝 编程学习
用 pytest fixture 组织可维护的测试:告别一堆重复的 setUp

用 pytest fixture 组织可维护的测试:告别一堆重复的 setUp

写测试写到后面,你八成会遇到这个场景:二十个测试函数,每个开头都要先建一个数据库连接、塞几条测试数据、跑完再清掉。复制粘贴到第五个函数你就烦了,而且哪天连接方式变了,二十处都得改。pytest 的 fixture 就是专门治这个的——它把「准备环境」和「测试逻辑」彻底分开,而且比 unittest 的setUp/tearDown灵活得多。这篇把 fixture 从基础用到进阶讲透。

朴素写法的痛:每个测试都在重复搭台子

假设我们测一个简单的用户仓储类。不用 fixture 时,代码大概长这样:

deftest_add_user():db=Database(":memory:")# 每个测试都自己建库db.init_schema()repo=UserRepo(db)repo.add(User(name="Alice"))assertrepo.count()==1db.close()# 每个测试都自己收尾deftest_find_user():db=Database(":memory:")# 又抄一遍db.init_schema()repo=UserRepo(db)repo.add(User(name="Bob"))found=repo.find_by_name("Bob")assertfound.name=="Bob"db.close()

问题一目了然:建库那三行、关库那一行,每个测试都在重复。真正的测试逻辑只有中间两三行,却被样板淹没。而且db.close()一旦某个测试中途 assert 失败就不会执行,资源泄漏。

fixture 基础:把「搭台子」抽出来

fixture 就是一个用@pytest.fixture装饰的函数,它准备好资源,通过yield交给测试用,yield之后的代码就是清理逻辑(相当于 tearDown,而且 assert 失败也照样执行):

importpytest@pytest.fixturedefrepo():db=Database(":memory:")db.init_schema()yieldUserRepo(db)# yield 出去的东西就是测试拿到的值db.close()# 测试结束后(哪怕失败)一定执行清理

测试函数只要把 fixture 的名字写进参数,pytest 会自动注入:

deftest_add_user(repo):# 参数名 == fixture 名,自动注入repo.add(User(name="Alice"))assertrepo.count()==1deftest_find_user(repo):repo.add(User(name="Bob"))assertrepo.find_by_name("Bob").name=="Bob"

现在每个测试只剩纯逻辑。更关键的是:每个测试函数拿到的都是一个全新的repotest_add_user里加的 Alice 不会污染test_find_user——因为默认每个测试都会重新执行一遍 fixture。测试之间互相隔离,这是可维护测试的地基。

scope:控制 fixture 多久重建一次

「每个测试都重建」是默认行为(scope="function"),隔离性最好但最慢。如果你的 fixture 很重(比如启动一个真实数据库容器、加载一个大模型),每个测试都重来一遍会慢到没法忍。这时用scope调节:

@pytest.fixture(scope="session")# 整个测试会话只建一次defdb_engine():engine=create_engine("postgresql://localhost/test")yieldengine engine.dispose()

scope 可选值,从窄到宽:

  • function(默认):每个测试函数重建一次,隔离最好。
  • class:每个测试类重建一次。
  • module:每个.py文件重建一次。
  • session:整个 pytest 运行只建一次。

实战里常见组合:重的、只读的资源用session(比如数据库引擎、连接池);会被测试改动的、需要隔离的资源用function。比如引擎用 session 建一次,但每个测试开一个会回滚的事务来保证隔离:

@pytest.fixture(scope="session")defdb_engine():engine=create_engine("postgresql://localhost/test")yieldengine engine.dispose()@pytest.fixture# function 级,每个测试一个干净事务defdb_session(db_engine):# fixture 可以依赖另一个 fixtureconn=db_engine.connect()trans=conn.begin()session=Session(bind=conn)yieldsession session.close()trans.rollback()# 回滚,测试写入的数据不落库,天然隔离conn.close()

注意db_sessiondb_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
# tests/conftest.pyimportpytest@pytest.fixturedefdb_session(db_engine):...# 同上

子目录的conftest.py可以有自己的 fixture,也能覆盖上层同名 fixture。这种分层让公共的放上面、专用的放下面,结构清晰。

进阶:参数化 fixture,一份逻辑测多种输入

有时你想用多组数据跑同一个测试。fixture 的params能做到——pytest 会为每个参数值把测试跑一遍:

@pytest.fixture(params=["sqlite","postgres"])defrepo(request):# request 是内置对象,request.param 拿到当前参数ifrequest.param=="sqlite":db=Database(":memory:")else:db=Database("postgresql://localhost/test")db.init_schema()yieldUserRepo(db)db.close()deftest_add_user(repo):repo.add(User(name="Alice"))assertrepo.count()==1

这一个test_add_user会自动跑两次:一次用 SQLite、一次用 Postgres。写一份测试逻辑,覆盖多种后端。

一个常见误区:别用 return 代替 yield 还想清理

新手常犯的错:fixture 里用return而不是yield

# 反例:return 之后没法写清理逻辑@pytest.fixturedefrepo():db=Database(":memory:")db.init_schema()returnUserRepo(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 管「测试需要什么环境」,测试函数只管「验证什么行为」,两者一分开,测试就好维护了。