pytest自动化测试实战:从unittest迁移到fixture参数化与断言重写

发布时间:2026/10/6 5:15:23
pytest自动化测试实战:从unittest迁移到fixture参数化与断言重写
如果你的工作里已经写了半年以上的自动化测试大概率经历过这样几个瞬间为了执行一个接口用例先写一个类再写一堆setUpClass和tearDownClass想跑某个模块的冒烟结果命令写了一大串断言一个字段还得去assertEqual和assertIn之间来回翻文档。等我把项目切到 pytest 之后这些场景基本全部消失了。pytest 是目前自动化测试领域里最主流的测试框架无论是接口自动化、函数单元测试还是基于 appium 的 UI 自动化都可以用同一套编写习惯去组织用例它解决的问题很直接把写用例的时间从处理框架规则转移到设计测试逻辑上。这篇文章面向两类人刚入门自动化测试、想系统掌握 pytest 的新手以及从 unittest 迁移过来、想提升用例编写效率的测试开发我会把我实际项目中用到的写法、踩过的坑和选型理由一起讲清楚方便你直接参考和复现。1. 从 unittest 迁到 pytest那些被旧框架消耗掉的时间先说清楚一个前提我并不是主张 unittest 完全不可用。在小规模、单文件、快速验证的脚本里unittest 完全够用。但当用例数量超过两三百条、需要分级执行、数据驱动、环境切换的时候unittest 的代码组织方式会明显拖慢你的节奏不信你回忆一下最常见的四个场景。第一个场景是 setup/teardown 的粒度割裂。unittest 提供了setUpClass、tearDownClass、setUp、tearDown、setUpModule等一堆钩子但它的作用域要么是类、要么是模块、要么是每个方法当你想要一个整个测试会话只登录一次但每个用例都复用该 token的东西用 unittest 写会很别你只能把 token 存储在类变量里再通过模块级别的变量传递。pytest 的做法是引入 fixture用scopesession声明生命周期直接消灭了这种割裂感。第二个场景是断言代码太长。unittest 的断言需要调用各种assertEqual、assertTrue、assertIn、assertIsNone说实话绝大多数用例真正需要的断言语义其实就是这个值和预期一致或者这两个字符串有没有包含关系绕这么多方法纯粹是框架的历史包袱。pytest 里你只需要写一句assert res[code] 0剩下的事框架帮你处理失败时自动告诉你res[code]实际等于多少不需要你手动拼接错误消息。第三个场景是测试发现机制。unittest 要靠unittest.TestLoader和手动addTest来组装 suite虽然可以通过discover()批量加载但如果你要精确控制某几个文件、某几个类跑一遍命令行参数写起来相当笨重。pytest 直接用一组文件路径和-k、-m表达式就能完成过滤比如想跑所有冒烟测试一个-m smoke就够了不需要维护任何 suite 文件。第四个场景是插件生态。现代自动化测试越来越依赖报告、重试、并发这些能力unittest 里要做重试得非常费劲allure 集成也只是能用但不够顺手。pytest 社区把这些能力做成了插件pytest-rerunfailures负责失败用例重跑pytest-xdist可以并行执行allure-pytest直接生成带步骤、带附件的报告这些插件的设计都和 pytest 的运行模型深度绑定你用起来会觉得它本来就应该长这样。我实际经历过一个项目最开始接口自动化用 unittest每个模块文件里都有三四个 setup测试人数一多大家各写各的有人用类变量存 token有人用模块变量风格非常混乱。后来整体迁到 pytest第一件事就是统一 fixture 约定代码行数减少了大半新成员上手速度也明显加快。所以我才说pytest 的核心价值不是新框架更酷而是它把测试过程里最消耗精力的模板代码给压缩掉了。2. 先吃透用例发现与执行流程不然你的 suite 永远缺用例2.1 默认匹配规则究竟有多严格很多人第一次用 pytest 就觉得这框架真奇怪我明明写了用例怎么跑的时候一个都不执行问题往往出在命名规则上。pytest 默认的文件发现规则是文件名必须以test_*.py或*_test.py开头或结尾测试函数以test_开头测试类以Test开头且类中的方法以test_开头。这三条规则任一条不满足用例就静默消失。我在实际项目中就吃过亏早期某个 API 封装文件叫api_client.py里面写了一批assert_*方法文件名不带 test 前缀pytest 自然不收集它。这个好理解。更容易踩坑的是类内部的方法如果你把测试类命名为TestCase正则里只认Test*后面直接跟非下划线字符或者测试方法用了check_*这类命名它一样不会被收集。解决办法要么是严格改名要么在pytest.ini里重写python_classes和python_functions但我的建议是能用默认规则就用默认规则因为你在命令行里对别人解释用例如何组织时默认规则是最没有歧义的。实际项目里还常见一种情况接口测试文件放在tests/api/test_order.pyUI 测试放在tests/ui/test_login.py你直接执行pytest tests时pytest 会递归扫描所有子目录。如果你只想跑接口层命令是pytest tests/api。目录本身就是过滤条件不需要额外加参数。这一点很多新手不知道总习惯于写一大串文件路径列表。2.2 pytest.ini 才是你的全局控制面板当你开始接触配置文件时第一个要建的就是pytest.ini。它放在项目根目录pytest 启动时会自动读取不用额外声明。我最常在配置里做这几件事指定testpaths告诉 pytest 默认扫描哪个目录配置python_files、python_classes、python_functions把命名规则调成自己团队的习惯配置addopts把每次运行都要加的公共参数放进去。举个例子我现在的接口测试项目里有一个这样的pytest.ini[pytest] testpaths tests python_files test_*.py python_classes Test* python_functions test_* addopts -v -s这段配置的执行效果是只要我敲pytest它就直接进入tests目录扫描所有符合test_*.py的文件并且默认开启详细输出-v和显示打印信息-s。注意addopts里的参数是自带的如果你偶尔想关掉详细输出不能简单靠命令行覆盖最稳妥的办法是临时改配置文件或者用覆盖参数这也是一个让新人困惑的地方。配置还有一个好处你在任何子目录下执行pytest它都会先找到项目根的pytest.ini然后以根目录为出发点收集用例。这意味着你不需要每次用cd切到固定目录才能跑测试。团队协作时配置文件等于把项目的测试执行标准固定住了谁进来都能用同一条命令跑同一种规则。2.3 执行阶段内部到底发生了什么理解 pytest 的收集与执行模型对排查资源清理没执行fixture 顺序诡异这类问题非常重要。一次完整的 pytest 运行大体上分三个阶段收集阶段pytest 扫描文件、解析函数和类、构建测试节点树。这个阶段你会发现如果你在某些测试模块顶部写了非测试用途的可执行代码它也会在这一阶段被执行因为 Python 模块导入时本身就是从上到下执行的。这也是为什么公共工具类不要放在以test_开头的文件里除非你有意为之。执行阶段pytest 进入每个测试节点先实例化该节点依赖的 fixture按作用域从小到大依次触发然后执行用例主体。用例执行完毕或抛出异常后进入后续清理逻辑。报告阶段pytest 汇总所有节点的结果生成 passed、failed、skipped 等统计数据再交给插件加工比如发出 allure 报告数据。大多数新手对中间阶段的细节不敏感直到出现这样一种 bug某个 session 级的 fixture 负责启动一个临时服务某个测试模块的 module 级 fixture 负责往里写测试数据结果因为 module 级 fixture 执行失败了session 级 fixture 的清理代码没有按照预期执行导致临时服务一直没被关掉。如果你不理解 fixture 的依赖层级和执行顺序这个 bug 排查起来会非常头疼。所以不要跳过这一节后面我们详细讲 fixture 的生命周期时会反复使用这套执行模型。3. fixture 依赖注入让 setup/teardown 的重复代码真正消失3.1 fixture 是什么为什么它能替换 setupfixture 在概念上理解很简单它是一个带pytest.fixture装饰器的函数负责给测试用例提供数据、对象、连接或其他准备工作。但它的使用方式才是核心亮点——测试函数直接通过参数名引用 fixturepytest 会自动把 fixture 的返回值注入进去。import pytest pytest.fixture def user_token(): return token-abc-123 def test_get_user_info(user_token): assert user_token.startswith(token-)这里函数test_get_user_info的参数名写的是user_tokenpytest 看到这个名字就会在当前文件及父级目录的conftest.py里查找同名 fixture然后把这个 fixture 的返回值作为参数传进来。整段代码里没有任何setUp和tearDown的痕迹但准备工作已经完成了。这个机制在工程上叫依赖注入意思是你不需要在测试函数内部去主动调用初始化代码框架替你做了。对于从 unittest 迁过来的人来说最直观的感受就是之前 setUp 里的内容现在拆成一个个带名字的 fixturetearDown 里的清理动作则写在 fixture 的 yield 之后。每个测试函数声明自己需要哪些依赖而不是继承一个打包好的父类。3.2 作用域和生命周期function、class、module、sessionfixture 最迷人的地方在于它的scope参数它决定了 fixture 的生命周期。我按使用频率由高到低给你讲讲scopefunction默认值每个测试函数执行前都重新调用一次 fixture。适用于临时数据、每次都要独立的对象比如注册一个新用户、生成一个随机订单号。scopeclass同一个测试类中的所有方法共享一份 fixture 返回值。适用于一组用例只需要准备一次前置条件的场景。scopemodule同一个测试文件共享一份。常见于文件级数据库初始数据或者文件级别的登录 token。scopesession整个 pytest 进程只执行一次。这是我最常用的接口自动化里登录获取 token、连接数据库、启动一个 Mock 服务都可以放在 session 级 fixture 里因为重复执行纯属浪费。很多人会问我到底该用 module 还是 session。我的判断标准很简单看你的系统资源能不能忍受多次初始化。如果只是想省时间比如业务 token 的获取那就用 session如果初始化动作会污染数据比如每个模块都要独立的数据快照那就降到 module。不要盲目追求最高的复用粒度否则用例之间的数据隔离性会变差。3.3 return 和 yield 的差别以及 conftest 的正确位置fixture 函数里你可以用return返回一个值也可以改用yield。yield最大的意义在于它下方的代码会在用例执行完毕后自动运行这是天然的后置清理点。我用一个生活例子帮你理解return相当于你去餐厅点完餐拿了餐就走餐厅之后怎么收拾桌子和没有关系yield相当于你坐在座位上吃完服务员收拾完桌子才算流程结束。pytest.fixture def temp_dir(): import tempfile, os d tempfile.mkdtemp() yield d os.rmdir(d)当测试用例执行完毕无论成功还是失败yield下方都会执行这就是为什么你能用它完成创建临时目录 → 用例使用 → 删除目录这个完整闭环。conftest.py是 pytest 的一个特殊文件它最大的特点是不需要被 importpytest 会自动加载它。它通常放在项目根目录或某个测试子目录。放在根目录的conftest.py里的 fixture 对全项目可见放在tests/api/conftest.py里的 fixture 只对tests/api目录及其子目录下的用例可见。这个层级可见性非常有用你可以把全局的 token、数据库连接放在根级 conftest 里把只有接口测试特有的 fixture 放在 api 目录下。同一路径层级下如果重复定义了同名 fixturepytest 会报错不同层级下内层会覆盖外层。这是团队协作时最容易出现分歧的地方一定要在规范里写明。3.4 autouse 与依赖顺序最容易踩的两个坑autouseTrue这个参数的意思是不允许测试函数显式声明每个用例都会自动使用这个 fixture。它特别适合做环境检查、埋点统计这类与用例逻辑无关但又必须执行的动作。比如想要每个用例执行前打印开始标记你就可以写一个autouseTrue的 fixture直接接管整条执行链。不过 autouse 同样受作用域控制如果一个scopesession的 autouse fixture 在执行过程中挂掉了那么整个测试会话都会异常终止所以在 autouse 里尽量只放不依赖具体业务的轻量检查。依赖顺序这块有个常见问题fixture 可以接受其他 fixture 作为参数形成依赖链。假设一个登录 fixture 依赖一个数据库初始化 fixture那么 pytest 会先执行数据库初始化再执行登录。这个顺序是自动推导的不用手动安排。但要注意的是如果你同时声明了两个互不相关的 fixture它们之间的执行顺序往往取决于参数顺序或者函数定义顺序不要依赖这种偶然性做有副作用的操作。真正需要强顺序的场景应该显式建一个总控 fixture 来组织依赖而不是指望参数顺序。4. 参数化与数据驱动用一组用例跑几十组数据4.1 从第一个 parametrize 开始接口测试里最常见的需求是同一个接口几十组不同的入参全部跑一遍每一组都单独查看结果。如果你用 unittest 写要么写几十个几乎一样的测试函数要么用一个循环在单个测试方法里遍历数据但循环里一旦某一条断言失败后面的数据就不会继续跑了而且报告里看不出到底是哪组数据挂了。pytest 的pytest.mark.parametrize就是为了解决这个问题的。import pytest pytest.mark.parametrize(username,password,expected_code, [ (admin, 123456, 0), (admin, wrong, 10001), (, , 10002), (normal_user, 654321, 0), ]) def test_login(username, password, expected_code): resp login_api(username, password) assert resp[code] expected_code执行这条测试时pytest 会把它展开成 4 个独立的测试节点每个节点的名字都带着具体参数值。第一组数据失败了不影响后面三组的运行。你也能通过-k精准筛出某个参数的用例来跑比如pytest -k wrong就只跑密码错误那一组。4.2 多个 parametrize 叠加是笛卡尔积有一个细节很容易被忽略如果你在同一个测试函数上堆了两个parametrize装饰器两者的参数组会做笛卡尔积组合而不是按位置一一对应。pytest.mark.parametrize(env, [dev, test]) pytest.mark.parametrize(case_data, [case_a, case_b]) def test_api(env, case_data): pass这段代码最终会生成 4 条用例分别是 devcase_a、devcase_b、testcase_a、testcase_b。这在实际项目中非常有用比如一套接口用例跑多套环境只需要把环境列表单独放在一个 parametrize 里。但如果你不想要这种组合行为那就不要叠加把多组数据想办法压成一维列表后再一次性传参。4.3 ids 参数给测试节点起个能看懂的名字参数化默认生成的测试 ID 是一串参数值比如test_login[admin-123456-0]。看多了你会发现参数值里如果含特殊字符、中文、长字符串报告会变得难以阅读。这时候可以用ids参数给每组数据重新命名。pytest.mark.parametrize(username,password,expected_code, [ (admin, 123456, 0), (normal_user, wrong, 10001), ], ids[admin_login_success, normal_user_wrong_password]) def test_login(username, password, expected_code): ...加了 ids 之后测试节点名就变成了test_login[admin_login_success]在 allure 报告和命令行里都非常直观。我的习惯是 ids 里包含业务场景预期两个信息这样当某条用例失败时只看报告标题就能知道是哪个场景出了问题不需要再对照参数表。4.4 数据放在外部文件里框架才算是能用起来直接把测试数据写死在装饰器里适用于少量数据一旦接口用例超过几百组我更推荐把数据放到独立文件里。实际项目中我用得最多的是 YAML因为它的结构表达能力强能容纳层级比较深的嵌套参数。假设tests/data/login_cases.yaml长这样- name: admin login success username: admin password: 123456 expected: code: 0 msg: success - name: admin wrong password username: admin password: wrong expected: code: 10001 msg: wrong password然后在测试文件里读取数据并参数化import pytest import yaml with open(tests/data/login_cases.yaml, encodingutf-8) as f: cases yaml.safe_load(f) pytest.mark.parametrize(case, cases, idslambda case: case[name]) def test_login(case): resp login_api(case[username], case[password]) assert resp[code] case[expected][code] assert resp[msg] case[expected][msg]每个 case 字典包含完整的请求参数和预期结果测试函数只负责发起请求和做断言。这样做的好处是测试数据和代码逻辑彻底分开产品和接口同事修改数据时不需要触碰 Python 代码你的回归验证里也更清楚哪组数据出了问题。有一点要注意读取外部文件的代码只会执行一次用例收集阶段就会读取文件内容所以如果你的测试数据文件本身有语法错误用例甚至不会被收集到报错会出现在测试开始前的导入阶段。5. 断言不将就普通 assert 的细节感知能力5.1 为什么assert a b能打败 assertEqualpytest 的 assert 并不是 Python 原生那个简单的 assert。它会做一个叫断言重写的操作在收集测试文件时pytest 会在字节码层面对源代码里的 assert 做改造把带有比较逻辑的表达式拆解开来让失败时能够展示左右两侧的具体值。你定义一个函数assert_equals(a, b)然后调用它做断言拿到的反馈就只有一个AssertionError没有任何上下文但如果直接写assert a bpytest 会告诉你a等于多少、b等于多少甚至对字典、集合这种容器给出差异部分。这正是原生 assert 体验最好的地方你根本不需要封装一堆断言工具函数。我特别建议团队规范里写明不要自己封装什么assert_code、assert_status这类的辅助断言函数。原因不是它们不能工作而是断言上下文会丢失。举个例子你封装了一个assert_response_code(resp, 0)里面写assert resp.code expected这个断言失败时pytest 看到的断言语句在函数内部报告的失败信息只是这个布尔表达式失败而不会自动展示resp.code和expected的具体值。最后你只能手动在函数里拼接错误信息写出来的东西比直接断言还麻烦。保持 assert 语句写在测试函数里是让 pytest 断言重写机制发挥价值的最简单姿势。5.2 接口返回体、集合、浮点数用 pytest.approx 处理边界接口自动化里最常断言的两类内容是状态码和业务字段。状态码一般直接assert resp.status_code 200业务字段则是assert data[code] 0。但字符串包含关系会稍微绕一点如果想验证响应体里包含某段提示文案直接写assert 登录成功 in resp.text即可如果失败pytest 会展示两边字符串的位置信息方便定位差异。浮点数比较是很多人都会忽略的坑。0.1 0.2 0.3在 Python 里是 False所以如果你的接口返回价格、金额这类浮点字段别直接写assert total 99.99。正确姿势是用pytest.approxfrom pytest import approx assert total approx(99.99, rel1e-3)它既支持相对误差也支持绝对误差能用最直观的方式去比较近似值。如果你不处理这个细节浮点断言会时不时给你来一下莫名其妙的失败。5.3 pytest.raises 与异常消息校验try/except 在这里是多余的接口测试和单元测试里经常要验证某段代码必须抛异常。最常见的错误写法是用try/except包住调用再用一个标志变量记录有没有捕捉到异常最后断言标志这种写法把简单问题复杂化了。pytest 提供了pytest.raises可以同时完成必须抛异常和异常的额外信息校验两件事。import pytest def test_register_with_invalid_email(): with pytest.raises(ValueError) as exc_info: register_user(emailnot-an-email) assert 邮箱格式不正确 in str(exc_info.value)pytest.raises的as exc_info可以拿到异常实例然后你就能继续对它做更多的断言。如果代码没有抛出指定异常测试会直接失败报告里会明确说明预期是 ValueEror 但没有发生。这比手写 try/except 干净太多也符合断言重写机制下信息最丰富的原则。如果你要处理的是不允许抛异常的场景直接调函数即可不需要任何断言包裹——只要它有异常抛出测试自然就失败了。这个朴素规则能帮你省掉一堆assert something is None的无效断言。6. 让 pytest 变成一个能上生产的框架标记、重试、并发和 Allure 报告6.1 mark 标记与分级执行冒烟、回归、慢速用例各走各路当测试用例从几十条涨到几千条的时候你不可能每次提交代码都全量跑一遍。pytest 用pytest.mark来做用例标签。最典型的做法是把用例分成冒烟和回归import pytest pytest.mark.smoke def test_ping(): ... pytest.mark.regression def test_complex_flow(): ...执行时pytest -m smoke只跑冒烟用例pytest -m not slow会排除掉速度慢的用例。标记可以叠加比如一条用例既是冒烟又是核心就可以同时打两个标记。配合pytest.ini里的markers声明你还能在配置里管理标记列表避免拼写错误。除了 mark 之外skip和xfail也是高频操作。pytest.mark.skip适用于已知环境不支持、暂时无法通过的用例pytest.mark.xfail表示预期失败如果它意外通过了pytest 会以 xpass 状态提示你该调整预期了。这两个标记在做跨版本兼容测试时特别省心。6.2 实测过程中最值得配置的重试与并发插件接口服务偶发 5xx、UI 自动化偶发元素定位失败这类问题在 CI 环境里非常烦人。pytest-rerunfailures插件能给失败用例设置重试次数我常用的命令是pytest -v --reruns 2 --reruns-delay 1意思是失败后最多再重试 2 次每次间隔 1 秒。重试间隔很重要如果没有延迟有些网络抖动在同一秒内会反复出现重试等于白做。你也可以在标记层面单独控制某类用例的重试策略pytest.mark.flaky(reruns3, reruns_delay2) def test_unstable_api(): ...注意一个反模式把重试当作掩盖 bug 的手段。如果某条用例重试 3 次依旧偶发失败你应该优先去查不稳定原因而不是继续加大重试次数。我在项目里对重试用例都有一个专门标记方便后续统计哪些用例长期不稳定。并发执行用的是pytest-xdist用法简单pytest -n auto-n auto会根据你机器的 CPU 核心数自动开启进程数。用并发需要注意数据隔离问题如果你的用例之间有共享文件、数据库状态直接并发可能导致相互覆盖。我的建议是并发前先确认 session 级 fixture 的初始化逻辑是幂等的否则不同 worker 会重复执行初始化反而拖慢速度。在接口测试场景并发收益通常比 UI 自动化明显得多因为接口用例对资源的独占要求低瓶颈主要在 IO。6.3 allure 报告把执行结果真正变成可交付的产出我几乎每个项目都会给 pytest 配上 allure因为命令行里的绿色红色只能自己看但测试报告要发给产品、开发和其他团队漂亮可交互的报告能省掉大量口头解释的时间。接入步骤不复杂pip install allure-pytest执行时指定结果目录pytest --alluredirreports/allure-results然后启动本地报告服务allure serve reports/allure-results仅仅这样就已经能生成带用例清单、执行时间、失败日志的报告了。但要让报告达到能直接给开发看的水平还要给用例加上 allure 的描述性标签import allure allure.feature(登录模块) allure.story(正常登录) allure.severity(allure.severity_level.CRITICAL) def test_login_success(): ...feature对应模块story对应业务场景severity表示严重程度。报告页面上就能按模块去看通过率而不是在几千条用例里翻。如果你做 UI 自动化还可以在用例失败时把当前页面截图附到报告里用allure.attach把图片文件挂上去这样即使没有视频回放也能快速判断是页面没渲染出来还是断言写错了。6.4 从接口到 appiumpytest 在移动 UI 自动化里的同构写法最后回应一下很多人都关心的 appium 自动化场景。pytest 在这类项目里的角色和接口测试完全一致fixture 负责创建 driver 连接测试函数负责操作步骤parametrize 负责多机型、多场景驱动。appium 项目的 conftest 里通常会有一个scopefunction的 fixture负责在每个用例开始前启动 driver在用例结束后执行driver.quit()这比 unittest 里手工管理 driver 生命周期干净得多。UI 用例的截图和日志收集也是通过 fixture 的 yield 后置逻辑去实现的。例如失败截图自动 attach 到 allure 报告只需要在 fixture 里判断用例结果然后执行截图保存。这套模式跑通之后你会发现 pytest 并没有为 UI 自动化准备什么魔法它只是把每个用例的前置和后置动作标准化了剩下的都交给测试代码自己组织。最后分享一个我已经养成的小习惯如果你正在从 unittest 或自研脚本框架转过来建议不要一口气把所有用例全部重写。先把一个完整模块迁过来跑出报告让团队看到 fixture 和参数化带来的减码效果再逐步扩散。另外我在本地开发时会频繁使用pytest --lf和pytest --ff--lf表示只重跑上次失败的用例--ff表示把上次失败的用例排在最前面执行这两个参数在调试不稳定用例时能让反馈循环短非常多。等你真正跑上几周回头再看那些曾经让你引以为傲的 setUp 代码你大概率会和我一样再也不想回到 unittest 的写法里。