多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Pytest实战指南:从fixture到参数化,高效编写Python自动化测试

Pytest实战指南:从fixture到参数化,高效编写Python自动化测试 1. 为什么说Pytest让测试从写作业变成写代码1.1 unittest时代最让人头疼的三件事我先坦白一个事早年我在团队里推自动化测试的时候最怕的不是被测系统的代码难写而是测试代码本身写得让人想摔键盘。那时候主力框架是unittest写一个TestCase要继承基类、重写setUp、tearDown、方法名必须以test开头、断言还得记住assertEqual还是assertTrue还是assertIn——一天下来大脑缓存全被这些API占满了。更要命的是这些测试代码大多从业务代码里复制-改改-粘贴出来十个测试类有八个长得差不多看着就烦。后来我认真反思过这个问题到底是测试这件事本身让人痛苦还是框架的设计放大了这种痛苦答案是后者。unittest出身于Java世界的JUnit思想讲究的是测试类、测试套件这种略显沉重的体系。对于Python这种强调简洁的语言来说它确实显得有点水土不服。你写业务代码时用函数、用列表推导式、用with语句管理资源怎么一到测试代码就要退回类继承的老路上去1.2 Pytest的三种核心设计取向函数即用例、表达式即断言、声明式资源2023年我第一次正经用Pytest写了一个接口测试项目一百多个用例写下来最大的感受是测试代码终于和普通代码一样自由了。它不是给你套一堆条条框框而是顺着Python本身的语法习惯来设计。核心就三点测试用例就是普通函数不需要继承任何类函数名以test_开头即可被识别断言直接用Python原生的assert表达式断言失败时Pytest会帮你做diff展示告诉你左值和右值分别是什么测试数据准备用fixture这种声明式的机制函数需要什么依赖直接写在参数里由框架注入这三点看起来简单但组合起来的威力是很大的。它意味着你不需要额外记忆一套测试专用语法你本来就会写Python自然就会写Pytest测试。团队新成员上手成本极低这一点在带新人时尤其珍贵。1.3 同一段测试代码的两个版本对比光说理论不直观我拿一个实际的例子来对比。假设这里有一个购物车折扣计算模块代码里有这么个函数# cart.py def calculate_discount(member_level, amount): 根据会员等级和消费金额计算折扣后应付金额。 if member_level guest: return amount if member_level silver: return round(amount * 0.95, 2) if member_level gold: if amount 100: return round(amount * 0.85 - 10, 2) return round(amount * 0.85, 2) if member_level diamond: return round(amount * 0.7, 2) raise ValueError(funknown member_level: {member_level})用unittest写测试的话得这样import unittest from cart import calculate_discount class TestDiscount(unittest.TestCase): def setUp(self): self.test_cases [...] def test_guest(self): self.assertEqual(calculate_discount(guest, 50), 50) def test_gold_with_threshold(self): self.assertEqual(calculate_discount(gold, 200), 160.0)而Pytest的版本是这样from cart import calculate_discount def test_guest_no_discount(): assert calculate_discount(guest, 50) 50 def test_gold_discount_above_threshold(): assert calculate_discount(gold, 200) 160.0区别很直观少了一个类、少了一堆方法调用的括号、断言也变成了一句人话。这还只是最基础的对比等到fixture和参数化登场两种框架在优雅维度上的差距会拉得更大。2. 从零到一30分钟跑通你的第一个Pytest项目2.1 安装与最小用例不论你是用virtualenv、pipenv还是直接用系统Python安装Pytest都只需要一条命令pip install pytest装完验证一下版本pytest --version能打印出版本号就说明环境OK了。接着在你喜欢的目录下建一个文件比如叫test_cart.py随便写一个测试def test_something_simple(): assert 1 1 2然后在终端执行pytest test_cart.py你会看到终端输出一片飘绿显示1 passed。到这里第一个用例就跑通了。整个过程不超过三分钟。2.2 测试文件的发现规则为什么不用做任何配置Pytest最让人舒服的一点是它几乎不需要配置。一个项目里只要满足以下命名的文件运行pytest时就会被自动收集文件名以test_开头比如test_login.py、test_order.py文件名以_test结尾比如login_test.py文件名匹配test*.py的也可以比如testcase.py文件内部只有满足以下条件的函数或类才会被当作测试用例执行模块级的函数名字以test_开头类名以Test开头注意大写T且类内部以test_开头的方法类不能有__init__方法这套规则是递归扫描的——你可以在任何层级的目录里放测试文件pytest会一路找下去。我曾经接手过一个老项目测试代码散落在各个业务模块的tests目录里最后靠pytest的递归收集机制一条命令全部跑完。如果是unittest的TestSuite手动装配光写装配代码就得累得半死。2.3 命令行参数的实用细节入门阶段你需要记住几个高频命令行参数日常调试效率会大大提升# 打印每个用例的详细执行结果推荐优先掌握 pytest -v # 只运行包含某个关键字的用例模糊匹配 pytest -k login # 执行时实时打印print输出默认是静默捕获的 pytest -s # 遇到第一个失败立即停止调试时节省时间 pytest -x # 指定失败3次后停止 pytest --maxfail3 # 只运行上次失败的用例注意首次运行需先生成记录 pytest --lf # 显示短测试摘要信息更紧凑 pytest -q我自己平时调试最喜欢用的是-k和-x的组合先-k把目标用例筛出来再配合-x遇到第一个失败就停不用等后面几十个用例跑完。等真正要提交代码进CI才用全量跑。2.4 断言失败时输出为什么这么友好这是Pytest让我真正回不去unittest的地方。普通assert失败时Python只告诉你AssertionError但Pytest会做断言内省把参与比较的左右值都打出来。比如def test_amount(): total 99 assert total 100运行后你会看到E assert 99 100如果两侧是可迭代对象或数据结构Pytest还会给出更细的差异。比如列表比较def test_cart_items(): items [apple, banana, pear] assert items [apple, banana, orange]输出E assert [apple, banana, pear] [apple, banana, orange] E At index 2 diff: pear ! orange这种精确定位到索引级别的报错省去了我自己写循环打log的时间。更重要的是团队成员看到这类报错时不用再猜哪里没对上直接就能改。3. fixture把测试数据准备从复制粘贴变成声明式3.1 fixture到底解决了什么问题早期用unittest写测试的时候最烦的事情就是测试数据准备和清理。每个测试类都要setUpsetUp里准备数据tearDown里清数据。如果十个测试类都需要同一个已登录用户那就得在十个setUp里各写一遍构造逻辑。改一个字段十个地方跟着改改漏一个就是一堆幽灵失败。Pytest的fixture改变了这个局面。它的核心思想是测试函数声明自己需要什么依赖框架负责在运行前把依赖准备好用完后根据声明自动清理。比如import pytest pytest.fixture def member_data(): 构造一个会员数据字典供多个测试复用。 return { user_id: 1024, level: gold, points: 5000, joined_days: 365, } def test_calculate_points(member_data): # 测试函数直接使用member_data无需自己构造 assert member_data[joined_days] 365这里的重点是声明式三个字测试函数不需要知道member_data是怎么来的、从哪来的只要在函数签名里写上这个名字Pytest就会自动去调用那个同名fixture函数并把返回值传给测试。这其实就是依赖注入思想在测试框架里的体现。3.2 yieldsetup与teardown的合体fixture更大的价值在于管理那些用完需要释放的资源——文件句柄、数据库连接、临时目录、HTTP服务等。传统方式是setUp里创建、tearDown里关闭两处代码相隔甚远中间隔着整个测试类。fixture用yield语法把准备和清理写在一起import pytest pytest.fixture def temp_db(): conn create_test_db() # 前置步骤创建连接 yield conn # 将连接交给测试函数使用 conn.close() # 后置步骤测试结束后自动关闭yield之前的代码相当于setupyield之后的代码相当于teardown。无论测试函数抛出什么异常yield后面的清理代码都会执行。这个设计把必须成对出现的逻辑收拢在一个函数里可读性和健壮性都强了很多。3.3 scope作用域怎么选function/class/module/sessionfixture有个scope参数控制它的生命周期。默认是function也就是每个测试函数调用一次fixturepytest.fixture(scopemodule) def shared_config(): 整个测试模块只创建一次。 return load_config_from_file()四种作用域的使用场景我列个表方便你对照scope生命周期适用场景注意点function每个测试函数一次通用数据准备互不影响默认值最安全class每个测试类一次类内用例共享重量级对象注意用例间数据隔离module每个测试文件一次数据库连接、耗时配置文件内用例共享一份session整个测试会话一次全局唯一的浏览器、全局环境谨慎使用需确保可复用从实际经验来说新手最容易犯的错是在module或session级别的fixture里放了会被测试修改的可变对象。比如一个session级别的list第一个测试往里面append了数据第二个测试拿到的就是污染后的数据。解决方法是只有那些只读或每次重新构造的依赖才适合高作用域一旦测试会修改数据就该用function级别的fixture或者fixture内部返回新建对象。3.4 conftest.py跨文件的共享fixture既然fixture这么好用那写在哪个文件里Pytest的答案是conftest.py——一个特殊文件Pytest会自动加载它不需要你手动import。fixture放在conftest.py里同目录及子目录下的所有测试文件都能直接用。比如项目结构是这样的tests/ ├── conftest.py # 放共享fixture ├── test_cart.py └── test_order.pyconftest.py里定义了member_datatest_cart.py和test_order.py都能用。目录层级原则也很简单conftest.py只对它所在的目录及其子目录的测试生效。所以你可以做分层设计——顶层conftest放整个项目共享的全局资源比如数据库连接、日志配置子目录下放该模块专属的fixture。我在实际项目中见过不少人把所有fixture都堆在一个顶层conftest里结果几百行找东西全靠滚动。正确姿势是能用小的不用大的能放在测试文件里就不放conftest只有多个文件共享时才往上提。3.5 一个完整的购物车fixture实战回到购物车例子我用fixture把所有用例需要的数据和服务都准备好import pytest from cart import calculate_discount pytest.fixture def guest_cart(): 访客购物车无会员折扣。 return {items: [{name: t-shirt, price: 79.9}], member_level: guest} pytest.fixture def gold_cart(): 黄金会员购物车满100减10。 return {items: [{name: t-shirt, price: 79.9}, {name: jeans, price: 199.0}], member_level: gold} def test_guest_pays_full_price(guest_cart): total sum(item[price] for item in guest_cart[items]) assert total 79.9 assert calculate_discount(guest, total) total def test_gold_gets_threshold_discount(gold_cart): total sum(item[price] for item in gold_cart[items]) assert total 278.9 # 258.9 278.9 * 0.85 - 10 assert calculate_discount(gold, total) 227.07这两个测试的代码量很少因为它们把构造购物车数据的活交给了fixture。等后续测试越来越多你只需要在fixture里改数据所有依赖的测试自动拿到新的数据维护成本大幅下降。4. 参数化与标记用更少的代码覆盖更多的场景4.1 parametrize的基本用法业务测试最怕的就是穷举场景。折扣计算函数可能有几十种边界组合不同会员等级、不同金额档位、金额恰好等于100、金额小于100、金额极大、金额为0、负数金额……如果用unittest大部分人会在一个测试方法里用for循环遍历用例断言失败后分不清是哪一条数据出的问题。Pytest的pytest.mark.parametrize是专门解决这个问题的import pytest from cart import calculate_discount pytest.mark.parametrize(member_level,amount,expected, [ (guest, 50, 50), (guest, 0, 0), (silver, 200, 190.0), (gold, 99, 84.15), (gold, 100, 75.0), (gold, 200, 160.0), (diamond, 300, 210.0), (diamond, 0, 0), ]) def test_discount_combinations(member_level, amount, expected): assert calculate_discount(member_level, amount) expected运行后Pytest会把8组参数展开成8个独立的用例各自有独立的执行记录、失败信息和测试ID。如果第一组和第三组失败只有这两条显示F其他6条照常通过——你一眼就能定位是哪组数据触发了bug。这在unittest时代需要写8个方法或者靠断言包装技巧才能实现。4.2 参数化与fixture的组合参数和fixture不是互相替代的关系而是可以结合使用。场景是测试需要一些复杂对象作为输入而这些对象本身又依赖fixture。这时可以用indirectTrue让parametrize中的参数值透传给fixtureimport pytest pytest.fixture def cart_builder(request): level request.param return create_cart(levellevel, items_count3) pytest.mark.parametrize(cart_builder, [guest, silver, gold], indirectTrue) def test_cart_total(cart_builder): assert cart_builder[items_count] 3不过说实话indirectTrue在日常项目中用到的频率不算特别高。我更常见的做法是在fixture内部调用参数化数据来构造对象。如果你刚入门先掌握普通的parametrize就足够应付80%的场景了。4.3 skip与xfail的正确使用姿势项目里有两种暂时不通过的情况用对比表格说明标记适用场景表现pytest.mark.skip功能未实现、环境不满足、依赖缺失用例不执行显示为Spytest.mark.skipif(condition, reason)特定平台/版本下跳过条件成立时跳过pytest.mark.xfail已知bug、期待失败的场景预期失败显示为x若意外通过则显示XPASS举个例子import sys import pytest pytest.mark.skipif(sys.version_info (3, 10), reason需要Python 3.10) def test_new_syntax(): ... pytest.mark.xfail(reason已知边界问题待修复后移除) def test_edge_case(): assert calculate_discount(gold, -10) ...这里的核心原则是skip和xfail必须写清楚reason。团队协作时没有reason的skip标记基本等于技术债——后人不知道怎么处理也不敢动它。我通常会在skip的reason里写上解除条件比如待xx环境就绪后移除或待bug #12345修复后改为正常断言。4.4 自定义标记搭建自己的冒烟/回归分层每个项目都应该有自己的测试分层。最朴素的分层就两层冒烟测试和全量回归。Pytest支持自定义标记配合-m参数就能实现。首先在项目根目录建一个pytest.ini文件声明自定义标记[pytest] markers smoke: 冒烟测试核心主流程 slow: 耗时长默认不执行然后在用例上打标import pytest pytest.mark.smoke def test_login_success(): ... pytest.mark.slow def test_full_report_generation(): ...执行时精确筛选# 只跑冒烟测试 pytest -m smoke # 排除慢速测试 pytest -m not slow # 跑冒烟和回归中标记为critical的 pytest -m smoke or critical这套机制在CI里的价值很大。比如每次代码合并后只跑-m smoke5分钟内给反馈每天凌晨的全量回归才跑所有用例。团队不用等一个小时才知道提交是否破坏了核心流程。5. 项目级落地目录结构、插件与执行优化5.1 一个推荐的测试目录结构入门级的Pytest可以随便放文件但企业项目必须有一个清晰的布局。我经过大小项目验证推荐这种结构project/ ├── src/ │ ├── cart.py │ └── auth.py ├── tests/ │ ├── conftest.py │ ├── fixtures/ # 放业务相关的fixture定义 │ │ ├── __init__.py │ │ ├── users.py │ │ └── carts.py │ ├── test_cart.py │ ├── test_auth.py │ └── data/ │ ├── login_cases.json # 测试数据文件 │ └── cart_cases.yml ├── pytest.ini └── requirements-dev.txt几个关键点测试目录tests和业务源码src分离conftest.py只在有共享需求时才出现不是每个目录都必须有测试数据尽量放独立的数据文件例如JSON或YAML方便非开发人员维护pytest.ini放在项目根目录负责全局配置这里有个容易踩的坑为了共享目录结构而硬造conftest。我见过有人给每个子目录都放一个空conftest说是规范实际上除了增加文件数量没有任何作用。conftest只应该在确实有共享内容时出现。5.2 必装插件清单与用途Pytest的生态是它最大的护城河之一。以下是经过大量项目验证、出场率最高的四个插件插件作用安装命令pytest-html生成HTML格式测试报告适合向团队和管理层展示pip install pytest-htmlpytest-xdist多进程并行执行用例大型项目提速利器pip install pytest-xdistpytest-rerunfailures失败用例自动重试适用于网络波动等不稳定场景pip install pytest-rerunfailurespytest-cov集成coverage.py输出代码覆盖率pip install pytest-covpytest-ordering控制用例执行顺序虽然官方不推荐依赖顺序pip install pytest-ordering# 组合使用示例8进程并行 失败测试重试2次 生成HTML报告 pytest -n 8 --reruns 2 --htmlreport.html --self-contained-html有一点提醒pytest-xdist并行执行时fixture若没有加scopesession每个worker都会独立创建资源。如果你的测试依赖一个共享的数据库状态并行跑会互相干扰。这种场景要么给fixture加session作用域要么就老老实实单进程跑。5.3 执行效率优化并行、失败重试、断点续跑实际项目中测试用例数量上千之后执行时间会变得不可接受。我的优化套路是并行执行pytest -n auto让pytest自动判断CPU核心数或者-n 4指定4个进程失败重试--reruns 2 --reruns-delay 1重试间隔1秒避免瞬时故障失败即停调试阶段用--maxfail1快速定位第一个问题断点续跑--lf只跑上次失败的用例开发时反馈极快这几招配合起来一个原本20分钟的测试套件可以被压缩到5分钟以内。但不要盲目追求并行——如果测试真的很依赖共享数据并行反而会导致乱序和偶发失败得不偿失。我的判断标准是先保证单进程全绿再开并行。5.4 让Pytest适配CI退出码与输出风格把测试接入CI/CD流水线时退出码是最重要的信号。Pytest的退出码含义如下0全部用例通过1有用例失败2测试执行中断比如收集错误、参数错误3内部错误4--maxfail触发5没有收集到任何用例CI脚本里通常只需要判断退出码是否为0。这里有一个很多人不知道的技巧如果你的CI用了pytest-xdist退出码是聚合所有worker结果的任何一个worker失败整体退出码就是1。另外为了让CI日志更简洁建议在pytest.ini里设置addopts -q --tbshort让非失败用例的输出尽可能短。6. 完整实战从需求到报告的登录接口测试6.1 需求拆解与用例设计前面讲的都是购物车这类纯函数现在来一个更贴近真实业务场景的登录接口的自动化测试。假设有一个标准的RESTful登录接口需求如下请求方式POST /api/login请求体{username: xxx, password: xxx}成功响应200携带token失败场景用户名或密码错误 → 401用户名为空 → 400密码为空 → 400账号已被锁定 → 403做用例设计时我会把每个场景拆成一条测试数据整理成表格用例编号usernamepassword预期结果TC001valid_uservalid_pass200返回tokenTC002valid_userwrong_pass401TC003wrong_uservalid_pass401TC004valid_pass400TC005valid_user400TC006locked_uservalid_pass403这6条用例覆盖了登录的核心路径和主要异常分支。虽然业务上还有更多情况比如密码连续错误5次触发锁定、token过期刷新等但那些可以放在下一轮的边界测试里。我踩过的坑是第一次写接口测试时用例设计得太粗糙只写了成功和密码错误两条导致上线后空用户名这种低级问题溜进了生产环境。所以设计用例千万别嫌多边界条件一定要覆盖。6.2 用fixture管理接口会话在fixture里封装接口客户端好处是所有用例都能复用同一个会话配置和基础URLimport pytest import requests pytest.fixture def api_client(): 构造一个指向测试环境的HTTP客户端。 session requests.Session() session.base_url http://test-api.internal.example.com session.headers.update({Content-Type: application/json}) yield session session.close() pytest.fixture def login_payload(): 默认的登录请求体。 return {username: test_user, password: Test123456}这里把api_client设计成function级别的fixture是刻意的——每个测试用独立的会话避免请求之间共享cookie或连接状态导致互相污染。如果接口量很大可以优化为session级别但那需要考虑线程安全和数据隔离问题。6.3 参数化覆盖全部登录场景用parametrize把设计好的6条用例直接映射为代码import pytest pytest.mark.parametrize(username,password,expected_status,has_token, [ (test_user, Test123456, 200, True), (test_user, wrong_password, 401, False), (nonexistent_user, Test123456, 401, False), (, Test123456, 400, False), (test_user, , 400, False), (locked_user, Test123456, 403, False), ]) def test_login(api_client, username, password, expected_status, has_token): resp api_client.post(/api/login, json{username: username, password: password}) assert resp.status_code expected_status json_data resp.json() if has_token: assert token in json_data and json_data[token] else: assert token not in json_data这段代码只有不到10行却覆盖了6个完整场景。parametrize展开后每一条失败数据都能独立定位测试报告里可以直接看到哪组输入、返回了什么状态码、断言哪里不匹配。6.4 生成HTML报告并适配CI最后把报告输出配置好。我通常会在pytest.ini里做如下设置[pytest] testpaths tests markers smoke: 冒烟测试核心主流程 slow: 耗时长默认不执行 addopts -q --tbshort执行命令pytest --htmlreports/login_report.html --self-contained-html--self-contained-html参数很关键它会把CSS和JS全部内嵌到单个HTML文件里这样发邮件或挂到CI产物时对方直接双击就能看到完整样式不用额外加载静态资源。从实际维护经验来说HTML报告不只是给测试人员看的更是给开发看的。每次失败后他们最关心的是哪个接口、什么请求体、什么响应、和预期差在哪。pytest-html的默认输出里这些信息都有配合--tbshort保留20行以内的堆栈定位效率比翻Raw日志高很多。登录接口这套模式是我在各项目中复用率最高的模板。换成注册、下单、查询结构完全一样fixture管理客户端、parametrize组织场景、断言验证状态码和关键字段。掌握了这个套路再复杂的接口测试也只是场景梳理的工程量问题。
返回列表