尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

pytest实战指南:从自动化测试框架选型到工程化落地

pytest实战指南:从自动化测试框架选型到工程化落地 这几年做 Python 自动化测试我前后用过unittest、nose最后在接口自动化、UI 自动化、Appium 移动端脚本里全面切到了pytest框架。如果你让我给团队推荐一套自动化测试框架我不会犹豫就直接说 pytest。不是因为它功能最多、文档最花哨而是因为它的学习曲线、扩展方式以及插件生态能让你从一个几十行的冒烟脚本平滑地演进成一套可维护、可报告、可持续集成的测试体系。这篇文章不打算写成一份教程式的官方文档我想从一个实际跑了几年自动化测试的人角度拆解 pytest 的核心机制、常用写法、踩过的坑以及怎么把它和 Allure 报告、参数化、失败重试这些工程能力组合起来。无论你是刚接触 Python 自动化测试的新人还是已经用 unittest 写了不少用例、想换个更顺手的框架下面这些内容应该都能对上胃口。1. 为什么是 pytest从 unittest 到 pytest 的迁移逻辑1.1 pytest 到底解决了什么问题很多人第一次接触 pytest第一个疑问是unittest 也能写测试为什么非要换我当初也有这个疑问。真正切换之后才明白pytest 解决的并不是“能不能写测试”的问题而是“写测试、维护测试、扩展测试”的体验问题。unittest 最大的问题是它把 Java 系 JUnit 的断言风格带到了 Python 里比较两个值要用assertEqual判断真值要用assertTrue写起来绕读起来也绕。pytest 直接用 Python 原生的assert 表达式失败时还能自动把中间变量的值展示出来定位问题快很多。再加上 fixture、参数化、插件体系这些机制pytest 能把测试代码的重复度压得很低。另一个关键点是测试发现规则。pytest 默认会递归查找当前目录下所有test_*.py或*_test.py文件以及文件里所有test_开头的函数和类你不需要手动去注册用例放进目录就能跑。这一点对团队成员协作特别友好新同学不需要理解复杂的套件加载逻辑只需要按约定命名。1.2 与 unittest、nose 的对比选型我用过nose它曾经也是一个很流行的选择但它更新太慢Python 3 时代的兼容性主要靠 pytest 去承接。现在再对比基本是 unittest 和 pytest 二选一。从测试生命周期来看unittest 提供了setUp、tearDown、setUpClass、tearDownClass这一套经典的钩子概念很清晰。但它的维度有点死板每个测试类里的setUp几乎都是一样的代码比如创建一个 HTTP 客户端、初始化数据库连接。pytest 用fixture替代了这套结构fixture 可以定义在单文件里也能放在conftest.py里共享还能按作用域控制创建和销毁时机灵活度不是一个量级。我整理过一张简单对比表可以直接拿去做选型参考对比项unittestpytest用例命名必须用unittest.TestCase子类函数名以test_开头即可断言assertEqual、assertTrue等专用方法原生assert失败信息更友好fixture 机制setUp/tearDown依赖继承fixture 函数支持作用域和参数注入参数化没有原生支持实现麻烦pytest.mark.parametrize非常方便插件生态很少大量成熟插件Allure、xdist、rerun 等第三方断言库可选可以结合pytest-assume、pytest-check等当然unittest 并非没有价值。如果你的团队里全是 Java 背景、对 JUnit 很熟或者项目已经被 unittest 的类结构盘得很死迁移成本也很高。但从可维护性和上手速度来看pytest 更适合大多数自动化测试项目。1.3 安装与最小可用示例pytest 的安装没有任何特殊要求就是一个普通 PyPI 包pip install pytest装完之后在项目根目录建一个测试文件test_demo.pydef add(a, b): return a b def test_add(): assert add(1, 2) 3然后在终端执行pytest你会看到 pytest 自动收集到test_demo.py::test_add并执行通过。这基本就是 pytest 最小可用的全貌。随着项目变大你需要处理的更多是“如何组织用例”“如何共享测试数据”“如何生成报告”这些我会在后面展开。2. 核心机制拆解fixture、conftest 与作用域2.1 fixture 的设计思路和 yield 用法fixture 是 pytest 最核心、也最容易被新人搞混的概念。我用一句话概括fixture 就是一个“带生命周期管理的依赖注入函数”。最原始的用法是准备测试数据、创建资源import pytest pytest.fixture def user_token(): token login(admin, 123456) return token def test_get_info(user_token): resp get_user_info(user_token) assert resp.status_code 200测试函数的参数名写user_tokenpytest 就会自动去查找同名 fixture并把返回值注入进来。这个“按名字注入”的设计非常 Pythonic但它也有一个副作用如果你给参数取了一个和已有 fixture 重名的名字就会被隐式覆盖这是后面要讲的坑之一。更复杂的场景是在用例执行前准备资源、用例执行后清理资源。这时候要用yieldpytest.fixture def db(): conn create_connection(test.db) conn.clean() yield conn conn.close()yield之前的代码在用例前执行yield语句把conn传给测试函数yield之后的代码在用例结束后执行。这个设计比 unittest 里写setUp和tearDown两个方法更直观因为它把“准备”和“清理”写在同一个函数里你一眼就能看到对应关系。2.2 conftest.py 的分层共享fixture 定义在某个测试文件里时只能被这个文件中的用例使用。如果多个测试文件都需要同一个 fixture比如都需要登录 token那你总不能每个文件都复制一遍。pytest 的解决方案是conftest.py。conftest.py是一个特殊文件放在某个目录下它定义的 fixture 可以被这个目录及其子目录里的所有测试文件使用。这是一个按目录层级生效的机制project/ conftest.py # 全局 fixture test_user/ conftest.py # 仅 user 目录使用 test_login.py test_order/ test_order.py我一般会把全局通用的 fixture比如测试环境的配置、数据库连接、公共接口客户端放在项目根目录的conftest.py里把某个业务模块特有的数据放到对应模块目录下的conftest.py。这样既减少了重复代码也明确了 fixture 的使用范围不会所有东西都堆在全局。2.3 fixture 作用域与自动使用fixture 默认是函数级作用域也就是每条用例执行前后都会执行一次。如果每次用例都需要真实登录一次速度会很慢。所以 pytest 提供了scope参数pytest.fixture(scopesession) def login_token(): return login(admin, 123456) pytest.fixture(scopemodule) def order_db(): ...scopesession表示整个 pytest 会话中只执行一次适合生成 token、加载配置这类重量级操作。scopemodule表示一个测试文件内只执行一次适合共享只读数据。scopeclass则是某个测试类内共享一次。还有一种情况是你希望某个 fixture 对每条用例都生效但用例函数里又不需要显式写这个参数。比如每个用例都要保证临时目录是干净的可以加autouseTruepytest.fixture(autouseTrue) def clean_temp(): tmp_dir Path(/tmp/test_output) if tmp_dir.exists(): shutil.rmtree(tmp_dir) tmp_dir.mkdir() yield shutil.rmtree(tmp_dir)加了autouseTrue之后pytest 会自动把所有测试的上下文包上这个 fixture不需要在测试函数参数里声明。但它也有风险如果 fixture 内部逻辑太重每条用例都会白白增加开销。所以autouse只适合轻量、必须执行的逻辑。2.4 实战经验fixture 返回值和 teardown 的顺序用得多了之后我发现 fixture 最容易出问题的不是返回值而是销毁顺序。pytest 的销毁顺序是反序的也就是后创建的先清理。举个例子如果你有个 session 级 fixture 创建了 token另一个函数级 fixture 依赖它那么函数级 fixture 先结束session 级 fixture 后结束。这看起来是很自然的栈式生命周期但如果你在 session 级 fixture 里关闭了某个连接而函数级 fixture 里还有清理逻辑要依赖这个连接就会踩到“连接已关闭”的坑。我的建议是所有资源清理都在定义它的那个 fixture 内部完成不要让下游 fixture 去收拾上游的残局。另外还有一个很实用的经验如果 fixture 需要在用例失败时也执行清理直接写在yield后面就行。pytest 对 teardown 阶段的异常会单独汇总不会让清理逻辑静默丢失。真正需要优先清理的可以把yield后面的代码用try / finally包住确保在异常情况下也能释放调用的句柄。3. 参数化与数据驱动让用例摆脱重复代码3.1 parametrize 的基础用法接口自动化里最烦的事情是什么不是断言不是环境而是一模一样的用例要换不同的数据反复跑。如果你用 unittest通常会为了十条测试数据复制十个测试方法或者在一个测试方法里写循环然后第一条断言失败后面全都不执行了。pytest 的pytest.mark.parametrize就是为数据驱动设计的。看一个最简单的例子import pytest pytest.mark.parametrize(username,password,expect, [ (admin, 123456, 200), (guest, 123456, 200), (none, 123456, 401), ]) def test_login(username, password, expect): resp login(username, password) assert resp.status_code expectpytest 会把这一条函数展开成三条用例每条看成一个独立的节点。展开之后的好处是其中一条数据失败不会影响其他数据失败报告里也能直接看到到底是哪组数据挂掉了。这比自己在函数内部写循环要清晰得多。3.2 多参数组合与 ids参数多的时候生成的测试节点 ID 会是一长串值比如test_login[admin-123456-200]如果参数里包含中文字符、特殊符号或者特别长的字符串报告会非常难看。这时候可以传ids参数给每组数据起一个可读的 IDpytest.mark.parametrize(username,password,expect, [ (admin, 123456, 200), (guest, 123456, 200), ], ids[admin-login, guest-login]) def test_login(username, password, expect): ...执行时显示的节点名称就不再是一堆裸参数而是test_login[admin-login]、test_login[guest-login]。在几千条用例的报告里这个细节能省很多找用例的时间。多参数、多组数据还可以叠加使用比如笛卡尔积。但我的经验是两个parametrize叠起来用例数量会爆炸使用时必须想清楚有没有必要做到全覆盖不要为了“看起来自动化很全面”而制造出执行时间以小时计的测试集。3.3 从外部文件读取测试数据测试数据直接写在装饰器里适合数量少、变化不频繁的场景。一旦数据量变大或者需要让测试人员不碰代码就能维护数据就应该把数据抽到文件里。我常用的做法是 JSON 或 YAMLimport json import pytest def load_cases(): with open(cases/login_cases.json, encodingutf-8) as f: return json.load(f) pytest.mark.parametrize(case, load_cases(), idslambda c: c[id]) def test_login(case): resp login(case[username], case[password]) assert resp.status_code case[expect]读取文件的逻辑写在模块顶部pytest 导入模块时就会执行也就是在用例收集阶段完成数据加载。这样做的优点是数据文件可以和代码分离缺点是一旦文件格式错误整个模块导入失败所有用例都会被报错。所以数据文件最好在外层就做一次格式校验别把脏数据直接塞给字段访问。3.4 参数化与 fixture 的联动少数情况下你不仅希望参数传给测试函数还希望参数能影响 fixture 的准备过程。比如不同参数需要不同数据库连接或者不同参数对应不同测试账号这时候要用到parametrize的indirect参数。先定义一个接收request.param的 fixturepytest.fixture def env(request): return request.param再在用例上标记pytest.mark.parametrize(env, [test, staging], indirectTrue) def test_env(env): assert env in [test, staging]indirectTrue的含义是参数名env不要直接用作测试函数的普通参数而是把它传给同名 fixture由 fixture 根据参数值准备资源。这个用法看起来有点绕但它非常强大是后面理解 pytest 动态 fixture 的基础。日常大多数接口测试用不到但设计一套测试平台时这几乎是绕不开的。4. 断言、异常测试与标记管理4.1 原生断言为什么够用pytest 失败的断言输出做得是真的细。比如def test_total(): total 100 expected 101 assert total expected执行失败信息会显示 assert total expected E assert 100 101不仅告诉你断言失败还把两个变量的实际值直接摆在面前。复杂一点如果断言的是一个包含两个字段的对象pytest 会做部分比较并展示差异。这让排查问题变得很快也不需要额外装 Hamcrest 那种断言库。当然原生断言在一些场景下确实没那么方便。比如一个用例里有多个独立断言你可能希望第一条失败后后面的断言还能继续执行而不是立刻终止。这时可以用pytest-check或pytest-assume插件来实现软断言。我的经验是接口测试默认用原生断言就好只有当你有“连续校验多条业务规则、失败后不中断”这个明确诉求时再引入软断言。4.2 异常断言与 pytest.raises自动化测试不只是测正流程异常分支同样重要。你要验证传入非法参数时系统是否返回了正确的报错信息而不是真的让异常直接抛出导致用例挂掉。pytest.raises就是干这个的import pytest def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): result 1 / 0还可以进一步校验异常信息里的关键字with pytest.raises(ValueError, matchmust be positive): register_user()match支持正则表达式它只匹配异常字符串的一部分。我踩过一个坑是用match去匹配中文提示的时候需要确保项目编码和终端编码一致不然会看到编码错误而不是用例失败。4.3 marker 和自定义标记pytest 自带了一些标记比如pytest.mark.skip、pytest.mark.slow实际上你可以自定义任意标记用于对用例做分类。最常见的需求是只跑冒烟用例import pytest pytest.mark.smoke def test_login(): ... pytest.mark.regression def test_order(): ...然后在命令行只跑冒烟pytest -m smoke不用-m跑全量时pytest 会提示你注册自定义标记否则会有 warning。我通常在项目根目录建一个pytest.ini[pytest] markers smoke: 冒烟测试 regression: 回归测试 slow: 耗时用例pytest.ini不仅能注册标记还能配置文件搜索路径、命令行参数默认值等。我强烈建议所有项目从一开始就建一个pytest.ini哪怕里面只写markers也能避免很多后续配置问题。4.4 条件跳过与预期失败有些用例依赖操作系统、Python 版本、测试环境变量。比如只有 Windows 才执行的用例或者只有开了某个功能开关才执行的用例可以用skipifimport sys import pytest pytest.mark.skipif(sys.platform win32, reasonnot supported on Windows) def test_cmd(): ...还有一种场景是你已知某个功能还没有实现但想保留用例让它先标记为预期失败而不是直接注释掉。这时候可以用xfailpytest.mark.xfail(reasonwaiting for bugfix) def test_known_bug(): ...预期失败如果意外通过了pytest 会在报告中显示XPASS这可能意味着 bug 已经修复、应该移除xfail。在团队协作里我会要求把xfail的原因写清楚否则过几个月没人记得当初为什么标记它。5. 插件生态与测试报告pytest 和 Allure 的实战组合5.1 常用插件清单pytest 的强大很大一部分来自插件。我平时几乎固定使用的插件有这几个pytest-xdist并行执行用例。pytest-rerunfailures失败用例自动重试。pytest-html生成轻量 HTML 报告。allure-pytest生成 Allure 报告。pytest-order控制用例执行顺序。pytest-cov测试覆盖率统计。这些插件可以通过一行命令安装pip install pytest-xdist pytest-rerunfailures pytest-html allure-pytest pytest-cov插件的组合能力要小心使用。比如失败重试和并行执行一起用报告里会多出 rerun 的节点allure 报告可以区分出flaky标记。如果团队对用例稳定性要求很高可以设置重试 1 到 2 次但最好单独统计重试过后仍然失败的用例因为这些才是真正需要处理的问题。5.2 Allure 报告接入Allure 报告是我在接口自动化项目里最常用的一环。它不只是展示通过/失败还能按功能模块、严重程度、执行历史来呈现结果给测试经理和开发看都比纯文本输出直观得多。接入步骤很简单。第一步安装插件pip install allure-pytest第二步执行时指定结果目录pytest --alluredir./allure-results第三步用 Allure 命令行生成 HTML 报告allure generate ./allure-results -o ./allure-report --clean如果本机没有安装allure命令行工具可以下载 Allure 的可执行文件或通过brew install allure在 macOS 上安装。团队里最好在 CI 环境统一安装否则不同成员的报告生成结果会有差异。在用例里还可以加一些装饰器把业务描述和缺陷链接带到报告里import allure allure.feature(用户模块) allure.story(登录) allure.title(使用正确的用户名密码登录成功) allure.severity(allure.severity_level.BLOCKER) def test_login_success(): ...这些描述信息会让报告变得非常有可读性。项目大了以后Allure 报告甚至可以按feature和story来筛选用例相当于给测试用例加了一套分类标签体系。5.3 失败重试与用例排序网络自动化测试最烦的就是偶发性失败比如后端超时 3 秒但脚本只等了 2 秒。这种用例不适合立刻改脚本因为真实环境就是有波动。用pytest-rerunfailures可以缓解pytest --reruns 2 --reruns-delay 1这条命令表示失败后最多重试 2 次每次间隔 1 秒。但重试不是银弹如果业务逻辑本身就是幂等的重试没问题如果用例会创建订单、发送短信重试可能会导致重复数据。所以我在给有副作用用例配置重试时会非常谨慎。关于用例顺序pytest 默认按照文件内定义顺序执行不同文件按收集顺序执行。如果你依赖某个测试先执行完成再去执行另一个这种设计本身就是脆弱的。更合理的做法是让每条用例尽量独立。但如果确实需要排序可以用pytest-order插件在用例上标记优先级。5.4 并行执行用例数量多以后单进程跑的速度会让人怀疑人生。pytest-xdist可以按进程并行pytest -n 4-n 4表示开 4 个进程。这里有个大坑并行的进程之间不共享内存如果你在一个进程里写临时文件另一个进程可能完全看不到。更危险的是数据库和测试环境的隔离多个进程同时创建相同资源可能产生冲突。所以并行执行之前先把用例的独立性盘好再用xdist。6. 常见问题与排查技巧实录6.1 fixture 不可见或 not found最常见的报错是fixture xxx not found。原因通常是 fixture 定义在某个测试文件里但另一个目录下的用例也想用跨目录不可见。解决方案是把它移到合适的conftest.py里。还有一类原因比较隐蔽fixture 确实写在conftest.py里了但conftest.py目录名不对或者 pytest 的执行根目录不是项目根目录。我一般会先执行pytest --collect-only -q看看实际收集到的测试节点有哪些再检查 fixture 在哪个目录生效。6.2 测试之间状态污染fixture 的scopesession是共享的如果某个用例修改了共享数据后续用例就会受影响。我遇到过最典型的是 session 级 fixture 返回了一个 dict某个测试函数改了 dict 里的字段之后其他用例读取到的是被改过的数据。对策是session 级 fixture 里尽量只放只读数据如果必须可变就在返回前用copy.deepcopy复制一份或者把对象改成函数级 fixture。自动化测试的用例之间应该尽可能不会互相影响这一点比单条用例跑得快更重要。6.3 收集用例为 0pytest执行后出现collected 0 items先检查文件名是否满足test_*.py或*_test.py函数名是否以test_开头。还有一个容易漏的点是如果项目里存在__init__.pypytest 在某些配置下会认为这是一个包收集行为会有差异。建议在pytest.ini里明确设置[pytest] python_files test_*.py *_test.py python_functions test_*有时是你跑命令的目录不对。pytest 默认从当前目录往下收集如果当前目录在项目根目录上层自然什么都找不到。6.4 命令行参数的坑pytest -m smoke和pytest --exclude这类自定义参数在有的版本里需要先从pytest_addoption里注册。如果你只是临时用-m不会踩到但如果你写自定义插件要注意所有--开头的参数都必须先addoption否则 pytest 会直接报 unrecognized arguments。还有一个体验问题是默认的 pytest 输出日志会被抑制测试代码里的print不能用普通方式看到。需要看输出时可以加-s参数或者用--capturesys。在排查问题时我会优先用-s而不是在用例里加一堆print再删掉。6.5 快速问题速查表常见问题可能原因排查/解决方向fixture not foundfixture 作用域外或 conftest 目录不对用--collect-only查看收集情况并检查目录层级collected 0 items文件/函数命名不符合规则或执行目录不对按test_前缀命名检查pytest.ini断言失败但信息看不懂断言变量类型复杂给断言加一个辅助描述或用pytest-check更明确地分组session fixture 被修改多个用例共享可变对象返回不可变对象或使用函数级 fixture并行执行数据冲突用例之间没有原子性每个进程独立使用数据目录数据库测试前清理数据重试后用例反而失败重试用例存在副作用对写操作类用例关闭重试或增加环境清理7. 把 pytest 用顺手的几个小习惯如果说前面这些是知识点那最后这几点是我在自己的项目里沉淀下来的使用习惯。第一从一开始就在项目根目录放一个pytest.ini。就算只有两三条配置也会把“默认执行范围”“markers 注册”“addopts 默认参数”这些问题提前定下来。后面团队扩张、CI 接入都不会因为每个人本地命令行参数不一样而出现行为差异。第二把 fixture 分成“资源型 fixture”和“业务型 fixture”两层。资源型 fixture 负责创建连接、登录、初始化临时目录业务型 fixture 负责拼业务数据、构造请求体。这两类职责分开之后用例里面看起来会特别干净大部分测试函数只需要声明一个业务 fixture 就能开始写断言。第三报告和日志要一起做。Allure 报告虽然好看但它记录的是测试结果不是业务日志。我通常会在 fixture 里用标准logging模块把请求 URL、响应码、耗时写进日志再配合 Allure 的 attachment 把关键请求响应挂到报告里。这样线上出问题时测试人员不用连服务器扒日志直接在报告里就能定位是环境问题还是用例问题。第四及时清理不再使用的 fixture 和参数化数据。自动化测试框架最容易负债的就是“看起来功能很多但没人知道哪些用例还在跑、哪些已经失效”。pytest --collect-only -q可以快速看到全量用例清单定期对着清单删掉冗余用例比一直堆新用例更能保证测试集的可维护性。pytest 这框架初看很简单真正把它用好需要理解的是它在测试生命周期里的定位——它不只是帮你跑脚本而是帮你把测试用例的组织方式、数据驱动方式、报表展示方式都拉上规范。希望这篇基于实战经验的拆解能帮你少走一些我走过的弯路。
返回列表