
接触pytest有一段时间后你会发现真正拉开测试代码质量差距的并不是你会多少断言写法而是你如何组织测试的前置条件和后置清理。我第一次在项目里看到几十个测试类各自维护一套setup、teardown的时候内心是崩溃的——数据库连接要初始化、缓存要预热、外部接口要mock、临时文件要落地这些逻辑被复制粘贴得到处都是改一处初始化流程简直像在横穿雷区。后来认真啃了一遍pytest的fixture机制才明白它为什么被称作pytest的倚天剑测试的依赖不再是一堆需要继承的基类而是可以组合、可以参数化、可以按作用域复用的构件。这篇博客我会把fixture的完整用法拆开讲透从它解决了什么问题开始到基础定义、作用域、参数化、依赖组合再到我在实战中踩过的坑。不管你是刚接触pytest的新手还是已经在项目里写了很久测试的老手这篇文章应该都能给你一些新的认知。1. 先搞明白fixture到底解决了什么问题再谈使用很多pytest新手上来就开始背pytest.fixture()装饰器的写法却不太清楚这个机制存在的真正原因。不看透这一点你写出来的fixture很容易只是把原来的setup函数换了个马甲治标不治本。1.1 unittest时代setup/teardown的三大痛点传统的unittest测试框架每个TestClass都要靠setUp和tearDown方法去准备和清理环境。这在测试数量少、依赖简单时没什么问题可一旦项目进入正轨痛点就非常明显。第一个痛点是复用困难。假设你有一套初始化用户数据的逻辑A测试类要用B测试类也要用C测试类要基于A和B的组合。在unittest里你会怎么做要么复制三遍要么搞一个公共基类然后把测试类继承过去。复制的问题是一改要改三处继承的问题是你为了拿到那点初始化数据被迫继承了基类里其他所有行为尾大不掉。第二个痛点是逻辑纠缠。因为setup在测试类中只能写一份所有用例共享一套初始化流程。可实际情况往往是用例A只需要数据库连接用例B需要数据库连接加登录态用例C需要的是完全不同的数据形态。在unittest中你得在setUp里拼命做条件判断或者干脆把用例拆到不同的测试类里。测试代码的重心偏移到了准备条件而不是验证行为。第三个痛点是清理时机不可控。tearDown只能在测试方法结束后执行一次颗粒度太粗。如果你想在测试中途释放某个资源让后续的步骤在一个更接近真实生产的环境中运行传统写法做不到。1.2 fixture为什么能解决这些问题fixture的理念是依赖注入。测试函数需要什么前置条件直接写在函数参数里pytest会自动去寻找、创建、传递和清理这个条件。你不需要继承任何东西也不需要写一堆模板化的setUp。import pytest pytest.fixture() def user_account(): return {name: tester, level: 10} def test_user_level(user_account): assert user_account[level] 10这个例子很简单但背后蕴含的思想很关键pytest看到test_user_level需要参数user_account会自动去查找名为user_account的fixture。找到后先执行fixture拿到返回值再注入给测试函数。在函数作用域下测试执行完毕fixture也就随之销毁。你完全可以把它理解成一个零件工厂每个fixture只负责一件具体事情测试函数需要哪个零件就声明哪个零件。这种按需注入的模式让测试的每个用例只依赖自己真正需要的东西去掉了很多无关干扰。1.3 fixture比setup多出来的几个专业技能fixture另一个强大的点是它可以有层级可以组合。fixture A可以依赖fixture BB可以依赖Cpytest会自动按照依赖关系构建执行顺序。这比手动在测试里逐个调用准备函数要优雅得多。fixture还支持作用域控制。你可以让某个fixture在整个测试类、整个测试模块甚至整个测试会话中只初始化一次而不用像unittest那样要么每个用例都执行一次要么用类级别的setUpClass去对付。更重要的是fixture天然支持参数化。数据和准备逻辑被分离同一个fixture可以因为参数不同产生不同的数据变体测试用例自动扩展。这种能力在unittest中实现起来相当别扭在pytest框架里却是一等公民。2. 从最简单的写法到作用域控制fixture基础使用拆解清楚了fixture的价值接下来看看怎么把它用熟。2.1 定义与请求的两种玩法定义fixture的核心就是装饰器pytest.fixture()。默认情况下它的作用域是function意味着每个请求它的测试函数都会得到一次全新的创建。请求fixture的方式也很直观在测试函数的参数列表中写上同名参数即可。pytest.fixture() def db_connection(): # 创建数据库连接 conn create_conn() return conn def test_query_user(db_connection): result db_connection.query(SELECT * FROM users) assert result is not None有一点值得注意fixture的返回值是通过函数参数传入的函数并不会真的看到fixture函数内部是怎么创建的。这其实是个好处它强制你将创建细节封装在fixture内部测试用例只需要关心我拿到了什么而不需要关心它是怎么来的。2.2 yield是fixture的灵魂setup和teardown一体很多初学者刚接触fixture时喜欢用return因为写法最简单。但实际项目里大部分fixture需要做两件事准备资源和清理资源。这时候yield才是正确的选择。pytest.fixture() def mock_data(): data create_test_data() yield data remove_test_data(data)这段代码的执行流程很清晰pytest调用fixture执行到yield之前的部分也就是创建数据。yield把data传给测试函数。测试函数运行完毕无论成功还是失败pytest回到fixture继续执行yield之后的清理逻辑。你把setup和teardown写在了一个函数里而不是拆成setUp和tearDown两个方法。这在逻辑上更内聚也更符合人类的阅读习惯。从我自己的经验来看除非这个fixture完全是只读的、不需要任何清理动作否则我几乎无条件用yield。即便当前函数不需要清理我也会写一个空清理逻辑占位为未来演进留好口子。2.3 作用域function、class、module、session怎么选scope参数控制fixture的存活范围可选值有scope创建时机清理时机典型使用场景function每个请求的测试函数执行前测试函数结束后临时数据、mock对象、环境变量class每个测试类执行前测试类结束后类级共享的客户端实例module每个测试模块执行前模块结束后读取配置文件、加载测试数据文件package每个测试包执行前包结束后包级共享的数据库快照session整个测试会话前会话结束后全局环境准备、成本极高的资源初始化选错scope的代价经常是隐性的。我见过有人把数据库连接设置为session作用域结果跑完一个测试用例后连接因为空闲超时断开了后面的用例全部红。还有一种场景是某个session fixture里存放了一个可变的字典第一个测试往里塞值第二个测试再读它期望的是空字典结果被污染了。体现在报错上就是那种让人挠头的偶发性失败。我的建议是能用function别用高一级。高作用域带来的性能提升往往抵消不了状态管理复杂度上升的代价。只有当初始化成本确实很高而且fixture内部的数据是只读或不可变时才考虑提升到module甚至session。2.4 autouse让fixture隐形地自动运行有些fixture你希望所有测试都自动应用不需要在每个函数参数里显式声明。典型的场景有设置环境变量、清理临时文件、确保某个目录存在。pytest.fixture(scopesession, autouseTrue) def setup_global_env(): os.environ[APP_ENV] test os.makedirs(/tmp/test_data, exist_okTrue) yield os.environ.pop(APP_ENV, None)autouseTrue让fixture在对应scope的开始点自动执行测试函数不需要知道它的存在。这非常方便但也会带来一个尴尬问题调试时你可能搞不清楚为什么某个测试环境里多了个环境变量少了另一份临时文件。排查时可以用pytest --fixtures查看当前生效的所有fixture并留意有没有autouse标记。autouse常见的坑在于作用域的错配。如果你希望整个会话只设置一次环境但写的是function作用域它就会在每个测试前反复执行删除时也会反复执行既浪费又容易和其他测试状态打架。反过来如果你希望每个测试都有独立的环境变量却写成session作用域又会造成互相污染。3. fixture的依赖组合与参数化从零件到装配线当单个fixture满足不了复杂的测试场景时组合能力就派上用场了。3.1 用例从其他fixture创建新的组装fixture可以调用其他fixture只需在fixture函数参数中声明依赖即可。这让fixture像工厂流水线一样一层层组合出最终需要的产物。pytest.fixture() def db_config(): return {host: 127.0.0.1, port: 3306, database: test} pytest.fixture() def db_client(db_config): return DatabaseClient(db_config) pytest.fixture() def api_service(db_client): return ApiService(db_client) def test_api_get_user(api_service): user api_service.get_user(u_001) assert user[status] activepytest自动处理执行顺序先运行db_config再处理db_client最后api_service。传统方式里你要在setup里手动写成嵌套调用或者用临时变量来控制顺序而pytest完全是声明式的。这里有个容易忽略的细节如果两个fixture都依赖同一个fixture在同一个测试函数中pytest不会重复执行那个被依赖的fixture而是复用同一个实例。比如test A同时依赖fixture X和fixture Y而X和Y都依赖Z那么pytest只会运行一次Z。这个机制保证了你的初始化逻辑不会被冗余执行也保证了同一个测试内的状态一致性。3.2 参数化fixture一份逻辑生成多条测试用例fixture的参数化是pytest做数据驱动测试的重要工具。你在pytest.fixture(params[...])中传入一组参数测试函数会被自动执行多次每次拿到的fixture值不同。pytest.fixture(params[ (zh_CN, 测试数据), (en_US, test data), (ja_JP, テストデータ), ]) def localized_message(request): locale, expected request.param return MessageFactory(locale).get_message(), expected def test_localization(localized_message): msg, expected localized_message assert msg expectedpytest会把参数列表展开成独立的测试用例用例ID里会带上参数化的标识。运行pytest --collect-only你能看到每个参数都生成了一条独立用例失败时也能精确地定位到是哪个参数组合出了问题。这种做法的最大优势是测试逻辑只写一次变化的部分通过参数来驱动。当你需要新增一个地区、一组输入数据时只需要改params列表不用增加测试函数。3.3 indirect参数让参数进入fixture内部直接用params定义好参数列表后fixture通过request.param获取当前参数。但你也可以不在装饰器里写死参数而是由测试函数上的pytest.mark.parametrize来驱动然后通过indirectTrue把参数传给fixture。pytest.fixture() def account(request): if request.param vip: return Account(typevip, quota100) return Account(typenormal, quota10) pytest.mark.parametrize(account, [vip, normal], indirectTrue) def test_quota(account): assert account.quota 0indirectTrue带来的一个明显好处是测试数据的来源和fixture内部的创建逻辑解耦了。你可以在测试函数层用parametrize灵活调节参数而不必每次修改fixture的params列表。这种机制在数据驱动测试中非常实用尤其是当fixture还依赖其他参数时indirect能保持更高的灵活性。3.4 request.getfixturevalue动态获取fixture偶尔你会遇到一种情况测试函数在运行时才知道自己想要哪个fixture而不是在定义时就能声明。pytest提供了request.getfixturevalue()方法让你在测试执行过程中按名称动态获取fixture。pytest.fixture() def mock_v1(): return ApiMock(versionv1) pytest.fixture() def mock_v2(): return ApiMock(versionv2) def test_version(request): version request.node.get_closest_marker(api_version).args[0] mock request.getfixturevalue(fmock_{version}) assert mock.version version这种动态请求的方式比较适合编写通用测试框架层比如根据用例标记选择不同API版本去测试。常规的业务测试中我还是更推荐显式声明依赖因为代码可读性更高也更利于pytest进行静态分析。4. conftest.py与内置fixture规模化工程中的共享机制4.1 conftest.py到底是干什么的conftest.py是pytest中一个特殊文件。pytest运行时会自动从测试目录向上查找所有conftest.py文件并将其中的fixture注册到全局命名空间中供该目录及其子目录下的所有测试文件使用。tests/ ├── conftest.py # 全局fixture ├── test_a/ │ ├── conftest.py # test_a目录专属fixture │ └── test_login.py └── test_b/ ├── conftest.py # test_b目录专属fixture └── test_order.py我推荐的分层方式是根目录的conftest放那些真正全局共享的内容比如测试环境的整体配置、外部接口的Mock方案子目录的conftest放该模块内独有的业务初始化逻辑具体测试数据尽量保持在测试文件内部。这样既保证了共享能力又不至于让根目录conftest变得臃肿不堪。4.2 我常用的几个内置fixturepytest自带了一批实用fixture有时候用好它们比什么都写自己的更高效。tmp_path用于创建临时目录和文件测试结束后pytest自动清理。所有测试互不干扰我也不用担心磁盘上残留垃圾数据。def test_write_file(tmp_path): target tmp_path / data.txt target.write_text(hello pytest) assert target.read_text() hello pytestcapsys用于捕获测试过程中的标准输出和标准错误输出。调试诊断信息、日志输出时尤其好用。def test_capture_output(capsys): print(hello) captured capsys.readouterr() assert hello in captured.outmonkeypatch用于临时修改对象属性或环境变量测试结束后自动还原。对于那些依赖环境变量切换行为的代码monkeypatch是最直接的测试手段。def test_env_switch(monkeypatch): monkeypatch.setenv(APP_LANGUAGE, zh_CN) assert os.environ[APP_LANGUAGE] zh_CN内置fixture的价值在于它们经过了pytest核心团队的大量实战验证边界情况处理得比我自己的临时方案要可靠得多。能用内置的就不要花时间重复造轮子。4.3 fixture名称冲突时怎么处理当多个conftest定义了同名fixture时作用域更下层的fixture会覆盖上层的同名fixture。这个优先级规则有时会带来出乎意料的测试结果。我排查问题时的第一步就是看当前测试文件所在的目录层级的conftest确认是否有fixture名称冲突。如果你发现自己定义的fixture没有生效而pytest也没有报错多半就是被更下层的同名fixture覆盖了。像这种隐性问题用pytest --fixtures -v查看每个fixture的来源是一个很靠谱的排查路径。5. 实战中的那些坑以及对应的对策这一节我想把实操中踩过的坑集中梳理一下它们每次都花了不少时间排查希望你绕过去。5.1 fixture的返回时机与清理时机没搞清楚yield之前的代码是准备阶段yield之后是清理阶段这是fixture最大的心法。但有一个很容易踩的细节如果fixture同时被多个测试请求且作用域是function那么每次请求都会执行完整的准备和清理流程。如果作用域是module或session那么只在第一次请求时执行准备测试全部跑完后才执行清理。鉴于此如果你在session级fixture中返回了可变对象并且测试中会修改它一定一定要小心。尽量返回不可变数据或者每次通过工厂函数生成副本。5.2 依赖关系确定后执行顺序不是你写参数的顺序fixture的执行顺序由依赖树的拓扑结构决定而不是由测试函数参数列表中参数的先后顺序决定。pytest.fixture() def a(): print(a) return a pytest.fixture() def b(a): print(b) return b def test_order(b, a): pass虽然参数里先写了b再写a但因为b依赖apytest会先运行a再运行b。在排查复杂初始化顺序问题时不要盯着测试函数的参数列表而是要看fixture之间的依赖关系。5.3 fixture命名导致的无效导入pytest的fixture是按名称查找的不是按导入路径查找的。这意味着你可以在测试文件中定义一个与conftest中同名的fixture本地的会覆盖全局的。这种按名称匹配的机制带来了便利但也埋了一些隐患。最典型的报错是fixture xxx not found。排查思路我按顺序整理一下检查拼写fixture名称对大小写敏感。检查定义位置确认fixture是否在当前文件或当前目录的conftest中。检查作用范围子目录的测试能否使用父目录conftest中的fixture可以反过来不行。检查是否忘了加pytest.fixture()装饰器或者装饰器拼成了pytest.fixture少了括号。5.4 并发运行时的fixture陷阱pytest-xdist是常用的并行扩展但并发下fixture的行为需要重新审视。默认情况下每个worker都会执行session级fixture一次。也就是说如果一个session级fixture初始化的资源是全球唯一的比如占用某个固定端口并发运行时就可能产生端口冲突。应对策略是让全局资源具备worker隔离能力比如端口从合成范围里分配或者改用文件锁、数据库特有的共享机制。无论如何切记session级fixture不是进程级别的单例保证。5.5 用return的习惯什么时候得改过来很多人从setup/teardown转到fixture时喜欢用return返回数据因为直觉上这个函数就是给我数据的。直到有一天需要清理某个资源却发现无从下手才意识到return和yield在fixture中的差别。我的原则是除了那些纯函数式的、没有任何外部副作用的fixture之外一律使用yield。即便当前不需要清理我也会让yield保留一个空清理位。成本几乎为零但未来扩展时自己会感谢这个习惯。我的个人体会很多人以为pytest的fixture只是一个花哨的装饰器但真正理解它之后你会发现它改变了测试代码的组织方式。从unittest的继承约定走向fixture的依赖注入测试的可读性和可维护性都上了一个台阶。如果你正在优化自己的测试项目我的建议是不要一开始就追求复杂的fixture组合先把基础定义和scope用对把autouse和conftest的共享逻辑理清楚再逐步引入参数化和indirect机制。这样每一层用法都有明确的回报不会把测试本身变成新的维护负担。最后再多说一句pytest的--fixtures命令值得你经常用它能看到当前明确生效的fixture及其定义位置排查问题、理解工程结构时非常好使。