## 从单元测试到系统测试: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 很简单:
importpytestimporttempfileimportos@pytest.fixturedeftemp_dir():tmp=tempfile.mkdtemp()yieldtmp shutil.rmtree(tmp)这个 temp_dir 会在测试开始前创建临时目录,测试结束后自动清理。在测试函数里这么写:
deftest_save_file(temp_dir):file_path=os.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):user=User.create(name="test")yielduser user.delete()@pytest.fixturedefcart_with_items(logged_in_user):cart=ShoppingCart(user=logged_in_user)cart.add_item(Item(name="book",price=10.0))yieldcart cart.clear()测试函数只需要依赖 cart_with_items,pytest 会自动解析依赖树,先跑 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 文档注释中写明。不算完美,但至少算是一条生存之道。