python fixture
## 从单元测试到系统测试Python Fixture 的演进与落地先说说 fixture 到底是什么。如果你写测试代码的时间不短可能还记得几年前测试里到处都是 setUp 和 tearDown 方法。每个测试类里都堆着大段重复的代码测试数据、数据库连接、临时文件创建混在一起看着就觉得别扭。后来 pytest 的 fixture 出来了这个问题才算真正有了一个优雅的解。说到底fixture 就是一组可复用的测试准备和清理逻辑但它的写法更像是对测试环境的声明而不是命令。举个现实例子想象你要测试一个电商系统里结算购物车的功能。测试之前总得让购物车里有商品吧商品得从数据库里取吧用户也得登录吧。传统做法可能是在每个测试方法开始前手动造数据或者写一堆 setUp 代码。fixture 的做法完全不同——你只需要定义一个名为 shopping_cart 的函数给它加上 pytest.fixture 装饰器然后在测试函数里把它当作参数传进来。pytest 会自动找到它、执行它把返回值喂给测试函数测试结束后再帮你做清理。这种做法的精髓在于fixture 之间可以互相依赖。购物车 fixtures 可能需要用户登录 fixture 和商品数据 fixture 作为前提条件pytest 会按照依赖关系自动排序执行不会出现先执行后依赖的混乱。更巧妙的是它的 scope 参数可以控制 fixture 的生命周期。session 级别的 fixture 只运行一次module 级别的在当前模块内共享function 级别的每个测试函数各自独立。这种做法让测试的构建变得像搭积木需要什么就声明什么自然形成层次结构。从能力上看fixture 能做三件关键事。最直观的是做准备工作创建临时数据库、模拟网络请求、生成测试数据文件。这些活放在 fixture 里代码就可以专注于测试业务逻辑本身。第二是清理动作测试完删除临时文件、回滚数据库变更、关闭网络连接。fixture 的 yield 语句可以做到这一点——yield 前面的代码做设置yield 后面的代码做清理。第三是实现参数化同一个 fixture 可以根据请求传入不同的参数同时生成多组测试环境来跑同一个测试。这在测试不同输入组合时非常有用可以减少很多样板代码。具体怎么用得从最基础的写法说起。定义一个 fixture 很简单importpytestimporttempfileimportospytest.fixturedeftemp_dir():tmptempfile.mkdtemp()yieldtmp shutil.rmtree(tmp)这个 temp_dir 会在测试开始前创建临时目录测试结束后自动清理。在测试函数里这么写deftest_save_file(temp_dir):file_pathos.path.join(temp_dir,data.txt)withopen(file_path,w)asf:f.write(test data)assertos.path.exists(file_path)这看起来很自然吧pytest 会自动识别参数名找到对应的 fixture 函数调用它。如果你需要 fixture 之间的复合可以写成pytest.fixturedeflogged_in_user(db_connection):userUser.create(nametest)yielduser user.delete()pytest.fixturedefcart_with_items(logged_in_user):cartShoppingCart(userlogged_in_user)cart.add_item(Item(namebook,price10.0))yieldcart cart.clear()测试函数只需要依赖 cart_with_itemspytest 会自动解析依赖树先跑 db_connection再跑 logged_in_user最后跑 cart_with_items。这种机制让测试的构建变得极其灵活你可以为不同的测试组合不同粒度的 fixture。最佳实践中有几个原则值得留意。第一个是确定 fixture 的粒度。有些团队喜欢极细的 fixture每个 fixture 只做一个简单的事比如只有一个用户对象、一个连接字符串。这样做的好处是复用性强但 fixture 数量会爆炸测试文件里到处都是 import。另一种极端是定义巨大 fixture把所有东西揉在一起。更推荐的做法是你的 fixture 应该站在业务场景的视角来定义。例如已登录用户且购物车有3件商品的场景可以作为一个 fixture这样测试代码读起来更像自然语言——“测试购物车满件打折功能时需要已有3件商品的购物车”。第二个经验是合理使用 scope。session 级别的 fixture 很诱人因为它可以节省大量重复初始化时间比如数据库 migration、外部 API token 获取。但 session 级别意味着 fixture 返回的对象在所有测试之间共享如果某个测试修改了这个对象的状态其他测试就会受影响。常见的做法是把不可变的部分放在 session 级别比如数据库连接字符串或配置对象而可变的数据放在 function 级别。另一种策略是使用 conftest.py 文件自动加载 fixture这个机制很实用但也会让 fixture 的来源变得隐蔽——尤其是大型项目时某个 fixture 到底定义在哪个 conftest 里可能难找。建议在 conftest.py 文件头部做明显注释或者只在 conftest.py 里放全局通用的 fixture其他按模块拆分。第三个经验是不要让 fixture 过于聪明。有些写代码的人喜欢在 fixture 里做复杂的业务逻辑判断比如根据测试名称动态改变返回的数据。这种做法虽然灵活但会让测试难以理解和维护。测试代码的价值在于它明确了被测系统的行为如果测试本身的行为就像黑盒那它就容易变成维护负担。更好的方式是靠参数化 fixture 或显式工厂 fixture 来实现变化。和同类技术对比最常被拿来比较的是 unittest 框架的 setUp/tearDown 方案。unittest 的 setUp 在类级别工作无法做到函数级别的粒度控制——同一个测试类里所有测试方法共享一个 setUp如果你想为一个测试换一组数据就只能创建新类。fixture 的粒度更细而且依赖注入式的写法让代码更清爽。另一个对比的是 Django 的 setUpTestData 和 setUp 方法。Django 提供了类级别和函数级别的测试准备但依赖反转控制不如 pytest fixture 自然。例如你想在测试中使用未登录用户的购物车场景unittest 的做法可能是在子类里重写 setUp耦合度高。也有不少团队在用 mock 模拟外部依赖时遇到困惑。mock 和 fixture 是互补的关系mock 解决的是外部依赖不在本地环境的问题fixture 解决的是测试环境准备工作的问题。常见的最佳组合是用 fixture 创建数据库、文件系统等本地资源用 mock 模拟外部 API 或第三方服务。一个经验是尽量避免在 fixture 内部使用 mock它会让 fixture 变得不纯净——同一个 fixture 在不同测试中对同一个外部调用可能产生不同的 mock 结果排查问题时令人头疼。还有一点值得提的是 pytest 和 nose 的关系。nose 也有类似的功能但不如 pytest fixture 的表达能力强。pytest fixture 的 yield 语法、scope 机制、自动注入这些特性整体上让测试代码更接近自然语言描述的场景而不是繁琐的准备工作。最后分享一个我从调试中得到的教训fixture 的执行顺序有时会让人迷惑。当一个测试用了多个 fixture而且部分 fixture 对其他 fixture 有隐式依赖比如修改全局缓存、更改环境变量这些依赖很难从代码层面看出来。遇到过最痛苦的一次是排查 CI 环境下的随机测试失败最后发现是某个 module 级别的 fixture 修改了 user 对象的默认权限影响到了其他模块的测试。从那以后我养成了一个习惯fixture 应当尽量是无副作用的如果必须修改全局状态就严格控制 scope并且显式地在 fixture 文档注释中写明。不算完美但至少算是一条生存之道。