如果你写过一段时间的自动化测试,大概率体会过这种场景:明明只是验证一个函数能不能正确返回,却要from unittest import TestCase,写一个类、继承它、再塞进去好几个方法名以test_开头的方法才能跑起来。等用例数量上了几百条,setUp和tearDown里的逻辑像毛线团一样缠在一起,删一条用例都可能让别的用例莫名报错。后来我换到Pytest框架之后才想明白,测试代码写得不舒服,真不全是自己的问题,是工具的表达能力跟不上需求了。
Pytest框架做对的核心事情是"顺着人写代码的习惯走":普通函数就能当用例、assert原生的断言方式、再加上fixture和参数化这套机制,基本上把测试代码从"写起来麻烦"变成了"写起来顺"。这篇文章不是官方文档的翻译,我会从实际使用的体验出发,讲清楚为什么要选pytest、怎么从零搭起来、以及我在真实项目里跑测试踩过的那些坑。无论你是刚接触自动化测试的新手,还是被unittest烦到想换工具的老手,这篇内容都可以直接对照着落地。
1. unittest被我扔到一边之后:pytest到底赢在哪里
1.1 先对比一下两种写法的体感差异
拿一个最普通的计算器加法函数来测。unittest的写法是这样的:
import unittest class TestCalculator(unittest.TestCase): def setUp(self): self.calc = Calculator() def test_add(self): result = self.calc.add(1, 2) self.assertEqual(result, 3) if __name__ == "__main__": unittest.main()而pytest的写法是这样的:
def test_add(): calc = Calculator() assert calc.add(1, 2) == 3没有类、没有继承、没有assertEqual这种专门记忆一套断言方法的负担,一个普通的assert就完成了校验。这看起来只是简化了样板代码,但实际影响远不止少写几行字:当你测试的东西一多,大脑的认知负荷会从"我怎么写测试结构"转移到"我到底要验证什么逻辑"上,这才是pytest最值钱的地方。
1.2 断言失败时的信息量天差地别
很多人没注意到的一个细节是:pytest的assert失败信息比unittest丰富得多。比如同样测1 + 1 == 3:
- unittest的
assertEqual失败只会告诉你"3 != 1"这种干巴巴的内容。 - pytest会对失败表达式做结构化拆解,直接展示参与比较的具体变量值,对于字典、列表、对象这类复杂结构,它还会标记出哪个字段不匹配。
这意味着排查测试失败原因时,pytest几乎把一半的调试工作替你做了。对于新手来说,这个特性简直是救命稻草,不用再去手动打印变量来猜测问题。
1.3 用fixture替代setUp和tearDown的思维转变
unittest时代,每个测试类可以有自己的setUp和tearDown,看起来很合理,但一旦测试类多了,你会发现很多代码是重复的:都要创建被测对象、都要建立临时目录、都要造一份测试数据。这个重复本质上是因为setUp只能按"类"来复用,跨类的共享逻辑要么复制粘贴,要么搞一个BaseTestClass来继承。
pytest用fixture彻底改变了这个模式。fixture是一种独立的、可命名的资源函数,任何测试函数只要在参数列表里声明一个同名的参数,就能自动拿到这份资源。它不跟类绑定,天然支持模块级、会话级的作用域,且可以随意组合依赖。我后面会专门讲fixture的细节,这里想表达的是:这个机制让"测试资源的管理方式"从原先的类继承体系里解放了出来,代码结构变得非常扁平易懂,项目越大收益越明显。
2. 安装与工程初始化:从pip到PyCharm,一步都不能漏
2.1 用pip安装pytest的正确姿势
安装本身很简单,一条命令搞定:
pip install pytest但我强烈建议你先创建一个虚拟环境,而不是直接全局安装。原因很简单:不同项目的依赖版本互相污染是自动化测试里最隐蔽的坑。今天给A项目装了一个新库,明天B项目的pytest莫名启动失败,排查到最后发现是依赖冲突,这光我身边就发生过不止一次。
我现在的固定流程是:
python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install pytest pytest --version看到版本号输出,说明安装成功。如果你在一个团队里,建议把pytest以及后面讲的插件都写进requirements.txt或者pyproject.toml,锁好版本,避免"在我电脑上是好的"这种尴尬事。
2.2 目录结构与命名规则:别小看test_前缀
pytest靠约定来收集测试用例,不认什么配置文件也能跑,但前提是你遵守它的命名规则:
- 测试文件:
test_*.py或*_test.py - 测试函数/方法:
test_开头 - 测试类:
Test开头,且不要带__init__方法
一个推荐的项目结构长这样:
my_project/ ├── src/ │ └── calculator.py │ └── order_service.py └── tests/ ├── conftest.py ├── test_calculator.py └── test_order_service.py这里有个很多人会忽略的细节:pytest在执行时会把当前目录加入sys.path,如果你的测试文件需要import src.calculator,最好把src做成一个包或者手工在conftest.py里添加路径。否则你会在命令行跑测试时发现一切正常,换个目录或者用IDE跑就ModuleNotFoundError了。
我自己的习惯是,在每个测试模块顶部统一用相对包路径导入被测代码,并且坚持只从src包入口导入,坚决不用绝对路径的sys.path.insert。这样项目的迁移和CI环境配置都会轻松很多。
2.3 在PyCharm里跑pytest:一个最容易被卡住的配置
PyCharm默认情况下,右键运行测试文件时可能会用unittest的runner来执行,哪怕你的代码是pytest风格,它也会尝试用unittest的规则去跑,结果往往是一堆"找不到测试"报错,让人一头雾水。
解决办法很简单:Settings -> Tools -> Python Integrated Tools -> Testing -> Default test runner,选择pytest。改完之后,PyCharm会重新索引测试用例,右键运行时就能正确识别pytest的用例了。后面的--cov参数、并行执行等命令行选项,也可以通过Run/Debug Configurations里的"Additional Arguments"传进去。
如果你在终端里跑pytest跑得好好的,但PyCharm里出了问题,优先检查这个设置。这不是玄学,90%的IDE相关pytest启动失败都是因为这个测试运行器没有切换过来。
3. fixture:测试代码里最容易被低估的核心机制
3.1 用一个生活类比理解fixture:厨房的"备菜"动作
fixture光看名字会让人觉得很高级,其实它的思想非常朴素:就好比做饭前,你先把菜洗好、切好、码在盘子里,等真正下锅的时候直接拿就行。对应到测试中,fixture就是那个"提前备好的菜",测试函数是"下锅炒的菜",两者通过参数名建立对应关系。
定义fixture的方式:
import pytest @pytest.fixture def calculator(): return Calculator() def test_add(calculator): assert calculator.add(1, 2) == 3注意到没有,test_add的参数列表里多了一个calculator,pytest看到同名fixture后,会自动调用它并把返回值传给测试函数。你完全不需要手动calc = Calculator()了。
3.2 yield的使用:setup和teardown的合二为一
很多资源需要"用完销毁":数据库连接要关闭、临时文件要清理、浏览器实例要退出。unittest里面你要分开写在setUp和tearDown两个方法里,而pytest只需在一个fixture中用yield一分为二:
@pytest.fixture def db_connection(): conn = create_connection() yield conn # yield之前的代码是setup conn.close() # yield之后的代码是teardown更妙的是,多个fixture可以嵌套依赖。比如db_session依赖db_connection,pytest会自动先创建连接,再创建会话,等测试结束后按逆序清理。资源和生命周期就这样被层层管理起来了,比手工try/finally干净得多。
3.3 fixture的作用域与autouse:用对地方是利器,用错是灾难
fixture默认是function级别的,也就是每个测试函数都会重新执行一次。但对于创建成本高的资源,比如初始化一个浏览器驱动或者建立数据库连接,每次都重建显然太浪费。这时可以把作用域改成session或module:
@pytest.fixture(scope="session") def browser(): driver = init_browser() yield driver driver.quit()另一个常用参数是autouse=True,表示不需要显式声明,所有测试自动使用。这个选项很诱人,但请谨慎:一旦fixture隐式地在每个测试里运行,排查失败时就少了一个明确的线索。我一般只把"所有用例都必须有"的公共初始化逻辑(比如设置环境变量、清理某个全局缓存)设成autouse,其他资源一律显式声明。
作用域的完整对照如下:
| 作用域 | 生效范围 | 典型适用场景 |
|---|---|---|
| function | 每个测试函数 | 临时数据、单次计算对象 |
| class | 每个测试类 | 共享同一个被测实例 |
| module | 每个测试模块 | 模块级公共资源 |
| session | 整个测试会话 | 数据库连接、浏览器实例 |
3.4 conftest.py:全局公共资源的"集散地"
conftest.py是pytest最灵活也最容易用混乱的文件。它的机制是:放在某个目录下,该目录及所有子目录下的测试都能自动使用其中定义的fixture,不需要任何import语句。
我们的项目里,根目录的conftest.py放着全局通用的fixture(比如测试数据库配置),子目录的conftest.py放该模块独有的fixture。使用上的经验是:conftest越大越难维护。千万不要把所有东西都塞进去,fixture的依赖关系会变得隐晦,新人看到测试用例里凭空出现一个参数,得翻好几层conftest才能找到来源。
我遇到过一个反面教材:一个项目的conftest.py有六百多行,几十个fixture互相嵌套,排查一个"为什么这个用例跑得这么慢"的问题花了两天,最后发现是有个session级别的fixture在底层对每个请求做了网络同步调用。这类问题一旦发生,比代码本身的问题更难定位。务实地讲:conftest里只放真正全局的、稳定的fixture,其他能放到测试模块里的,就放回模块里去。
4. 参数化与断言技巧:让测试用例的覆盖密度成倍提升
4.1 参数化的三种常见写法
很多时候,一个测试逻辑要覆盖多组数据:左右边界值、空值、非法值。如果每个数据组合写一个测试函数,代码会膨胀到不可读。pytest的参数化用@pytest.mark.parametrize解决:
import pytest @pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, 200, 300), ]) def test_add(a, b, expected): assert a + b == expected执行后,pytest会把四组数据展开成四个独立的测试用例,任何一个失败都不会影响其他用例的运行,报告的展示粒度也更细。
如果你需要在此基础上再组合多组fixture,直接把fixture参数和参数化参数混着写就能生效。比如参数化的用户名列表,配合一个login_user的fixture,可以实现"多个用户身份跑同一段业务用例"的效果。
4.2 给参数化用例设置ids:别让报告变成一堆乱码
默认情况下,参数化用例的显示名是test_add[1-2-3]这种风格,数据一多,报告根本没法看。可以用ids参数给每一组数据命名:
@pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), ], ids=["positive", "zero", "negative"]) def test_add(a, b, expected): assert a + b == expected这样Allure或者HTML报告中显示的用例名就是test_add[positive],一眼就知道它在验证什么意图。很多新手写参数化从来不关心ids,几百条用例跑挂了,光看名字完全不知道哪个场景失败了,排查效率极低。
4.3 断言的艺术:不止是会写assert
pytest的断言核心是Python原生assert,但它还内置了几个非常有用的辅助工具:
pytest.raises(ValueError):断言某个异常必然发生。pytest.approx:处理浮点数比较,解决0.1 + 0.2 != 0.3的问题。pytest.warns:断言某个警告必然出现。
import pytest def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): result = 1 / 0 def test_float_precision(): assert 0.1 + 0.2 == pytest.approx(0.3)浮点数比较是个非常隐蔽的坑。你用assert 0.1 + 0.2 == 0.3去跑,它一定会失败,因为二进制浮点数无法精确表示0.3。很多测试新手在这里卡住,然后开始怀疑"是不是pytest有问题",实际上是需要approx来做容差比较。这个知识点我在指导新人时几乎每次都要讲一遍。
4.4 失败重试:是个好东西,但别拿它掩盖真实bug
有些测试偶发失败,比如网络延迟、外部服务抖动、并发资源竞争。此时pytest-rerunfailures插件可以自动重试:
pip install pytest-rerunfailures pytest --reruns 3 --reruns-delay 1但我的态度是:重试只能用在你已经确认"这个失败是环境因素导致的,不是业务代码问题"。如果某个用例连续失败,重试通过之后你就不管了,那它背后的真实问题可能一直在裸奔。最好的做法是:坏了就修,修不了就标记@pytest.mark.flaky(reruns=2)并写上原因注释,而不是全局开一个永久的--reruns。
5. 插件生态与Allure报告:从"跑得动"到"看得清"
5.1 pytest-xdist:把测试从串行变成并行
当一个项目的用例数量上来之后,串行执行的时间会让人抓狂。pytest-xdist插件让并行变得只需要一个参数:
pip install pytest-xdist pytest -n auto-n auto会根据CPU核心数自动决定并行进程数。这里也有一个隐蔽的坑:如果你的fixture不是线程安全或进程安全的,并行执行会导致奇怪的失败。比如一个模块级的临时文件fixture,每个进程都会往同一个路径写入,就会互相覆盖。用-n之前,最好先小范围并行跑一次,确认所有fixture在设计上都支持并发。
5.2 pytest-cov:覆盖率数字不代表一切
pip install pytest-cov pytest --cov=src --cov-report=html这是我最推荐给团队的"第一张量化的测试健康度仪表盘"。--cov=src指定统计被测代码的目录,--cov-report=html生成浏览式的报告,可以看到每个文件的行覆盖率和分支覆盖率。
我要泼一点冷水:覆盖率80%并不等于测试质量80分。覆盖率只是告诉你"哪里的代码在测试中没被执行到",而不是"哪些行为被验证对了"。一个只测了正常路径的用例也能刷出很高的覆盖率,但它对异常处理的保护可能为零。所以覆盖率低于某个阈值(我一般要求团队新模块不低于70%)应该被阻止合入,但达到阈值之后,真正要看的反而是那些没有被覆盖的分支。
5.3 Allure报告:把测试结果变成能给人看的东西
pytest自带的终端输出和JUnit XML报告,适合机器读,不适合人看。当项目需要汇报、复盘、或者测试人员要给产品和开发讲清楚失败原因时,我强烈推荐Allure。
使用步骤:
- 安装命令行工具:macOS下
brew install allure,Windows下可以用scoop install allure。 - 安装pytest插件:
pip install allure-pytest。 - 执行并生成原始结果:
pytest --alluredir=./allure-results。 - 生成并打开报告:
allure serve ./allure-results。
Allure报告里最有用的几个能力:每个步骤和参数的追踪可以让你快速定位失败发生在哪一步;实时的趋势图可以直观反映一整个迭代周期的测试健康度变化;针对不稳定用例的失败分类,可以帮你区分"产品bug"还是"环境问题"。我在团队里引入Allure后的效果立竿见影——测试人员向研发提bug时,不再需要复制一堆终端日志,直接甩一个步骤log链接,对方点开就能自己看到问题在哪。
6. 真实项目的pytest落地记录:从0到1再到顺畅跑
6.1 项目背景与最初的目标
我去年接手过一个微服务的回归测试改造。原先团队用一段非常古老的、基于unittest的脚本,测试用例有三百多条,但跑完一轮要二十多分钟,而且经常因为顺序依赖导致莫名失败。机器上跑不通,CI上更跑不通,最后没有人愿意维护,测试基本处于"名存实亡"的状态。
这次改造的目标定得很务实的五条:
- 用例可以随便挑选子集执行,不存在跨文件依赖。
- 单文件测试可以在30秒内跑完,全量回归控制在3分钟内。
- 有可读的失败原因报告,至少能从报告看出是哪个接口哪个参数出问题。
- 新增用例的成本要低,新人一天之内能学会怎么写。
- 能在CI流水线里稳定跑。
6.2 迁移过程中踩过的三个坑
第一个坑是关于收集路径的。迁移后的第一个周末,我写好了pytest.ini和几十条用例,兴奋地在项目根目录跑pytest,结果它把我的src包目录也当成了测试收集目标,很多不相关模块里的test_文件被当成用例执行,直接报了十几个import错误。解决方法是明确指定收集路径和忽略规则:
# pytest.ini [pytest] testpaths = tests第二个坑是session级fixture的数据串扰。我把一个"共享数据库连接"的fixture设成了session作用域,本意是复用一个数据库连接提升性能。后来发现,一个测试对数据库的写入会影响另一个测试的读取结果,因为连接是共享的,事务之间没有隔离。最后的修正方案是:连接本身复用,但每个测试在自己的事务里操作,结束后回滚,保证互不干扰。
第三个坑几乎每个人都可能遇到:fixture里用了yield但忘记在finally里清理资源。有一个temp_dir的fixture创建的临时目录,测试结束了也不删,跑了几百轮测试之后,磁盘被占满,CI直接挂了。后来统一改成了yield配合shutil.rmtree,并保证即使断言失败也要执行到清理逻辑。这也是为什么我前面强调 yield之后的teardown要放在finally块里的原因。
6.3 改造完成后的真实收益
改造稳定运行后,全量回归从原来的二十多分钟降到了大概三分钟(借助-n auto并行),而且几乎没有"偶发失败"了。更重要的是,这条流水线变成了一个强约束:每次提交代码,CI自动跑全量用例,失败了会生成Allure报告,开发者自己就能定位问题。测试从一个"名义上存在"的环节,变成了真正能拦下回归bug的关口。
我最深的体会是:pytest不是魔法,它并不能让烂代码自动变好,但它的表达方式让"写出清晰可维护的测试"这件事的门槛大幅降低了。当你不再为框架的语法规则分心,测试思路自然就清晰了,用例写起来顺手了,维护的意愿也就提高了。
最后单独分享一个我坚持了很久的小习惯:接到一个新需求时,先写一条预期会失败的测试用例,看它失败,再写业务代码让它通过。这个"先红后绿"的节奏,会让你在写业务逻辑的同时把所有边界条件想清楚。pytest的精确断言和快速反馈,恰好就是支撑这个习惯最好的土壤。