用 pytest fixture 组织可维护的测试:告别一堆重复的 setUp
用 pytest fixture 组织可维护的测试:告别一堆重复的 setUp写测试写到后面,你八成会遇到这个场景:二十个测试函数,每个开头都要先建一个数据库连接、塞几条测试数据、跑完再清掉。复制粘贴到第五个函数你就烦了,而且哪天连接方式变了,二十处都得改。pytest 的 fixture 就是专门治这个的——它把「准备环境」和「测试逻辑」彻底分开,而且比 unittest 的setUp/tearDown灵活得多。这篇把 fixture 从基础用到进阶讲透。朴素写法的痛:每个测试都在重复搭台子假设我们测一个简单的用户仓储类。不用 fixture 时,代码大概长这样:deftest_add_user():dbDatabase(:memory:)# 每个测试都自己建库db.init_schema()repoUserRepo(db)repo.add(User(nameAlice))assertrepo.count()1db.close()# 每个测试都自己收尾deftest_find_user():dbDatabase(:memory:)# 又抄一遍db.init_schema()repoUserRepo(db)repo.add(User(nameBob))foundrepo.find_by_name(Bob)assertfound.nameBobdb.close()问题一目了然:建库那三行、关库那一行,每个测试都在重复。真正的测试逻辑只有中间两三行,却被样板淹没。而且db.close()一旦某个测试中途 assert 失败就不会执行,资源泄漏。fixture 基础:把「搭台子」抽出来fixture 就是一个用pytest.fixture装饰的函数,它准备好资源,通过yield交给测试用,yield之后的代码就是清理逻辑(相当于 tearDown,而且 assert 失败也照样执行):importpytestpytest.fixturedefrepo():dbDatabase(:memory:)db.init_schema()yieldUserRepo(db)# yield 出去的东西就是测试拿到的值db.close()# 测试结束后(哪怕失败)一定执行清理测试函数只要把 fixture 的名字写进参数,pytest 会自动注入:deftest_add_user(repo):# 参数名 fixture 名,自动注入repo.add(User(nameAlice))assertrepo.count()1deftest_find_user(repo):repo.add(User(nameBob))assertrepo.find_by_name(Bob).nameBob现在每个测试只剩纯逻辑。更关键的是:每个测试函数拿到的都是一个全新的repo。test_add_user里加的 Alice 不会污染test_find_user——因为默认每个测试都会重新执行一遍 fixture。测试之间互相隔离,这是可维护测试的地基。scope:控制 fixture 多久重建一次「每个测试都重建」是默认行为(scopefunction),隔离性最好但最慢。如果你的 fixture 很重(比如启动一个真实数据库容器、加载一个大模型),每个测试都重来一遍会慢到没法忍。这时用scope调节:pytest.fixture(scopesession)# 整个测试会话只建一次defdb_engine():enginecreate_engine(postgresql://localhost/test)yieldengine engine.dispose()scope 可选值,从窄到宽:function(默认):每个测试函数重建一次,隔离最好。class:每个测试类重建一次。module:每个.py文件重建一次。session:整个 pytest 运行只建一次。实战里常见组合:重的、只读的资源用session(比如数据库引擎、连接池);会被测试改动的、需要隔离的资源用function。比如引擎用 session 建一次,但每个测试开一个会回滚的事务来保证隔离:pytest.fixture(scopesession)defdb_engine():enginecreate_engine(postgresql://localhost/test)yieldengine engine.dispose()pytest.fixture# function 级,每个测试一个干净事务defdb_session(db_engine):# fixture 可以依赖另一个 fixtureconndb_engine.connect()transconn.begin()sessionSession(bindconn)yieldsession 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# tests/conftest.pyimportpytestpytest.fixturedefdb_session(db_engine):...# 同上子目录的conftest.py可以有自己的 fixture,也能覆盖上层同名 fixture。这种分层让公共的放上面、专用的放下面,结构清晰。进阶:参数化 fixture,一份逻辑测多种输入有时你想用多组数据跑同一个测试。fixture 的params能做到——pytest 会为每个参数值把测试跑一遍:pytest.fixture(params[sqlite,postgres])defrepo(request):# request 是内置对象,request.param 拿到当前参数ifrequest.paramsqlite:dbDatabase(:memory:)else:dbDatabase(postgresql://localhost/test)db.init_schema()yieldUserRepo(db)db.close()deftest_add_user(repo):repo.add(User(nameAlice))assertrepo.count()1这一个test_add_user会自动跑两次:一次用 SQLite、一次用 Postgres。写一份测试逻辑,覆盖多种后端。一个常见误区:别用 return 代替 yield 还想清理新手常犯的错:fixture 里用return而不是yield。# 反例:return 之后没法写清理逻辑pytest.fixturedefrepo():dbDatabase(:memory:)db.init_schema()returnUserRepo(db)# 用 return 也能注入,但 db 永远不会 closereturn版本能用,但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 管「测试需要什么环境」,测试函数只管「验证什么行为」,两者一分开,测试就好维护了。