ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pytest Fixture中yield与return的本质区别:从依赖注入到资源清理

Pytest Fixture中yield与return的本质区别:从依赖注入到资源清理 这个问题我在技术群里见过不下十次在面试里也被人问过。很多刚接触 Pytest 的人都会困惑用return能跑用yield也能跑那到底有什么区别翻了翻网上的教程绝大多数 demo 又是清一色的return于是不少人就形成了一个印象——能用 return 就不碰 yield。但这个印象在写真实项目测试时会给你挖大坑。我最初也是这么写的后来写接口自动化测试时发现数据库连接占着不放、测试数据残留、全局状态被改得乱七八糟排查了好几个小时才意识到问题出在 fixture 的写法上。先说结论两个都能给测试函数返回数据但是yield额外给了你一个测试结束后执行收尾代码的机会。也就是说return版的 fixture 是一条单行道yield版的 fixture 是一条能掉头的路前半段是准备数据setup后半段是清理现场teardown。决定用哪个本质上是问你自己一个问题这个 fixture 用完以后需不需要收拾这篇文章我会把背后的依赖注入机制讲清楚再给你一套可以照着判断的标准最后用一个完整的接口测试案例把各种坑串起来。适合刚开始学 Pytest 的人也适合那些写着写着感觉不太对劲的测试开发。1. 从依赖注入说起fixture 到底干了什么1.1 依赖注入容器你需要的不是调用而是声明老规矩看一个最基础的例子import pytest pytest.fixture def user_id(): return 10086 def test_get_user(user_id): assert user_id 10086test_get_user这个测试函数声明了一个名为user_id的参数但代码里从头到尾没有调用过user_id()这个函数。它只是把这个名字写在了参数列表里。Pytest 在收集测试时发现这个参数名会自动去 fixture 注册表里找同名的 fixture找到之后先执行user_id()再把返回值塞给测试函数的参数。这个机制不是简单的函数调用而是依赖注入。你可以把 Pytest 理解成一个容器测试函数是消费者fixture 是提供者容器负责把两者对接。类比一下去餐厅点餐的过程你不需要钻进厨房自己炒菜你只需要在菜单上写下菜名声明参数服务员容器就会把后厨做好的菜端到你面前注入参数。这套机制让测试代码变得非常干净。测试函数不用关心数据是怎么造出来的不用关心数据用完以后怎么处理它只关心自己要用什么。所有前置准备都可以收拢到 fixture 里统一管理这也正是 fixture 存在的核心价值。1.2 fixture 的缓存、作用域和依赖链fixture 还有一个容易被新手忽略的特性缓存。同一个 fixture 在同一作用域内只会执行一次执行结果会被缓存下来后续再次请求直接拿缓存的结果。import pytest pytest.fixture def counter(): print(初始化 counter) return {count: 0} def test_a(counter): counter[count] 1 def test_b(counter): counter[count] 1 print(counter[count])如果你跑这两个测试会发现初始化 counter只打印一次。因为在默认的function作用域里虽然两个测试函数都声明了counter但缓存是按当前测试函数来隔离的。test_a里的修改不会影响test_b。可以通过scope参数来控制缓存的粒度function默认每个测试函数独立、module模块内共享、class类内共享、session整个测试会话共享。scope的选择直接影响 fixture 何时创建、何时销毁后面讲yield的收尾时机时还要提到它。fixture 也可以依赖其他 fixture比如pytest.fixture def db_conn(): conn create_connection() return conn pytest.fixture def db_cursor(db_conn): return db_conn.cursor()db_cursor依赖db_connPytest 会先构造db_conn再构造db_cursor。依赖链可以嵌套很多层形成一个由多个 fixture 节点组成的依赖图。这个依赖图在收尾时的遍历顺序恰恰是很多人栽跟头的地方我会在第 4 节专门展开。2. return 和 yield 的本质区别一个没有散场阶段一个有2.1 回到 Python 语法return 是结束yield 是暂停先忘掉 Pytest单纯从 Python 语义来看两个词的区别。return执行时函数立刻结束返回值交给调用方函数体内的局部变量和状态一并释放。yield则完全不同。一个包含yield的函数会变成一个生成器generator。调用这个函数时函数体一行都不会执行而是返回一个生成器对象。当外部对生成器调用next()或send()时函数体才开始执行一直跑到yield这一行把yield后面的值交给外部然后暂停在那里。下次外部再调用next()时函数会从刚才暂停的位置继续往下执行。Pytest 的yieldfixture 利用的正是这个暂停—恢复机制。当你在 fixture 函数里写下pytest.fixture def data(): print(setup准备数据) yield {id: 1} print(teardown清理数据)Pytest 会把这个 fixture 当作生成器来对待。测试执行前Pytest 驱动生成器跑到yield位置把{id: 1}注入给测试函数。测试函数跑完后Pytest 再驱动生成器从yield处继续往下执行直到函数结束。整个过程其实是准备数据yield 之前 - 测试函数运行 - 清理数据yield 之后这就是 fixture 版本的 setup/teardown 结构。return只能表达准备数据这一段yield能够同时表达准备和清理两段。2.2 yield 之前是开台之后是收台用一个临时文件的例子来看更直观import pytest from pathlib import Path # 版本一return只负责返回 pytest.fixture def temp_file_return(tmp_path): f tmp_path / demo.txt f.write_text(hello) return f # 版本二yield返回字段同时带收尾 pytest.fixture def temp_file_yield(tmp_path): f tmp_path / demo.txt f.write_text(hello) yield f print(teardown删除临时文件) f.unlink(missing_okTrue)两个 fixture 都能把临时文件路径交给测试函数。区别在于第二个在测试结束后会执行print并删除文件而第一个不会。你可能会说tmp_path本身是 Pytest 内置的 fixture测试结束它会自动清理根本不需要手动删。对这个例子只是为了演示yield的机制真正要表达的是即使测试函数抛异常、断言失败yield 后面的代码也一样会执行。这一点非常关键。写测试时清理动作不能依赖测试函数正常结束因为测试用例挂了恰恰是最需要清理的时候。测试失败留下一堆脏数据、一个没关闭的连接后续测试会被连带影响到时候你很难定位真正的问题出在哪里。2.3 为什么很多教程都用 return并不代表 return 够用网上大量教程都用return写 fixture原因很简单教程里的 fixture 大多是造一份数据这种纯准备型场景。pytest.fixture def user_payload(): return {name: 张三, age: 18}这种 fixture 只是把一段字典数据返回给测试函数测试结束后这个字典对象自然销毁不需要额外动作。对这类场景return就是最合适的写法简单、直接、读代码的人一眼就能看出这个 fixture 只是准备数据没有收尾逻辑。但一到真实项目就会遇到另一类需求fixture 持有数据库连接、打开了网络请求、创建了临时目录、修改了全局配置、往测试数据库里插了数据。这些东西用完以后必须释放、还原、清理否则测试之间会互相污染。return表达不了收尾动作这才会用到yield。所以更准确的说法是不是 return 不够用而是你过去遇到的场景恰好都不需要收尾。把这两种写法理解成纯准备型 fixture和带清理型 fixture的分界线比争论哪个词更好用要准确得多。3. 判断该用哪个三条实用标准别靠感觉3.1 标准一是否存在需要显式释放的资源或连接这是最直接的一条。看看 fixture 返回的东西属于哪一类fixture 返回的内容类型推荐写法普通字典、列表、数据模型对象纯数据return数据库连接、游标、连接池系统资源yieldRedis 客户端、消息队列连接系统资源yield打开的文件句柄系统资源yield模拟 HTTP 服务、Socket 服务系统资源yield进程、线程、锁系统资源yield全局配置、环境变量、mock 对象需要还原yield这里的判断口径是如果这个对象在测试结束后不关闭、不释放会不会对后续测试或系统运行产生影响。如果你只是 new 一个普通对象、返回一段 JSON、算出一个数学结果这种不影响系统的纯数据用return就好。有一种特殊情况要提醒有些连接对象在进程退出时会被系统自动回收比如测试进程一结束操作系统会回收文件描述符、释放内存。但这不代表你可以不清理。自动回收发生在进程层面而测试套件往往在同一个进程里跑几百上千个用例。如果每个用例都开一个连接不关闭连接池很快被榨干后续用例全部失败。我在一个 Django 项目中就遇到过数据库最大连接数是 100接口测试跑了两百多个用例后开始大面积报 too many connections罪魁祸首就是一个返回数据库连接的 fixture 用了return。3.2 标准二测试结束后是否必须做某种还原操作资源不止是连接状态也是资源。典型场景fixture 往数据库插入了测试数据测试跑完要把数据删掉不能污染正式数据。fixture 修改了全局配置项比如改了settings.DEBUG、改了环境变量测试完要恢复原值。fixture 给某个模块打了补丁monkeypatch 之外的全局替换测试完要还原。fixture 清了缓存测试完要把缓存填回去或者把 key 删掉。这类还原动作本质上也是一种清理yield是最自然的写法。import pytest import os pytest.fixture def mock_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_valueyield之后写还原逻辑就能保证不管测试过程出了什么岔子环境变量一定会被恢复。如果你用return在 fixture 里恢复逻辑根本不会被执行到还得每个测试函数自己 try/finally不但啰嗦还容易漏。有一个常见的认知误区很多人以为monkeypatch需要配合yield使用。其实monkeypatch是 Pytest 内置的 fixture它自己就实现了自动还原不需要你手动处理。我这里举的环境变量案例是想说明不是只有 mock 才需要还原任何被测试代码改动的全局状态都应该考虑还原。3.3 标准三作用域跨越多个测试时收尾次数怎么算scope参数决定了 fixture 的生命周期也决定了yield后面的收尾代码执行几次。import pytest pytest.fixture(scopemodule) def db_conn(): print(module 级别建立连接) conn create_connection() yield conn print(module 级别关闭连接)scopemodule意味着整个模块内的所有测试共享这一个连接。Pytest 会在模块内第一个测试请求该 fixture 时创建连接模块内所有测试跑完后才执行yield后面的关闭逻辑。这是一个很大的性能优势不用每个测试都重新握手建立连接。但要注意这时候收尾代码只执行一次。如果清理逻辑是删除测试数据它会等模块内全部测试都跑完才删而你本来的意图可能是每个测试结束后都删掉自己造的脏数据。想要每次都删就要把 scope 调回默认的 function。这里有个实际经验连接这类开销大的资源尽量用高 scope数据清理这类操作尽量用低 scope。两者诉求不同经常需要拆成两个 fixture 来组合。比如一个 module 级的连接 fixture 负责复用连接一个 function 级的清理 fixture 负责每次测试后删除脏数据。不要试图用一个 fixture 同时满足两个互相冲突的需求。遇到需要做收尾动作但又想控制收尾频率的场景就同时写好 scope 与 yield并在 teardown 里打印日志确认执行时机。日志能帮你快速定位清理到底是没执行还是执行次数不对。4. 嵌套 fixture 的清理顺序LIFO很多人第一次都猜反4.1 一个例子看清楚执行顺序如果你的 fixture 之间存在依赖比如 fixture B 依赖 fixture A那么收尾顺序是什么样的看代码import pytest pytest.fixture def layer_a(): print(开启 A) yield A print(关闭 A) pytest.fixture def layer_b(layer_a): print(开启 B) yield fB {layer_a} print(关闭 B) def test_layers(layer_b): print(执行测试)执行这个测试控制台输出顺序是开启 A 开启 B 执行测试 关闭 B 关闭 A注意到没有创建顺序是先 A 后 B清理顺序恰恰相反先 B 后 A。这是典型的后进先出 LIFO。很多初学者第一反应是B 依赖 A所以应该先关 A 再关 B实际情况正好相反。4.2 为什么会是 LIFO而不是依赖顺序从资源管理的角度LIFO 才是安全的。想象一下B 持有 A 创建的某个句柄并且 B 的清理逻辑需要用到这个句柄比如关闭游标、提交事务。如果 Pytest 先把 A 关闭了B 再去用 A 创建的句柄轻则报错重则操作一个已经被注销的资源导致难以预料的后果。用嵌套事务类比更容易理解外层事务开启后内层事务才开启内层事务提交后外层事务才能提交。如果先提交外层事务内层事务的提交状态就悬空了。所以合理的清理顺序永远是后创建的先清理。这个栈式清理特性其实和 Python 的with语句的退出顺序一致也和装饰器的执行顺序一致。理解了这一点嵌套 fixture 的清理顺序就不再是死记硬背而是顺理成章。4.3 fixture 缓存和清理次数的坑前面提到 fixture 在同一作用域内只执行一次这一点和yield收尾结合会引出一个很隐蔽的坑。import pytest pytest.fixture(scopesession) def global_store(): print(创建全局存储) data [] yield data print(全局存储清空) data.clear() def test_one(global_store): global_store.append(one) def test_two(global_store): print(global_store)输出创建全局存储 测试 one 测试 two 全局存储清空global_store是 session 级两个测试共享同一个列表对象。test_one往里加了 onetest_two里打印出来就是[one]。这在某些场景是特性共享状态某些场景就是污染源测试相互干扰。而清理只在最后执行一次并不会在test_one结束后就把列表清空。我知道很多人在写带状态的 fixture 时会遇到这个问题解决思路不是盲目把 scope 调低而是想清楚这个状态到底该不该跨测试共享如果每个测试需要独立状态就用 function 作用域每次测试结束 Pytest 会自动执行yield后面的清理如果确实需要共享状态就必须自己接管状态的分代管理在 fixture 内部按测试维度记录和重置避免用例之间串联。用yield时也建议在 teardown 里加上日志。这样当你看到清理日志只出现了一次却期待它出现多次时能立刻反应过来是作用域配置的问题而不是在那里干着急。5. 不用 yield 也能清理request.addfinalizer 和 yield 的取舍5.1 addfinalizer 的写法Pytest 里还有一条实现收尾的路径request.addfinalizer。import pytest pytest.fixture def db_conn(request): conn create_connection() def close_conn(): conn.close() request.addfinalizer(close_conn) return connfixture 函数声明了一个request参数Pytest 会把当前测试请求上下文注入进来。调用request.addfinalizer注册一个清理函数fixture 本身仍然可以用return返回数据。测试结束后不管通过还是失败Pytest 都会执行已经注册的 finalizer。5.2 yield 与 addfinalizer 的对比Pytest 官方在 3.0 版本引入了yieldfixture 的写法并且推荐优先使用它因为可读性更好。yield把 setup 和 teardown 两段代码写在一个函数里从上往下读就是完整的生命周期addfinalizer则把清理逻辑写成了一个回调代码的可读性和紧凑度都差一截。不过addfinalizer有一个yield替代不了的能力动态注册清理函数。fixture 里可以按需注册也可以在运行过程中多次注册不同的清理函数。import pytest pytest.fixture def complex_fixture(request): conn create_connection() request.addfinalizer(conn.close) if some_condition: ticket allocate_ticket() request.addfinalizer(ticket.release) return conn这个例子里清理动作有 1 个确定、1 个条件触发。用yield写也可以但需要把if判断挪到 teardown 代码里再判断一次哪条路径注册了清理就执行哪个代码会变绕。addfinalizer的优势就是注册动作和资源分配发生在同一个地方因果关系清晰。两种方式整理成表格维度yieldrequest.addfinalizer可读性高setup/teardown 上下直观低清理逻辑是回调动态注册清理函数不支持只能固定一段收尾代码支持可条件注册、多次注册默认推荐是Pytest 官方推荐兼容旧代码时使用与 return 配合不能与 return 同时使用可用 return 返回数据代码整洁度更整洁更灵活但容易写得很散5.3 如果坚持用 return有什么替代方案偶尔会看到有人既想用return的直观又想带清理逻辑于是把清理塞进测试函数里def test_something(fixture_conn): try: # 使用 fixture pass finally: # 手动清理 fixture_conn.close()问题在于如果有十个测试函数都依赖这个连接那么每个测试函数都要写一遍try/finally。这完全违背了 fixture 设计的初衷——把公共准备和公共清理收拢到一处管理。与其这样重复不如直接把 fixture 改成真正的清理型 fixturepytest.fixture def fixture_conn(): conn create_connection() yield conn conn.close()测试函数保持干净只做业务断言清理工作回到它应该在的地方。所以我的建议很明确优先用 yield只有遇到动态注册清理函数这种特殊需求时才考虑 addfinalizer。至于坚持 return 又要清理的方案基本是性价比最低的路不推荐走。6. 完整案例与踩坑清单接口测试资源清理的实战收尾6.1 一个登录态加数据库清理的完整 fixture 案例最后放一个我实际项目中用过的完整案例串起前面所有知识点。场景是接口自动化测试需要先注册一个测试用户用该用户登录拿 token然后用 token 访问业务接口最后把数据库里的测试数据清理干净。import pytest import requests pytest.fixture def api_base_url(): return http://127.0.0.1:8000 pytest.fixture def test_user(api_base_url, db_session): 创建测试用户并保证测试结束后从数据库清理 # 1. 准备阶段 user_data {username: test_user_001, password: test_pass_123} resp requests.post(f{api_base_url}/api/v1/users, jsonuser_data) assert resp.status_code 201 user_id resp.json()[id] # 2. 交给测试函数使用 yield {**user_data, id: user_id} # 3. 清理阶段删除测试用户 db_session.execute(DELETE FROM users WHERE id :id, {id: user_id}) db_session.commit() pytest.fixture def auth_token(test_user, api_base_url): 依赖 test_user登录后返回 token resp requests.post(f{api_base_url}/api/v1/auth/login, json{ username: test_user[username], password: test_user[password], }) assert resp.status_code 200 return resp.json()[token] def test_get_user_profile(auth_token, api_base_url): 带登录态的接口测试 headers {Authorization: fBearer {auth_token}} resp requests.get(f{api_base_url}/api/v1/users/me, headersheaders) assert resp.status_code 200 assert resp.json()[username] test_user_001这里有几个值得注意的设计点test_user是 function 作用域每个测试用例跑完都会执行清理删除自己创建的测试用户。auth_token依赖test_user但它本身只是返回 token不需要额外清理所以用return。api_base_url是纯配置数据同样用return。db_session我一般设为 session 级连接整个测试会话复用会话结束统一关闭避免频繁建连。嵌套关系上test_user依赖api_base_url和db_sessionauth_token又依赖test_user。按照前面讲的 LIFO 顺序清理顺序是先销毁auth_token没有清理逻辑再清理test_user删除用户最后关闭db_session。这个顺序完全安全因为删除用户时db_session还活着。6.2 用 --setup-show 验证 setup 与 teardown 顺序如果你对清理顺序心里没底或者怀疑某些 teardown 没有按预期执行Pytest 提供了一个非常实用的调试参数pytest --setup-show tests/test_user.py输出大概长这样SETUP S api_base_url SETUP S db_session SETUP F test_user SETUP F auth_token tests/test_user.py::test_get_user_profile (fixtures used: api_base_url, auth_token, db_session, test_user) TEARDOWN F auth_token TEARDOWN F test_user TEARDOWN S db_session TEARDOWN S api_base_urlS和F分别表示 session 级和 function 级。看到TEARDOWN行就能确认 teardown 确实执行了也能看清执行顺序。我自己排查 fixture 问题时第一步永远是跑--setup-show比加 print 调试快得多。常见问题也能从这个输出里看出来如果你看到某个 fixture 的 TEARDOWN 迟迟不出现多半是 scope 设置成了 module 或 session它要等整个模块或整个会话结束才清理这不是 bug是预期行为。6.3 yield fixture 的高频错误自查最后整理一份自查清单都是我在实际项目里见到过或者自己踩过的坑。第一不要在 yield 之后再用带返回值的 return。一个 yield fixture 里不能同时向测试函数返回两个值。你可能会想pytest.fixture def bad_fixture(): yield 1 return 2 # 不行Pytest 会直接报错fixture function cannot return values other than None when using yield。如果确实需要同时给测试函数多个值正确做法是 yield 一个元组或字典而不是想着再 return 一个。第二清理逻辑要幂等。幂等意味着清不清理第二次结果都一样。比如删除数据DELETE FROM users WHERE id ...重复执行不会报错但如果你是弹出一个列表元素第二次执行就可能抛异常。teardown 阶段出现异常Pytest 会把这个异常附加到测试结果里可能导致原本通过的用例被标记为失败或错误干扰问题定位。所以清理逻辑尽量写得保守一点能用missing_okTrue、try/except兜底就兜底。第三不要在 teardown 里做重操作。清理代码里跑全量数据导出、做复杂的业务校验这些都会拖慢整个测试套件而且 teardown 阶段如果断言的逻辑太复杂出问题反而会掩盖测试用例本身的结果。清理就是清理越简单越好。第四区分创建型 fixture和清理型 fixture。如果一个 fixture 既负责创建连接又负责清理数据容易在作用域层面打架。创建一个数据库连接你可能希望 session 级复用清理测试数据你可能希望 function 级每次都做。这种互相冲突的诉求拆成两个 fixture 各管一段才是正解强行塞进一个 fixture 里只会让 scope 怎么调都不对。第五不要假设 fixture 一定会被用到。如果测试函数没有请求这个 fixturePytest 不会执行它yield前后的代码都不会跑。如果想让某些 fixture 自动生效而不需要测试函数声明参数可以在pytest.fixture上加autouseTrue但要慎用避免隐式依赖太多影响测试的可读性。6.4 我的个人习惯后来我写 fixture 基本形成了条件反射写之前先问自己一句这个东西测试结束以后需不需要放回原处需要就用yield开头在yield后面写清理不需要就用return结束绝不为赋新词强说愁。这种判断方式极其朴素但足够应对绝大多数场景。一个小技巧是给清理动作加日志尤其是数据库操作和数据清理避免出了问题时两眼一抹黑。Pytest 自带的caplog或者在 teardown 里加一句print都行成本极低排查问题时帮助却很大。另外在团队协作中最好在 review 时统一约定所有涉及资源、状态、数据的 fixture 必须清理清理优先用 yield 而不是 addfinalizer。这个约定看起来简单但能省掉很多线上互相排查为什么测试跑挂了为什么数据库里有垃圾数据的时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进