资讯详情

资讯详情

Pytest实战指南:从fixture到插件,精通Python自动化测试

做开发这几年我最大的一个感受是写测试这件事入门容易坚持难。早些年用 unittest 给项目补用例setUp 和 tearDown 那套类结构一写就是一堆模板代码再加上 assertEqual、assertTrue、assertIn 这些断言方法背起来又长又绕很多同事宁愿花时间写脚本手工点页面也不愿意碰测试代码。后来团队把 Pytest 引入主项目我一开始只当它是换了个写法真正跑完两个迭代之后才意识到这不只是换工具而是换了一套组织测试的思路。Pytest 是目前 Python 生态里最主流的测试框架靠的是更少的模板代码、更直观的原生断言、更灵活的 fixture 机制还有庞大的插件生态。这篇文章我会按自己实际接入项目的路径从选型思路讲到 fixture 机制再到插件组合和排坑实录把核心用法和那些只有踩过坑才知道的细节一次讲透。不管你是刚接触自动化测试的新人还是被 unittest 折磨过想换框架的老手这篇都值得花几分钟读完。1. 为什么是Pytest选型思路与设计哲学1.1 老牌unittest的痛点在哪里先说一个我自己的例子。之前维护一个内部工具接口不多但逻辑分支特别多我按 unittest 的规范写了一批测试用例每个测试类都要继承 TestCase每个用例要封装成类方法公共的初始化逻辑塞进 setUp跑完还要记得清理。代码看下来真正测试业务逻辑的部分只占三分之一剩下三分之二全是框架要求的壳。这不是我写得不好是 unittest 的设计风格本身偏重它从 xUnit 那套经典模式继承了大量结构类继承、特殊方法名、专用断言方法对大型测试工程来说够严谨但对日常项目来说成本偏高。更让人头疼的是断言那部分。unittest 提供几十个断言方法assertEqual、assertNotEqual、assertTrue、assertFalse、assertIn、assertNotIn、assertIsNone……光记名字就要记一阵子而且报错信息不一定直观有时候断言失败只提示False is not true你还要回去翻代码才能定位是哪一步出了问题。Pytest 的做法是完全不一样的它直接复用 Python 内置的 assert 语句断言表达式怎么写失败就怎么报连变量值都会给你打印出来。这一点看着简单用起来是真的省心。1.2 pytest的核心理念约定优于配置Pytest 最吸引我的一点是约定优于配置这个设计思路。它不要求你继承任何基类不强制你用特定的断言方法甚至不限制文件里能不能写普通函数。它只需要你遵守几条最基本的命名约定测试文件以 test_ 开头或test 结尾测试函数以 test开头测试类以 Test 开头且不写init方法。只要满足这些约定pytest 会自动发现并执行你的用例不需要额外注册、不需要配置文件也能跑起来。这种设计带来的直接好处就是入门门槛极低。我以前给组里新人做培训用 unittest 讲清楚类继承和 setUp/tearDown 的执行顺序就要一个小时换成 pytest十分钟就能让他们自己写出能跑的用例。你不需要理解 pytest 内部是怎么收集用例的只要记住文件名和函数名带上 test 就行剩下的交给框架。对团队来说统一风格的成本也低很多因为约定本身已经替你决定了大部分规范。1.3 和nose2、unittest的实际对比做技术选型的时候我们团队把 pytest 和 unittest、nose2 拉在一起做过一次简单对比当时整理了一张表后来一直贴在项目文档里对比维度unittestnose2pytest用例编写风格类继承样板代码多支持函数但生态较弱函数式优先简洁直接断言方式专用断言方法数量多支持原生 assert原生 assert失败信息详细fixture机制setUp/tearDown作用域有限有但生态不活跃fixture 函数作用域细粒度插件生态官方功能为主插件少维护一般插件丰富覆盖报告/并发/覆盖率等学习成本中高中低社区活跃度高但趋于稳定低高迭代快如果只是写一两个简单用例三者区别不大但一旦项目测试量上到几百条、几千条涉及数据准备、接口依赖、并发执行、测试报告这些真实需求pytest 的优势就非常明显了。尤其是它在社区维护和插件丰富度上遥遥领先很多场景你根本不用自己造轮子找现成插件装上就能用。这也是我把 pytest 当作默认选择的最重要原因。2. 环境准备与第一个测试用例2.1 安装与版本确认Pytest 的安装没什么特殊之处直接用 pip 装就行。建议在虚拟环境里操作避免污染系统 Pythonpip install pytest装完验证一下版本pytest --version我当前用的版本是 8.x新版本对 Python 的最低要求是 3.8如果你的项目还在跑 Python 3.7 以下建议先把解释器版本升上来或者用 7.x 的 pytest 凑合一下。实际开发中我还习惯顺手把几个高频插件一起装上这里先不展开后面第 4 章会专门讲。2.2 测试目录怎么组织项目里的测试目录不是随便乱放的我通常按下面的结构组织project/ ├── src/ # 业务源码 │ └── calculator.py └── tests/ # 测试代码 ├── conftest.py # 全局共享fixture ├── test_calculator.py └── test_api.pypytest 默认从当前目录递归查找符合条件的测试文件你可以在项目根目录直接敲pytest命令它会自动找到 tests 目录下的用例。这里有一个容易踩的坑如果 tests 目录下放了__init__.pypytest 会按包的方式导入测试模块模块命名冲突的概率会变大如果你不确定自己的导入路径设计建议先不要放__init__.py让 pytest 按根目录方式收集省很多事。2.3 写一个能跑的测试用例假设我们有一个简单的计算器模块src/calculator.pydef add(a, b): return a b def divide(a, b): if b 0: raise ValueError(除数不能为0) return a / b对应的测试文件tests/test_calculator.py可以这样写from src.calculator import add, divide def test_add(): assert add(1, 2) 3 def test_add_negative(): assert add(-1, 1) 0 def test_divide_by_zero(): try: divide(1, 0) except ValueError as e: assert 除数不能为0 in str(e)在项目根目录执行pytest输出会显示每个用例的通过情况绿色点代表通过。如果某个用例断言失败pytest 会非常详细地打印出表达式两边的实际值比如 assert add(1, 2) 4 E assert 3 43 4就是这么直观你不需要去查变量名和数值的对应关系一眼就知道错在哪。2.4 断言的艺术从assert到pytest.approxPytest 的断言基于原生assert但它的价值远不止能写那么简单。框架会在运行时对断言表达式做解析提取出实际值和预期值然后生成结构化、带上下文的失败信息。正因为这个特性你在写断言的时候可以完全自由地组合复杂的布尔表达式比如def test_list_operation(): result [i * 2 for i in range(5)] assert len(result) 5 assert 6 in result assert result[0] 0 assert all(x % 2 0 for x in result)这种写法在 unittest 里要么拆成多个断言方法要么写一堆辅助函数在 pytest 里就是一个平平无奇的 assert。还有两个高频场景需要注意。一是浮点数比较直接assert 0.1 0.2 0.3会因为二进制浮点误差失败正确做法是使用pytest.approxdef test_float(): assert 0.1 0.2 pytest.approx(0.3)二是预期抛出异常与其像前面那样写 try/except不如用pytest.raises代码更干净还能精确校验异常信息def test_divide_by_zero(): with pytest.raises(ValueError, match除数不能为0): divide(1, 0)3. fixture机制测试的依赖注入3.1 fixture到底解决了什么问题做测试最麻烦的事情之一就是准备环境连数据库、创建临时文件、初始化配置、登录拿 token。如果你在每个测试函数里都复制粘贴一遍初始化代码很快就变成一场灾难。fixture 就是用来解决这个问题的它本质上是一个被框架管理的依赖注入函数测试函数声明参数名pytest 就会自动把对应的 fixture 返回值传进来。最简单的用法是加一个装饰器import pytest pytest.fixture def user_token(): # 模拟登录返回一个token return fake-token-12345 def test_get_user_info(user_token): headers {Authorization: fBearer {user_token}} assert headers[Authorization] Bearer fake-token-12345不需要在测试里主动调用user_token()只要函数参数列表出现user_tokenpytest 就会自动执行这个 fixture 函数并注入返回值。这种隐式依赖看起来有点魔法但实际用起来非常顺手尤其是在大型测试工程里它能把每个用例的关注点压缩到最小。3.2 yield fixture与清理逻辑前面那个例子返回纯数据还好如果 fixture 里创建了数据库连接、临时目录或者启动了一个进程用完之后必须清理。此时可以把 fixture 改造成生成器形式在yield之前是准备阶段yield之后是清理阶段。import pytest import tempfile import os pytest.fixture def temp_file(): fd, path tempfile.mkstemp() with open(path, w, encodingutf-8) as f: f.write(pytest) yield path os.close(fd) os.remove(path) def test_read_temp_file(temp_file): with open(temp_file, r, encodingutf-8) as f: assert f.read() pytest这个用例跑完临时文件会被自动删除不需要在每个测试里手动清理。如果你有多个测试依赖同一个临时文件可以再结合作用域来控制生成频率这就是下一步要聊的。3.3 conftest.py与作用域管理fixture 可以定义在测试文件内部但更常用的是放到conftest.py里这样同一目录及其子目录下的所有测试文件都能共享。conftest.py是 pytest 的约定文件不需要 importpytest 会自动加载它。它也是放全局插件注册、命令行选项、钩子函数的地方。fixture 默认是函数级作用域也就是每个测试函数执行前都会重新执行一次 fixture。如果初始化过程很耗时比如连接数据库、启动浏览器可以指定更大的作用域pytest.fixture(scopesession) def db_connection(): # 整个测试会话只执行一次 conn create_connection() yield conn conn.close()作用域从小到大分别是 function、class、module、package、session选择的原则很简单能复用就复用但要小心状态污染。我踩过一次坑把一个登录态的 fixture 设成了 session 级结果某个用例修改了用户信息后边其他用例全挂了。后来我在 session 级 fixture 里只放只读的配置和连接可变的数据尽量用 function 级重新生成。3.4 参数化一组数据跑出多个用例测试算子里最常用的技巧是参数化一条测试逻辑配上多组输入输出就能覆盖大量分支。pytest.mark.parametrize是它的核心入口import pytest pytest.mark.parametrize(a,b,expected, [ (1, 2, 3), (-1, 1, 0), (0, 0, 0), (100, 200, 300), ]) def test_add(a, b, expected): assert add(a, b) expected运行后你会发现 pytest 把每一组参数当成独立的用例来显示一条测试逻辑可以生成 4 条用例记录。哪个参数组合挂了失败信息里会直接写明参数值定位非常方便。参数化还有一个进阶用法叫间接参数化把参数传递给 fixture 来决定测试环境比如同一套接口用例在测试环境和预发环境各跑一遍这也是我在实际项目中用得很多的功能。4. 进阶玩法插件生态与实战技巧4.1 常用插件组合Pytest 的生态是我见过最友好的。你不需要全部记住我列一下我每个项目都会装的五个插件以及它们各自解决什么问题插件用途典型命令pytest-html生成 HTML 测试报告pytest --htmlreport.htmlpytest-cov统计代码覆盖率pytest --covsrc --cov-reporthtmlpytest-xdist多进程并行执行用例pytest -n 4pytest-rerunfailures失败用例自动重试pytest --reruns 2pytest-timeout设置单个用例超时时间pytest --timeout30安装就是一条命令的事pip install pytest-html pytest-cov pytest-xdist pytest-rerunfailures pytest-timeout这几个插件能覆盖大部分日常需求。比如项目测试量比较大加-n 4就能让用例在 4 个进程里并行跑总共耗时能缩短一半以上接口偶发超时导致用例不稳定用--reruns 2让它重试两次给每个用例加--timeout30防止某个用例卡死把整个测试流程拖住。这些都是我在真实项目中验证过非常有效的组合。4.2 标记、条件跳过与预期失败不是所有用例在每种环境下都应该执行。比如某些用例依赖外部服务本地开发没有这个服务此时直接跳过比让它失败更合理。Pytest 提供了标记机制可以用pytest.mark.skip无条件跳过也可以用pytest.mark.skipif按条件跳过import sys import pytest pytest.mark.skipif(sys.platform win32, reasonWindows环境暂不支持该测试) def test_linux_only_feature(): ... pytest.mark.skip(reason接口还没实现) def test_not_implemented(): ...还有一种情况是某个 bug 已知但还没修复想先把用例写上去又不想让它影响整体结果可以标记为预期失败pytest.mark.xfail(reason已知问题等待修复) def test_known_bug(): ...如果这个用例意外通过了pytest 会标出 xpass这个时候你就该去确认 bug 是不是已经修好了可以把这个标记拿掉。这个机制在推进技术债时特别好用既能留下测试又不会让 CI 持续变红。4.3 接口自动化测试实战案例空谈理论没什么感觉我拿一个典型的接口测试场景串一遍。假设有一个用户查询接口GET /api/user/{id}返回 JSON 数据。我会把测试分成三层conftest 里放 base_url 和 session 级的请求会话fixture 负责构造合法用户数据测试函数专注断言。# conftest.py import pytest import requests pytest.fixture(scopesession) def base_url(): return http://127.0.0.1:8000 pytest.fixture(scopesession) def http_session(base_url): session requests.Session() session.headers.update({Content-Type: application/json}) yield session session.close()# test_user_api.py import pytest import requests pytest.mark.parametrize(user_id,expected_name, [ (1, Alice), (2, Bob), (999, None), ]) def test_get_user(http_session, base_url, user_id, expected_name): resp http_session.get(f{base_url}/api/user/{user_id}) assert resp.status_code 200 data resp.json() if expected_name is None: assert data[name] is None else: assert data[name] expected_name把接口地址、请求会话、测试数据都拆开之后后面的维护成本非常低。接口字段变了只改一行断言请求头变了只改 conftest要加一组测试数据只加一行参数。这就是 pytest 在工程化层面带给我的最大收益测试代码可以像业务代码一样被结构化地组织和演进。5. 常见问题与排查技巧实录5.1 用例收集失败或跑不起来初学者最容易遇到的问题就是明明写了测试文件pytest 却一个用例都没收集到。常见原因有三个文件名不是test_开头或_test结尾测试函数名不是test_开头目录名包含特殊字符导致 pytest 无法导入。排查方法很简单先运行下面的命令看 pytest 到底发现了什么pytest --collect-only它会把所有收集到的用例列出来如果提示no tests ran基本就是命名不符合约定。如果你用了--collect-only还是找不到再检查一下是不是忘了加__init__.py导致的包导入问题或者 conftest.py 里是否有语法错误因为 conftest 的导入失败会让整个测试收集过程静默失效。5.2 fixture重名与作用域冲突fixture 同名时pytest 会优先选择更局部的作用域也就是离测试文件更近的 fixture 会覆盖 conftest 里的同名 fixture。这个规则本身合理但如果你在 conftest 里定义了一个通用 fixture又在某个测试文件里定义了同名 fixture 但功能不一样排查起来就很费劲。我建议的规范是通用 fixture 集中在顶层 conftest.py目录专属的 fixture 放在对应目录的 conftest.py测试文件里尽量只写本文件专用的 fixture并且命名前先全局搜一下有没有重名。另一个常见问题是 fixture 里抛了异常但报错信息被吞掉。有时候 fixture 准备数据失败pytest 会报 fixture 相关错误但你在失败信息里看到的不是原始异常而是fixture xxx failed这种笼统提示。处理办法就是看完整堆栈建议在运行命令里加-v和--tblongpytest -v --tblong这样每个 fixture 的错误堆栈都会完整展示定位问题快很多。5.3 相对路径、编码与缓存坑测试代码里最忌讳直接依赖当前工作目录。pytest 在执行时当前目录通常是启动命令所在目录如果测试用例里用了open(data.txt)这种相对路径换个地方跑就挂了。我的习惯是先用Path(__file__).parent定位测试文件所在目录再基于它向上或向下找资源文件from pathlib import Path TEST_DIR Path(__file__).parent DATA_FILE TEST_DIR / data / test_data.json编码问题也很常见。Windows 环境下控制台默认编码可能是 GBK测试里如果打印或断言包含中文的字符串容易报编码错误。建议在 pytest.ini 或 pyproject.toml 里加上[pytest] addopts -p no:cacheprovider不过我一般不用这行而是建议团队成员统一在 pytest.ini 里配置编码相关参数或者在 fixture 里处理。需要注意的是.pytest_cache缓存目录会记录上次运行的状态某些诡异问题可以试试删掉它再跑不要忽视这个缓存带来的副作用。5.4 问题排查速查表我把这几个月的实际体验整理成了一份速查表遇到问题时先对照看一下大部分情况都能覆盖现象可能原因排查/解决0 tests collected命名不符合约定检查文件名、函数名是否 test 开头中文断言失败乱码控制台编码问题设置 PYTHONIOENCODINGutf-8fixture 报错但看不到原因异常被框架包装加 --tblong 看完整堆栈用例互相影响fixture 作用域过大检查可变数据是否用了 session 级相同用例重复跑很久未使用并行插件加 pytest-xdist 并用 -n 参数新加的用例没执行缓存了旧的收集结果删 .pytest_cache 或加 --clear6. 结尾一点私人的经验如果你问我 pytest 和其他测试框架相比最值得学习的一点是什么我会说是它对人的习惯的尊重。它没有强迫你用类、用继承、用一堆规定好的方法而是顺着你自然写代码的方式帮你把测试这件事变得不那么像负担。当然框架本身只是工具真正决定测试质量的是你对业务的理解和用例设计的能力但一个好的框架至少能让你愿意去写测试而不是每次打开测试文件就想关掉。最后再分享一个小技巧日常开发时我会在终端里配置一个别名pt等于pytest -q --disable-warnings跑测试的时候输出清爽很多。配合 IDE 的 pytest 插件写完函数顺手在旁边点一下运行这种低成本、高反馈的循环才是坚持写测试的最大动力。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →