ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pytest实战指南:从断言到fixture再到插件生态

Pytest实战指南:从断言到fixture再到插件生态 在Python圈子里混久了你早晚要面对一个问题怎么保证你写的代码不会因为一次改动就崩掉手写print调试、手动点界面验证这些方法在小项目里还能凑合一旦项目上了规模就会有强烈的动力去引入一套正规的测试方案。我试过unittest、pytest、nose这几个主流框架折腾一圈下来pytest被固定成了默认选项而且这一用就是好几年。pytest这个关键词在Python测试领域几乎成了代名词它不但能写单元测试还能做接口测试、数据驱动测试、甚至配合Selenium做UI自动化覆盖面非常广。这篇文章我会从一个实际动手者的角度把pytest的核心概念、使用方法和踩坑经验一次讲清楚。不管你是刚接触Python的小白还是写过一阵子unittest但觉得别扭的老手都可以照着文章里的步骤实际操作一遍。内容会覆盖环境安装、断言写法、fixture机制、参数化、插件扩展和问题排查按着我的思路走下来你就能搭建一套够用、好维护的测试体系。1. 为什么是pytest测试框架选型背后的思考1.1 从unittest说起它到底卡在哪不少Python开发者第一次接触测试框架基本都是被unittest“劝退”的。我这里不是说unittest不能用它在标准库里待了这么多年稳定性和兼容性没得挑但它的书写风格确实太“端着”了。一个简单的加法函数测试用unittest写需要继承TestCase类、定义setUp和tearDown方法、用self.assertEqual来做断言。整套流程下来十行能解决的问题硬生生写成了三十行而且和现代Python的简洁风格显得非常割裂。很多人刚入门时会觉得“是不是我写得不对”其实不是你的问题是unittest的设计思路本身就很老派。它脱胎于Java的JUnit思想把“测试类”“测试方法”“夹具生命周期”这些概念强加给Python但实际上Python有更灵活的表达方式。用了pytest之后你会发现一个简单的函数测试直接写一个普通函数加一行assert断言就够了不需要类、不需要继承、不需要你在脑海里做一堆概念映射。还有一点是unittest的插件生态和报告输出能力偏弱。虽然也可以接入第三方库生成报告但配置过程繁琐很多人跑到配置这步就放弃了。而pytest的插件生态非常成熟装一个插件、加一个命令行参数就能获得漂亮的HTML报告、并发执行、覆盖率统计这些能力。对一个要长期维护的项目来说这些能力直接决定了测试环节好不好落地。1.2 pytest的核心优势以及它在自动化测试中的位置pytest最打动我的三个点我简单总结一下。第一是断言写法足够直白直接用Python原生的assert关键字失败了就清清楚楚告诉你哪个表达式为False不像unittest那套self.assertEqual、self.assertTrue需要额外记忆。第二是fixture机制足够灵活可以精确控制每个测试用例的依赖数据、前置条件和清理动作还支持按作用域复用、按参数动态返回用起来非常顺手。第三是插件生态pytest-html生成测试报告、pytest-xdist并行跑用例、pytest-cov统计覆盖率这些都是“装完就能用”的级别不需要写一堆胶水代码。在自动化测试这个圈子里pytest的定位更像是“测试执行引擎”。你是做接口自动化也好、UI自动化也好、数据校验也好都能拿pytest当底座再往上搭Requests、Selenium、Appium这些工具。现在很多企业招聘测试开发岗位pytest都是在JD里明确写着的技能项这也从侧面说明了它的市场地位。如果你在Python测试方向上有长期规划pytest是你绕不开的一环。2. 环境准备与第一个测试用例2.1 安装pytest从pip到虚拟环境在开始之前先确认你的电脑上有Python环境。建议使用Python 3.8以上版本往下兼容性太差部分新特性用不了。安装pytest非常简单打开终端执行pip install pytest如果你使用的是国内网络环境下载速度比较慢可以加一个镜像源pip install pytest -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后验证一下版本号确认安装成功pytest --version这里我说一个我自己的习惯所有Python项目我都建议用虚拟环境隔离依赖不要一股脑往全局环境里装包。用python -m venv venv创建一个虚拟环境激活之后再装pytest。这样做的好处是每个项目的包版本互不干扰你在这个项目里升级了pytest不会把另一个项目的测试环境搞崩。这属于是踩过坑之后才养成的觉悟早期偷懒用全局环境结果不同项目依赖打架光排冲突就花了半天时间。2.2 第一个测试用例测试文件命名与函数式写法安装完成之后我们写一个最简单的测试。新建一个目录比如叫demo_project在里面创建两个文件。第一个文件是待测模块calculator.pydef add(a, b): return a b def subtract(a, b): return a - b第二个文件是测试文件test_calculator.pyfrom calculator import add, subtract def test_add(): assert add(1, 2) 3 def test_subtract(): assert subtract(5, 3) 2注意这里的命名规则pytest默认会查找test_*.py或*_test.py格式的文件对于文件内的测试函数要求函数名以test_开头。这个规则不是随便定的它是pytest的收集策略遵守这个约定pytest才能在指定目录下自动发现测试用例。如果你不按这个规则命名pytest会直接忽略你的文件运行的时候什么都测不到这是新手最容易踩的第一个坑。写完之后在终端执行pytest命令pytest你会看到类似这样的输出collected 2 items test_calculator.py .. [100%] 2 passed in 0.02s 看到2 passed就说明测试通过。如果想把每个用例的执行情况看得更清楚可以用pytest -v它会把每个测试函数名和结果逐行列出来。2.3 断言失败时pytest给了你什么信息测试最重要的用途之一是展示失败信息。回到上面的例子如果我们故意把断言写错比如断言add(1, 2) 4运行pytest后输出会非常直观地提示你 assert add(1, 2) 4 E assert 3 4 E where 3 add(1, 2)这里3 4是核心信息它能直接告诉你实际值是3期望值是4。更贴心的是pytest还会用 where告诉你这个实际值是怎么来的等于帮你做了部分调试工作。unittest在这方面的输出就要简略得多很多时候只告诉你“断言失败”你还得自己回头打日志。所以在写测试的时候我强烈建议你的断言写得“有信息量”。比如断言列表长度时不要只写assert len(items) 5可以拆开写成assert len(items) 5, f列表长度应为5实际为{len(items)}这样失败时的报错信息自带上下文排查问题会快很多。3. fixture机制pytest最核心的能力3.1 fixture到底解决了什么问题如果你只写最简单的单元测试断言用上pytest就够了。但真实项目的测试没有这么单纯几乎所有用例都需要准备数据、初始化对象、清理环境这些“测试前的准备工作”和“测试后的清理动作”在pytest里统一交给fixture来处理。fixture这个概念不太好理解我用一个生活化的类比来拆解。假设你要请朋友来家里吃饭步骤大概是这样的买菜、洗菜、切菜、炒菜、上桌、吃完饭收拾碗筷。“菜”和“食材”就是测试依赖的数据“洗菜切菜”就是前置准备动作“收拾碗筷”就是清理动作。在pytest里fixture就是那个帮你把整个流程管理起来的人你告诉它你需要什么它在测试执行前把东西备好测试结束后再帮你收拾干净。下面是一个典型场景假设每个测试用例都需要一个临时的数据库连接对象。import pytest pytest.fixture def db_connection(): # 模拟建立数据库连接 conn create_connection(localhost, testdb) yield conn # 测试执行到这里时拿到conn # 测试结束后执行清理关闭连接 conn.close() def test_query_user(db_connection): user db_connection.query(SELECT * FROM users WHERE id1) assert user.name Alice注意到yield的用法了吗yield之前的代码是“准备阶段”测试用例执行时拿到的就是yield出来的值而yield之后的代码会在测试结束后自动执行非常适合做资源释放、文件关闭、临时数据删除这些清理动作。这个模式我几乎天天在用可以说它是pytest fixture最优雅的设计之一。3.2 fixture的作用域session、module、class、functionfixture默认每个测试函数执行前都会完整跑一遍这在很多场景下是浪费的。比如你有一个非常耗时的初始化操作每个用例都跑一遍整个测试套件的执行时间就会变得很长。这时候需要给fixture指定作用域。pytest.fixture(scopesession) def db_connection(): conn create_connection(localhost, testdb) yield conn conn.close()scopesession表示整个测试过程只初始化一次所有测试用例共享同一个连接对象。作用域的选择规则其实挺简单按“共享程度”从大到小排列是sessionmoduleclassfunction。作用域生效时机适用场景function每个测试函数执行前后各跑一次最常见的默认配置class每个测试类执行前后各跑一次类级别的共享数据module每个测试模块执行前后各跑一次模块内共享开销大的对象session整个测试会话只跑一次数据库连接、全局配置这里我要提醒一个非常容易被忽视的问题session作用域虽然省时间但也会引入“测试之间的数据污染”。比如两个测试用例共用一个数据库连接第一个用例往表里插了一条数据第二个用例查的时候发现结果多了一行用例就失败了。所以session级的fixture最好只放那些“只读且不变”的资源比如配置文件、只读的API客户端。凡是会被测试修改状态的资源优先级是function是最安全的。3.3 conftest.py让fixture在多个文件中共享写测试的时候你一定遇到过这种场景多个测试文件都需要用到同一个fixture总不能每个文件都复制粘贴一遍定义代码吧pytest提供了一个非常干净的解决方案conftest.py文件。conftest.py可以被放在测试目录的任意一层它里边定义的fixture会自动对当前目录以及所有子目录下的测试文件生效。比如目录结构是这样的demo_project/ ├── conftest.py ├── test_user.py └── test_order.py在conftest.py里定义一个fixtureimport pytest pytest.fixture def api_client(): client APIClient(base_urlhttp://test-api.example.com) client.login(tester, 123456) yield client client.logout()test_user.py和test_order.py都可以直接引用api_client这个fixture不需要任何import。这个机制用起来非常舒服它把测试基础设施和具体业务测试用例天然分开了你可以在conftest.py里维护全局的测试数据、环境配置、公共钩子业务测试文件只关注测试逻辑本身。我还有一个小技巧conftest.py是可以嵌套的如果你在某个子目录里放一个conftest.py那么它只对该子目录下的测试文件生效。这种“就近覆盖”的特性很适合在大型项目里做环境隔离比如A模块用mock数据、B模块用真实服务互不影响。3.4 内置fixture与常用内置函数除了自己定义的fixturepytest还内置了一些非常实用的fixture和函数。tmp_path就是一个我高频使用的内置fixture它会在测试执行时自动创建一个临时目录测试结束后自动清理适合用于文件读写相关的测试def test_save_file(tmp_path): file_path tmp_path / data.txt file_path.write_text(hello pytest) assert file_path.exists()理论上你可以自己创建临时目录、用完再删除但用tmp_path不但更安全还能省掉清理代码。另一个经典的capsys可以捕获测试中的标准输出用来验证函数打印的内容是否符合预期。再来说说pytest.raises它用于断言“某个异常是否被抛出”import pytest def divide(a, b): if b 0: raise ValueError(除数不能为0) return a / b def test_divide_by_zero(): with pytest.raises(ValueError, match除数不能为0): divide(10, 0)这里match参数可以按正则或字符串匹配异常信息不用去捕获异常后再自己断言代码简洁多了。4. 参数化数据驱动测试的核心玩法4.1 parametrize的基本用法与适用场景在测试工作中最烦人的不是写测试而是“同一个逻辑要测很多组数据”。比如你测一个登陆函数需要验证各种输入组合正确账号密码、错误密码、空账号、空密码、不存在的用户……如果每个场景都写一个独立测试函数代码会膨胀到没法看。pytest的pytest.mark.parametrize装饰器就是专门解决这个问题的。它在同一个测试函数上挂多组输入参数和期望值pytest会把每组参数当成一条独立用例来执行。import pytest from calculator import add pytest.mark.parametrize(a,b,expected, [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, 200, 300), ]) def test_add_params(a, b, expected): assert add(a, b) expected运行之后你会发现pytest收集了4条用例失败的时候也能精确告诉你“哪一组参数”没通过排查效率比写一个循环里做多次断言高得多。这里有一个很重要的经验不要把多组数据放在一个测试函数里用for循环断言因为一旦中间某一组失败后面的组就直接不执行了而parametrize会把每一组拆成独立的用例记录哪个失败一目了然。4.2 参数组合与间接参数化当你有多个参数需要做笛卡尔积组合时parametrize可以叠加使用。比如某个接口测试需要同时验证不同的请求方法和不同的认证方式pytest.mark.parametrize(method, [GET, POST]) pytest.mark.parametrize(auth_type, [token, basic]) def test_api_request(method, auth_type): # 模拟发起接口请求 pass这样pytest会生成4条用例组合覆盖所有情况。要注意参数的顺序会影响用例的执行顺序和ID展示但不会影响功能逻辑你可以按可读性习惯来调整。另外还有一个“间接参数化”的进阶用法适合fixture需要根据参数动态变化的情况。我在实际项目中用过一次场景是测试不同配置下的数据库行为代码如下pytest.fixture def config(request): return {env: request.param} pytest.mark.parametrize(config, [dev, prod], indirectTrue) def test_config_loading(config): assert config[env] in {dev, prod}indirectTrue的作用是告诉pytest参数名对应的不是测试函数参数而是同名fixture。这个虽然看起来有点绕但如果你需要“根据测试参数动态生成fixture数据”它就是正解建议记下来。4.3 数据文件驱动把测试数据从代码中剥离当测试数据越来越多硬编码在装饰器里就会变得难以维护。我的做法是把测试数据放到外部文件里常用的格式是JSON或YAML然后用参数化的方式读取文件数据。import json import pytest def load_test_data(): with open(test_data.json, r, encodingutf-8) as f: return json.load(f) pytest.mark.parametrize(case, load_test_data()) def test_from_file(case): input_data case[input] expected case[expected] assert process(input_data) expected这样做的好处不言而喻测试数据和测试代码分离测试用例的增删改不需要动代码文件产品经理或者测试同事可以直接维护数据文件。这在接口自动化测试里尤其常用一组接口用例就是一条json记录维护成本极低。我在做几十个接口的自动化case时就是靠这种方式把上千条用例数据从代码里解放出来的。5. 插件生态让pytest从能用变成好用5.1 测试报告pytest-html生成可视化结果大部分开源工具都具备把测试信息打印到终端的能力但真要给团队看测试结果终端输出肯定是不够的。pytest-html是一个官方生态里的插件安装之后一行命令就能生成一个完整的HTML测试报告。pip install pytest-html pytest --htmlreport.html生成的report.html文件包含用例总数、通过数、失败数、耗时、日志输出以及失败用例的详细堆栈信息可以直接给同事或者领导看也可以挂到CI系统里作为构建产物。如果希望报告中只显示失败用例可以这样操作pytest --htmlreport.html --self-contained-html--self-contained-html参数会把CSS和JS内联到HTML中这样报告文件可以单独用浏览器打开不会因为找不到静态资源而显示得很难看。5.2 并发提速pytest-xdist让测试跑得更快测试用例多了以后最直观的问题就是执行时间变长。所有用例串行执行一个小时起步慢得让人怀疑人生。pytest-xdist提供了并行执行能力安装之后可以用-n参数指定并行进程数。pip install pytest-xdist pytest -n 4-n 4表示用4个进程并行跑测试如果你的机器是8核CPU甚至可以设成-n auto让pytest根据CPU核心数自动决定并发数。实际项目中我见过一个原本跑35分钟的回归测试套件在加了-n auto之后直接压到了8分钟效率提升非常明显。不过这里要记住一个原则不是所有测试都能无脑并行。如果多个用例共享同一个数据库或者同一个文件资源并行起来很容易互相干扰出现“偶发性失败”。所以启用pytest-xdist之前先检查你测试里的共享资源是否做了隔离。5.3 覆盖率统计pytest-cov告诉你测到什么程度很多人写测试写了跑一遍全绿就觉得万事大吉但其实完全没有概念自己到底测了多少代码。pytest-cov可以统计测试覆盖率告诉你哪些代码被执行过哪些代码是“无人问津”的孤岛。pip install pytest-cov pytest --covcalculator --cov-reportterm-missing这里的--covcalculator表示统计calculator模块的覆盖率--cov-reportterm-missing会在终端显示覆盖率百分比以及没被执行到的行号。覆盖率指标并不是越高越好但它是衡量测试质量的一个重要参考。我个人一般在核心模块压到90%以上就算合格工具类、异常处理等分支可以根据实际重要性再调整。5.4 运行控制跳过用例、标记预期失败与排序执行测试用例并不是每个阶段都能跑。比如有个用例依赖的第三方接口正在维护跑肯定失败但你又不想把它删掉。这时候可以用pytest.mark.skip跳过或者用pytest.mark.skipif按条件跳过import sys import pytest pytest.mark.skipif(sys.version_info (3, 8), reason需要Python 3.8) def test_new_feature(): pass pytest.mark.skip(reason第三方接口维护中临时跳过) def test_third_party(): pass如果你的项目希望控制用例的执行顺序可以用pytest-ordering插件。虽然我不建议把测试写得太依赖顺序但有些场景下顺序是必须的比如先登录才能调下单接口。这个插件提供了简单的装饰器pytest.mark.run(order1) def test_login(): pass pytest.mark.run(order2) def test_create_order(): pass6. 常见问题与排查技巧实录6.1 测试收集不到用例新手最容易遇到的现象是运行pytest后显示“no tests ran”或者“collected 0 items”。排查方向很简单第一检查文件名是不是以test_开头或者以_test.py结尾第二检查测试函数名是不是以test_开头第三确认你执行pytest时所在的目录是否包含了测试文件。如果明确指定执行某个文件可以写成pytest test_calculator.py缩小范围定位问题。还有一个隐蔽的坑如果测试文件里import了一个不存在的模块pytest收集用例时会直接报错但报错信息有时候不太直观你只看到一串traceback。我的排查习惯是在项目根目录用python -c from 你的模块 import 测试模块手动试一遍导入先确认模块导入没问题再跑pytest能少走很多弯路。6.2 fixture未定义或名字冲突使用fixture时如果报fixture xxx not found先检查两件事fixture是不是定义在前置文件中、作用域是否覆盖到了当前测试文件。另外fixture和测试函数参数名必须完全一致参数名拼写差一个字母都会匹配不上。还有一种情况是整个项目里有多个conftest.pyfixture名字重复了。这时候pytest会用就近原则优先使用当前测试文件所在目录的conftest.py里定义的fixture有时候你觉得用的是A环境的fixture实际上用的是B环境的排查这种问题最直接的手段就是给fixture打log或者给fixture加一个唯一标记参数。6.3 断言失败时如何快速定位数据问题前面提到过断言失败的核心信息往往在最后几行但真实项目的测试输出通常很长前后还有一堆日志。我的建议是给pytest加-l参数也就是--showlocals它会在断言失败时显示测试函数里的所有局部变量值。这样一来你不仅能知道“等于3不等于4”还能看到输入参数、中间计算结果、甚至循环变量当前值定位问题快到飞起。pytest -l如果报错信息还是太长可以用--tbline把traceback压缩成一行适合只想先看个大概的情况。6.4 用例重试与稳定性处理接口自动化测试中最头疼的就是“偶发失败”。明明代码没问题但网络抖动一下一个超时就失败了然后整个回归报告红得刺眼。我的解决方案是引入pytest-rerunfailures插件给容易受环境影响用例设置重试次数pip install pytest-rerunfailures pytest --reruns 2 --reruns-delay 1--reruns 2表示失败后最多重试2次--reruns-delay 1表示每次重试间隔1秒。这个机制能让偶发的网络抖动被自动消化不会因为一次瞬时错误中断整条CI流水线。但记住重试是“治标”如果某些用例频繁重试仍然失败就要从根源去排查了不能靠重试掩盖代码bug。6.5 测试数据清理的最佳实践最后再聊聊测试数据的清理。测试执行后数据库里会残留一堆脏数据如果是开发本地还好到了测试服务器脏数据积累多了会影响后续用例的执行结果。我的做法是在fixture的yield之后做清理并且清理逻辑尽量和测试解耦写成独立的工具函数方便复用。pytest.fixture def clean_user(db_connection): user_id create_test_user(db_connection) yield user_id delete_test_user(db_connection, user_id)另外有个细节要注意如果你用session级别的fixture清理动作千万要谨慎。session级别的清理只在所有测试结束时执行一次如果中间某个用例报错退出了清理代码可能压根跑不到残留数据就会留在那里。针对这种情况可以考虑用pytest的--basetemp参数把所有临时文件统一放到一个目录测试结束时整目录清理这也是我长期用的兜底方案。写到这里pytest的核心体系其实已经搭建完整了。从我这些年的实战体验来看pytest最好的地方在于它不会强迫你用某一种模式而是给你一堆好用的螺丝刀和扳手让你按场景自由组合。我个人的建议是不要想着一步到位掌握所有插件先把assert断言、fixture和参数化这三个基本功练熟日常测试基本就能胜任了等遇到性能瓶颈和报告需求时再逐步引入插件生态。最后再分享一个小习惯我会在新项目的依赖文件里把pytest、pytest-xdist、pytest-cov、pytest-html这几个常用插件直接放进去省的每次新建项目都要重新装一遍。测试框架是项目质量的底线值得你在这上面多花点心思。
返回列表