ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Pytest Fixture 用 return 还是 yield?一文讲透执行机制与选型实践

Pytest Fixture 用 return 还是 yield?一文讲透执行机制与选型实践 最近在代码评审里我又看到有同事纠结一个问题“Pytest Fixture 到底该用 return 还是 yield” 这不是第一次了几乎每个把 pytest 用上一两个月的同学都会在 fixture 的写法上卡一下。很多教程只告诉你 yield 可以用来做 teardown但没把两者的执行机制、适用场景、以及容易踩的坑讲透。这篇文章我就结合自己平时的项目经验把 return 写法和 yield 写法从头到尾捋一遍尽量说清楚什么时候用哪个更合适。如果你刚接触 pytest或者已经在接口自动化、Web 自动化里面大量使用 fixture 但偶尔被奇怪的现象困住这篇文章应该能帮你省点时间。1. 先从最基础的问题说起Fixture 到底是什么1.1 一个最简单的 fixture 例子先用最小例子建立共同认知。fixture 在 pytest 里就是加了一个pytest.fixture装饰器的函数它的返回值会注入到测试函数参数里。看这段代码import pytest pytest.fixture def user_data(): return {name: zhangsan, age: 18} def test_user(user_data): assert user_data[name] zhangsantest_user的参数里写了user_datapytest 执行测试前会先调用user_data()这个函数把返回值当作user_data参数传进去。这是最常规、也最好理解的方式fixture 就是给测试准备数据的函数。但实际项目的 fixture 往往没那么纯。有的需要创建临时文件有的需要连数据库有的需要启动一个浏览器实例。这些东西用完之后得清理文件要删连接要关浏览器要退出。这时候问题就来了fixture 函数里如果直接return那测试执行完之后谁来负责做清理动作pytest 给出的答案是把 fixture 写成生成器函数用yield代替returnyield 之前是准备阶段yield 之后是清理阶段。比如import pytest import os pytest.fixture def temp_file(): path /tmp/test_file.txt with open(path, w) as f: f.write(hello) # 到这里测试所需的资源已经准备好了 yield path # 测试结束后会执行下面的清理 if os.path.exists(path): os.remove(path) def test_read_file(temp_file): with open(temp_file, r) as f: assert f.read() hello在这个例子里yield path把文件路径交给测试函数yield后面的代码会在测试结束之后补执行。这就是 return 和 yield 最直观的区别。1.2 fixture 解决的核心问题不只是“给数据”我以前带人的时候发现很多新手以为 fixture 只是“替代 setup/teardown 的函数”这个理解太窄了。fixture 真正解决的是三个问题数据复用、依赖注入、生命周期管理。数据复用很好理解同一个 fixture 可以被多个测试共用而且 pytest 默认每个测试执行前都会重新调用一次 fixture保证互相之间不污染。依赖注入是指测试函数声明需要什么东西pytest 就自动提供什么完全不用在测试内部手动初始化。生命周期管理则是 yield 的强项它把“准备资源”和“清理资源”包在同一个函数里代码的内聚性比传统的setup_methodteardown_method高很多。对比一下传统 unittest 风格class TestFile: def setup_method(self): self.path /tmp/test_file.txt with open(self.path, w) as f: f.write(hello) def teardown_method(self): os.remove(self.path) def test_read(self): with open(self.path, r) as f: assert f.read() hello这段逻辑上是能跑的但如果一个文件同时被多个测试需要或者某些测试想要不同路径setup/teardown 就开始变得笨重。fixture 可以把数据生产、资源创建、清理逻辑全部封装在一个函数里还支持参数化、作用域控制比手写 setup/teardown 要优雅得多。1.3 为什么 return 和 yield 会成为“灵魂拷问”因为很多人的困惑来自于不清理行不行有些资源是临时的系统自己会回收比如 Python 对象有些资源不会被自动回收比如数据库连接、临时文件、网络端口。pytest 官方文档里对 yield fixture 有明确的定位它主要用于“资源获取-释放”模式类似上下文管理器但文档不会直接告诉你“这个场景该用 return那个场景该用 yield”。于是大家都去用 yield结果发现到处是 yield 反而更麻烦代码变长、作用域理解不对时清理不执行、参数化的时候容易搞混。也有人一律 return然后在测试函数里手动清理结果到处是 try/finallyfixture 的优势全丢了。所以这个问题的本质不是“哪个写法更高端”而是“你到底需不需要在测试结束后执行一段清理逻辑”。2. return 和 yield 在执行机制上到底差在哪2.1 return 写法的执行流程一个普通 fixture 函数如果用returnpytest 会把它当成一个普通的工厂函数调用它、拿到返回值、把返回值注入测试。整个流程非常简单pytest 发现测试函数需要某个 fixture。pytest 按规则查找这个 fixture 函数。pytest 调用 fixture 函数。得到返回值后把它作为测试参数执行测试。测试结束pytest 不再对这个 fixture 做任何额外处理。这个过程没有“清理阶段”也没有“fixture 结束后的回调”。如果你确实要清理资源要么在测试函数内部做要么把资源对象设计成自带清理机制比如with语句内部处理要么直接将 fixture 改为 yield 写法。很多新手会踩一个误区以为对象被垃圾回收了就会自动调用析构函数文件对象也会自动关闭。但 Python 的垃圾回收时机是不确定的而且数据库连接往往有连接池连接对象被回收不代表连接真的关了。把依赖“自动清理”是不可靠的设计。2.2 yield 写法的执行流程yield fixture 的执行流程稍微复杂一点但原理并不高深。你可以把 fixture 函数理解成一个“可暂停的生成器函数”pytest 调用 fixture 函数。代码执行到yield 值此时函数暂停yield后面的值被当作 fixture 的返回值。pytest 把返回值注入测试函数执行测试。测试结束包括断言失败、异常抛出pytest 会继续执行 fixture 函数中yield之后的代码。yield之后的代码就是这个 fixture 的清理阶段无论测试成功还是失败它都会被执行。这种机制很像 Python 内置的上下文管理器。你可以类比with open(...) as f:with块结束时会自动关闭文件。yield fixture 把“进入上下文”和“退出上下文”包在同一个函数里只是退出点挪到了yield后面。看一个更贴合实际场景的例子import pytest import sqlite3 pytest.fixture def db(): conn sqlite3.connect(:memory:) conn.execute(CREATE TABLE users (id INTEGER, name TEXT)) yield conn conn.close() def test_insert_user(db): db.execute(INSERT INTO users (id, name) VALUES (1, lisi)) count db.execute(SELECT COUNT(*) FROM users).fetchone()[0] assert count 1dbfixture 创建了一个内存数据库连接yield 把连接对象交给测试。测试结束后连接会被关闭。如果测试里断言失败pytest 依然会执行conn.close()这一点非常关键。2.3 Setup 和 Teardown 的映射关系如果你用过 pytest 的setup_module和teardown_module可以把 yield fixture 看成一种更灵活的前置后置写法传统写法yield fixture 写法setup_module()中准备资源fixture 函数中yield之前的代码pytest 执行测试测试函数体teardown_module()中清理资源fixture 函数中yield之后的代码从代码组织上看传统写法把准备和清理拆成了两个函数逻辑分散。yield fixture 用一个小函数把准备和清理压缩在一起代码行数更少结构也更清晰。尤其是当 fixture 需要按作用域执行时yield 的优势会更明显一个session级 fixture就可以在全部测试开始前连接一次数据库全部测试结束后统一关闭不需要你手动去记什么先后顺序。3. 到底该怎么选我总结的四个判断标准3.1 只需要“给数据”优先用 return如果你的 fixture 只是向测试提供数据、配置、模拟对象而这些数据不占用操作系统资源、不需要额外释放那就用 return。常见场景有返回一个字典、列表、字符串等测试数据。返回一个配置对象或者一个命名元组。返回一个纯内存模拟对象比如 mock 出来的服务。返回一个从数据库查询出来的结果但连接本身在更外层管理。我自己的经验是在接口自动化项目里很多 fixture 只是准备请求体、登录 token、环境配置这些数据用完即弃完全不需要清理一律写 return 就好。硬写成 yield 反而会让代码多一层缩进阅读的时候还要区分哪段是 setup、哪段是 teardown徒增理解成本。3.2 需要“用完清理”果断用 yield一旦 fixture 涉及外部资源或者对测试环境有“副作用”就必须用 yield。我列几个平时最常遇到的临时文件测试结束后需要删除。数据库连接/会话测试结束后需要 close 或 rollback。浏览器驱动自动化结束后需要 quit。网络服务测试时需要启动一个本地 HTTP 服务结束后要停掉。环境变量测试时改动了os.environ结束后要恢复原状。当前工作目录测试时切到了别的目录结束后要切回去。这些共同特点都是“创建资源容易忘记释放会出问题”。用 yield 可以保证清理代码一定会执行哪怕测试抛异常。比如pytest.fixture def set_env(): old_value os.environ.get(APP_MODE) os.environ[APP_MODE] test yield if old_value is None: os.environ.pop(APP_MODE, None) else: os.environ[APP_MODE] old_value注意这个 fixture 的yield后面没有跟任何值。那测试函数里怎么接收呢不接收它直接以副作用方式工作一般配合pytest.mark.usefixtures(set_env)使用。这也是 yield fixture 的一个重要形态不一定要返回东西yield 本身只是划分准备和清理的分界线。3.3 特殊场景scope、autouse、session 级 fixture有些场景不是简单的“给数据”或“要清理”而是需要在整个测试生命周期里控制某件事。比如一个 session 级的 fixture希望整个测试运行期间只连接一次数据库最后统一关闭。这时候 return 明显不合适因为没法在最后执行关闭逻辑。用 yield 就很自然pytest.fixture(scopesession) def db_engine(): engine create_db_engine() yield engine engine.dispose()再看 autouse 场景。autouse fixture 会自动作用于匹配的测试一般用来做全局的前置后置逻辑。比如统计用例开始时间、在用例结束后收集测试报告数据。这种场景通常不需要返回值但必须保证测试结束后执行额外代码所以用 yield 是标准做法pytest.fixture(autouseTrue) def timing(): start time.time() yield duration time.time() - start log_duration(duration)还有一种情况是yield后面跟多个值比如一个 fixture 想同时返回两个对象。严格来说 fixture 只能返回一个对象但你可以 yield 一个元组测试函数里解包或者 yield 一个自定义对象。3.4 一张速查表帮你做决定我根据实际使用经验整理了一张选型口诀表你对照着判断就行。场景推荐写法原因只返回不可变数据、字典、列表return没有清理需求返回 mock 对象或模拟服务returnmock 由框架自动管理不需要手动清理创建临时文件或目录yield测试结束要删除避免残留创建数据库连接yield连接需要关闭避免占用连接池启动网络服务或浏览器yield进程和端口需要释放修改环境变量、工作目录yield清理阶段要恢复原状态只做前置准备不需要返回值用 yield 或不用 fixture若无清理可不用 fixture若有清理必须 yield全局 session 级资源yield在所有测试后统一释放判断核心就一句话你能确定资源会随着 Python 解释器退出而自动清理干净吗如果不能就用 yield。这句话我基本每带一个新人都会讲一遍能解决绝大多数选择困难。4. 实操细节从 return 改成 yield以及参数化里的坑4.1 从 return 改成 yield 的最小改造很多项目里早期用 return现在想改成 yield其实改动非常小。拿最开始的文件路径例子来演示改造前pytest.fixture def temp_file(tmp_path): f tmp_path / data.txt f.write_text(hello) return str(f)改造后pytest.fixture def temp_file(tmp_path): f tmp_path / data.txt f.write_text(hello) yield str(f) f.unlink(missing_okTrue)如果你只是想加一个清理逻辑直接把return xxx改成yield xxx然后把清理代码放到后面就行。测试函数里的使用方式完全不变因为 fixture 注入给测试的还是同一个值。唯一要留意的是函数一旦要用yield它本质上就变成了生成器函数函数内部不能再用return 带值的写法否则会变成返回一个生成器对象而不是yield出来的那个值这个细节很容易踩。4.2 yield 前后代码的执行顺序决定了你的“准备阶段”放哪yield 之前的代码会在测试开始前完整执行yield 之后的代码会在测试结束后执行。但有一点很多人没注意测试时如果某个 fixture 的清理代码依赖另一个 fixture 的数据顺序会变得比较复杂。pytest 在处理 fixture 依赖时是有一个“栈”的。比如pytest.fixture def a(): print(setup a) yield a print(teardown a) pytest.fixture def b(a): print(setup b) yield bb print(teardown b) def test_x(a, b): print(run test)执行顺序是setup asetup brun testteardown bteardown a也就是后创建的先清理依赖方比被依赖方先清理。这一点在你写带连接的 fixture 时很重要。比如一个 fixture 创建了数据库连接另一个 fixture 创建了基于连接的事务那清理顺序应该是先回滚事务再关闭连接不能反过来。只要让事务 fixture 依赖连接 fixturepytest 就会自动保证正确的清理顺序。4.3 参数化 fixture 时 yield 要注意的事pytest 的参数化有两个层面一种是pytest.mark.parametrize参数化测试用例一种是pytest.fixture(params[...])参数化 fixture。两者都会让 fixture 被多次执行。如果用 yield 写参数化 fixture会发现每个参数值都会走一遍完整的准备-清理流程这时候一定要搞清楚 yield 是在参数循环内还是外。举个例子错误示范pytest.fixture(params[a, b]) def wrong_fixture(request): resource create_resource() yield resource # 每个参数都会执行到这里 resource.close()这个写法其实是正确的每个参数都会创建并关闭资源。真正容易错的是你想在同一个 fixture 里处理两类完全不同结构的资源导致准备代码里有多个 return/yield 分支。比如pytest.fixture(params[file, db]) def resource(request): if request.param file: yield create_file() remove_file() else: yield create_db() close_db()这种在两个分支里都使用 yield 的写法会变成两个生成器分支看似能跑但可读性很差而且如果清理逻辑遗漏会非常难排查。我建议拆成两个 fixture用不同名字表达不同资源类型然后在测试里分别引用或者用一个统一资源对象把清理封装进去。4.4 fixture 依赖另一个 fixture 时的 return/yield 混合项目里常常一个 fixture 返回一个依赖对象另一个 fixture 负责它的清理。这种“创建和清理职责分离”也很常见。比如pytest.fixture def conn(): return create_connection() pytest.fixture def clean_conn(conn): yield conn conn.close()clean_conn依赖conn测试函数用clean_conn能拿到连接对象同时保证测试结束后连接关闭。这种写法我一般不建议除非有特殊理由因为明明可以合并成一个 yield fixture分离反而让使用者疑惑到底该依赖哪个。但如果你想复用同一个连接对象到不同测试又不想每个测试都看到清理逻辑这种模式在某些团队编码规范里也有出现。我更推荐的做法是把创建和清理写在同一个 fixture 里职责单一。给测试用的名字就是连接对象本身它负责什么、清理什么都不需要使用者关心。5. 常见问题与排查技巧实录5.1 清理代码不执行先查 scope 和缓存我见过最典型的问题是用了 yield 写了清理但清理代码根本没执行。排查时先看作用域。比如pytest.fixture(scopesession) def db(): conn connect() yield conn conn.close()如果这个 fixture 定义在测试文件里但实际只有部分测试用到它那么conn.close()只会在 session 结束时执行一次前提是这个 fixture 确实被首次使用过。如果一个 session 级 fixture 定义在conftest.py中但是没有被任何测试请求那整个 session 都不会执行它的 yield 后的清理因为 fixture 根本不会被实例化。这一点常常被忽略pytest 是惰性实例化 fixture 的不是所有定义了 fixture 都会执行。还有 scope 为module时yield 后的清理代码会在最后一个使用该 fixture 的测试执行完之后执行不是模块导入时就执行。如果你调试时发现清理时机不对先看 scope再看用了这个 fixture 的几个测试是否全部执行成功或失败最后确认是不是用例被跳过。跳过skip也算请求了 fixture但清理代码是否执行取决于跳过的位置如果 fixture 本身在执行过程中被跳过清理逻辑可能不会走到。5.2 fixture 里写了 return但测试收到一个生成器对象这个坑比较隐蔽。如果你原本写的是return 值后来想尝试 yield又顺便改成了pytest.fixture def f(): return some_generator()那测试里收到的就是一个生成器对象不是生成器执行后的结果。这是两个概念你在 fixture 里 return 一个生成器和你在 fixture 里用 yield 是不同的。pytest 识别“yield fixture”靠的是 fixture 函数本身是否包含yield关键字而不是它 return 了什么。如果你确实想返回一个生成器给测试使用需要小心作用域和迭代时机。反过来如果你发现自己写的是pytest.fixture def f(): return lambda: generate_data()那是返回函数对象测试里需要加括号调用。这些都属于代码逻辑问题不算 pytest 的特性但很多人把它和 return/yield 选型搞混了排查半天。5.3 用 --setup-show 看清执行序列遇到 fixture 调用链复杂、清理顺序错乱时我最推荐的工具就是 pytest 自带的--setup-show它能把每个 fixture 的 setup 和 teardown 执行顺序打印出来。用法很简单pytest --setup-show -s test_demo.py输出大概长这样SETUP S dbfixture SETUP F tempfile ...这个输出能非常直观地帮你确认某个 yield fixture 的 teardown 到底有没有执行、执行顺序是什么样的、作用域是不是超出了预期。有一次我在排查一个测试因为端口的没释放导致的偶发失败就是这个命令一眼看到连接 fixture 的 teardown 根本没出现后来发现是作用域写成了 session而测试文件里还有一个 module 级 fixture 提前引用了它导致连接一直没关端口全被占满。5.4 其他常见问题速查问题可能原因建议yield 后的清理代码没执行作用域设置错误、fixture 未被请求、测试进程被强杀检查 scope 和使用情况测试收到生成器对象fixture 内部 return 了一个生成器而不是 yield 数据确认函数内是否出现yield关键字清理顺序和预期不一致fixture 依赖关系复杂后创建的先清理用--setup-show查看执行链参数化丢数据yield 了可变对象被多个测试修改返回不可变数据或深拷贝session fixture 的清理在 pytest 收尾时报错清理代码本身异常在清理代码外层加 try/finally只想跳过测试但 fixture 也执行了fixture 在测试用例之前被创建用pytest.skip延迟到测试函数内或用 fixture 内 skip 但注意清理时机排查这类问题的时候我一般不会只看代码优先跑一遍--setup-show再用一个极简的 fixture 复现能少走很多弯路。6. 我的一点实践心得工程里用久了我基本遵循一个原则默认用 return只有当我要管理外部资源的生命周期时才用 yield。这个原则帮我避免了很多无意义的代码嵌套。不要为了“显得高级”而用 yield也不要因为怕清理而到处用 try/finally。pytest 已经把清理机制封装得很好你只要理解 yield 是“在测试结束后继续执行”心里就会有数。另外写 fixture 的时候加一句文档字符串会很有帮助。比如注明“本 fixture 只负责提供数据不负责清理”或者“请确保测试结束后连接会被关闭”。别小看这一行字在多个人维护的测试项目里它能省掉很多“return 还是 yield”的争论。最后再分享一个小技巧。如果你不想用 fixture 做清理也可以直接在测试里使用 Python 的with语句def test_read_file(tmp_path): p tmp_path / data.txt with open(p, w) as f: f.write(hello) with open(p, r) as f: assert f.read() hello这时候资源清理就交给上下文管理器了完全不需要 fixture 参与。所以选 return 还是 yield不是把清理逻辑从测试文件里搬到一个函数里那么简单而是要考虑数据到底归谁管。搞清楚这一点你的 fixture 就能写得更干净排错也更轻松。
返回列表