高级测试工程师课程(第11章):PyTest 框架实战——fixture、参数化、mock、HTML报告与hook机制全打通
前言
pytest 是 Python 测试领域事实上的标准框架。相比标准库 unittest,它用更少的代码表达更强的能力:自动发现、fixture 依赖注入、参数化数据驱动、丰富的插件生态。本章我在 Ubuntu 24.04 服务器上基于pytest 9.1.1完整实操了课程第 11 章内容:unittest 与 pytest 正面对比、pytest.ini 配置、-k/-m执行控制、fixture 四种 scope 的生命周期实证、conftest.py 共享、@pytest.mark.parametrize 数据驱动、unittest.mock 打桩、pytest-html 报告、conftest hook 机制,全部真实执行并附真实输出。
一、实验环境
| 项目 | 配置 |
|---|---|
| 云主机 | 华为云 ECS,8 vCPU / 14GB 内存 |
| 操作系统 | Ubuntu 24.04.4 LTS (noble) |
| Python | 3.12.3(虚拟环境 /root/venv) |
| pytest | 9.1.1 |
| pytest-html | 4.2.0(pluggy 1.6.0) |
| requests | 2.34.2(mock 演示用) |
| 实操目录 | /root/pytest-lab |
安装过程:
$aptinstall-ypython3-venv&&python3-mvenv /root/venv $ /root/venv/bin/pip configsetglobal.index-url https://repo.huaweicloud.com/repository/pypi/simple $ /root/venv/bin/pipinstallpytest pytest-html requests $ /root/venv/bin/pytest--versionpytest9.1.1二、为什么用 pytest:与 unittest 正面对比
2.1 被测函数
先把被测代码myfunc.py摆出来——一个"字符串转整数并校验"的函数,后续所有用例都围绕它:
defstr_to_int(s):"""字符串转整数:去首尾空白、支持正负号、非法输入抛异常"""ifnotisinstance(s,str):raiseTypeError(f"期望str, 实际{type(s).__name__}")s=s.strip()ifnots:raiseValueError("空字符串")body=s.lstrip("+-")ifnotbody.isdigit():raiseValueError(f"{s!r}不是合法整数")returnint(s)2.2 unittest 版本
importunittestclassTestStrToInt(unittest.TestCase):defsetUp(self):print("\n[unittest] setUp 每条用例前执行")deftearDown(self):print("[unittest] tearDown 每条用例后执行")deftest_normal(self):self.assertEqual(str_to_int("123"),123)deftest_invalid_raises(self):withself.assertRaises(ValueError):str_to_int("abc")# ... 共5条2.3 pytest 版本
importpytest@pytest.fixture(autouse=True)defaround_each():print("\n[pytest] 前置")yieldprint("[pytest] 后置")deftest_normal():assertstr_to_int("123")==123deftest_invalid_raises():withpytest.raises(ValueError):str_to_int("abc")2.4 真实运行对比
unittest 运行:
$ /root/venv/bin/python -m unittest discover -s tests -p 'test_unittest*' -v test_invalid_raises (test_unittest_style.TestStrToInt.test_invalid_raises) ... ok test_negative (test_unittest_style.TestStrToInt.test_negative) ... ok test_normal (test_unittest_style.TestStrToInt.test_normal) ... ok test_strip_space (test_unittest_style.TestStrToInt.test_strip_space) ... ok test_type_error (test_unittest_style.TestStrToInt.test_type_error) ... ok ---------------------------------------------------------------------- Ran 5 tests in 0.000s OKpytest 运行同功能用例(全量 32 条,摘取关键行):
$ /root/venv/bin/pytest -v ============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /root/venv/bin/python3 rootdir: /root/pytest-lab configfile: pytest.ini testpaths: tests plugins: html-4.2.0, metadata-3.1.1 collected 32 items tests/test_pytest_style.py::test_invalid_raises PASSED [ 28%] tests/test_pytest_style.py::test_negative PASSED [ 37%] tests/test_pytest_style.py::test_normal PASSED [ 43%] ... ============================== 32 passed in 0.18s ==============================2.5 对比结论
| 维度 | unittest | pytest |
|---|---|---|
| 用例写法 | 必须继承 TestCase,类方法 | 普通函数即可 ✅ |
| 断言 | self.assertEqual/assertRaises 等十几种 | 原生 assert,失败自动展开对比 ✅ |
| 前后置 | setUp/tearDown 固定命名 | fixture + yield,可组合可复用 ✅ |
| 参数化 | 需 subTest 或第三方 ddt | @pytest.mark.parametrize 内置 ✅ |
| 插件生态 | 基本没有 | 1600+ 插件(html/allure/xdist…)✅ |
有意思的是:pytest 能直接运行 unittest 风格的用例(上面 32 条里就包含了 unittest 类的 5 条),迁移成本几乎为零。
再补充一个断言层面的细节:pytest 对原生assert做了断言内省(assertion introspection)——当assert a == b失败时,pytest 会自动展开两边的值做 diff 展示,告诉你左边是什么、右边是什么、差在哪个字符。而 unittest 如果偷懒用self.assertTrue(a == b),失败时只会告诉你False is not true,排障体验天差地别。这也是 pytest 敢让你直接用裸 assert 的底气:它重写了 assert 语句的字节码,在不增加任何语法负担的前提下提供了比专用断言方法更强的诊断信息。
三、测试发现与 pytest.ini 配置
3.1 配置文件
[pytest] testpaths = tests addopts = -ra markers = smoke: 冒烟用例(核心链路) slow: 慢速用例(耗时长,日常跳过) disable_test_id_escaping_and_forfeit_all_rights_to_community_support = Truetestpaths:默认只从 tests 目录收集,避免扫到虚拟环境。addopts = -ra:每次运行自动附加-r选项,末尾汇总显示跳过/失败原因。markers:注册自定义标记,不注册会有 warning。- 最后一行解决中文 ids 转义问题,详见踩坑记录。
3.2 -k 按名称过滤(真实输出)
$ /root/venv/bin/pytest -v tests/test_parametrize.py -k 'str_to_int' collected 17 items / 6 deselected / 11 selected tests/test_parametrize.py::test_str_to_int_fail[None类型] PASSED [ 9%] tests/test_parametrize.py::test_str_to_int_fail[字母] PASSED [ 18%] tests/test_parametrize.py::test_str_to_int_fail[小数] PASSED [ 27%] tests/test_parametrize.py::test_str_to_int_fail[空串] PASSED [ 36%] tests/test_parametrize.py::test_str_to_int_ok[前导零] PASSED [ 45%] tests/test_parametrize.py::test_str_to_int_ok[大数] PASSED [ 54%] tests/test_parametrize.py::test_str_to_int_ok[带空格] PASSED [ 63%] tests/test_parametrize.py::test_str_to_int_ok[显式正号] PASSED [ 72%] tests/test_parametrize.py::test_str_to_int_ok[普通] PASSED [ 81%] tests/test_parametrize.py::test_str_to_int_ok[负数] PASSED [ 90%] tests/test_parametrize.py::test_str_to_int_ok[零] PASSED [100%]-k支持表达式:-k "login and not slow",按用例名关键字灵活筛选。
3.3 -m 按标记过滤(真实输出)
$ /root/venv/bin/pytest -v -m smoke collected 32 items / 31 deselected / 1 selected tests/test_parametrize.py::test_login_smoke PASSED [100%] ======================= 1 passed, 31 deselected in 0.06s ======================= $ /root/venv/bin/pytest -v -m 'not slow' ======================= 31 passed, 1 deselected in 0.07s =======================解读:-m smoke精准命中 1 条冒烟用例;-m 'not slow'反向排除 1 条慢速用例。CI 流水线里"PR 阶段跑 smoke、夜间跑全量"就是这么实现的 ✅。
四、fixture 与 scope:四种作用域生命周期实证
4.1 setup/teardown vs fixture
unittest 的 setUp/tearDown 是"一刀切":每条用例前后都执行,无法表达"整个会话只登录一次"。pytest 的 fixture 用scope参数解决这个分层问题。
4.2 演示代码
importpytest@pytest.fixture(scope="session")deff_session():print("\n>>> [session] 整个测试会话只执行1次")yield"session"print("\n<<< [session] 会话结束销毁")@pytest.fixture(scope="module")deff_module():...@pytest.fixture(scope="class")deff_class():...@pytest.fixture(scope="function")deff_function():...classTestGroupA:deftest_a1(self,f_session,f_module,f_class,f_function):print(" 执行 test_a1")deftest_a2(self,f_session,f_module,f_class,f_function):print(" 执行 test_a2")classTestGroupB:deftest_b1(self,f_session,f_module,f_class,f_function):print(" 执行 test_b1")4.3 真实运行输出(pytest -s)
$ /root/venv/bin/pytest -v -s tests/test_fixture_scope.py collected 3 items tests/test_fixture_scope.py::TestGroupA::test_a1 >>> [session] 整个测试会话只执行1次 >>> [module] 每个模块执行1次 >>> [class] 每个类执行1次 >>> [function] 每条用例执行1次 执行 test_a1 PASSED <<< [function] 用例结束销毁 tests/test_fixture_scope.py::TestGroupA::test_a2 >>> [function] 每条用例执行1次 执行 test_a2 PASSED <<< [function] 用例结束销毁 <<< [class] 类结束销毁 tests/test_fixture_scope.py::TestGroupB::test_b1 >>> [class] 每个类执行1次 >>> [function] 每条用例执行1次 执行 test_b1 PASSED <<< [function] 用例结束销毁 <<< [class] 类结束销毁 <<< [module] 模块结束销毁 <<< [session] 会话结束销毁 ============================== 3 passed in 0.01s ===============================4.4 解读
输出是教科书级的证据链:
- session:3 条用例全程只在 test_a1 前创建 1 次,全部用例跑完最后才销毁 ✅
- class:TestGroupA 结束时销毁,TestGroupB 开始时重新创建 ✅
- function:每条用例前创建、用后销毁,共 3 次 ✅
- 销毁顺序与创建顺序严格相反(栈式),module 在最后一个 class 销毁后才销毁。
实战建议:数据库连接/token 用 session scope,测试数据准备用 class/module scope,每条用例的临时状态用默认 function scope。
4.5 fixture 的两个进阶知识点
(1)autouse 自动生效。给 fixture 加autouse=True后,作用域内所有用例不需要在参数列表里声明也会自动执行,适合"每条用例都要做"的隐性准备工作,比如清理临时目录、记录用例开始时间。第二节 pytest 版本对比代码里的around_each就是 autouse fixture,5 条用例没有一条显式引用它,但每条前后都打印了"前置/后置"。要节制使用——autouse 过多会让用例的实际依赖变得隐晦,新人看用例时不知道背后还跑了什么。
(2)fixture 之间的依赖与参数化组合。fixture 可以像用例一样在参数里声明引用其他 fixture,pytest 会按依赖图自底向上组装。当多个带 params 的 fixture 被同一条用例引用时,会产生笛卡尔积组合:比如 browser(3 个值)× env(2 个值)= 6 条用例。这是做多维度兼容性测试的利器,但也要警惕组合爆炸,3 个各带 5 个参数的 fixture 就是 125 条用例。
4.6 常用命令行参数速查
| 参数 | 作用 | 实战场景 |
|---|---|---|
| -v | 显示每条用例名 | 日常调试 |
| -s | 不捕获 print 输出 | 观察 fixture 打印(见4.3节) |
| -k 表达式 | 按名称筛选 | 只跑某个模块的用例 |
| -m 标记 | 按标记筛选 | CI 分层执行 smoke/全量 |
| -x | 第一个失败立即停止 | 快速反馈,联调阶段 |
| –lf | 只跑上次失败的 | 修复后回归验证 |
| -n auto | 多进程并行(pytest-xdist) | 千级用例提速 |
| –html | 生成 HTML 报告(pytest-html) | 结果归档 |
五、conftest.py 共享 fixture 与 fixture 参数化
5.1 conftest.py
importpytest@pytest.fixturedefbase_url():"""共享fixture:所有用例可直接使用"""return"https://api.example.com"@pytest.fixture(params=["chrome","firefox","edge"])defbrowser(request):"""fixture参数化:一个用例自动跑3遍"""returnrequest.paramconftest.py 放在哪个目录,其 fixture 就对哪个目录(含子目录)生效,不需要 import,pytest 自动注入。
5.2 使用与真实运行
deftest_fixture_param_browser(browser):print(f"\n 当前浏览器:{browser}")assertbrowserin("chrome","firefox","edge")deftest_shared_base_url(base_url):assertbase_url.startswith("https://")tests/test_parametrize.py::test_fixture_param_browser[chrome] PASSED [ 18%] tests/test_parametrize.py::test_fixture_param_browser[edge] PASSED [ 21%] tests/test_parametrize.py::test_fixture_param_browser[firefox] PASSED [ 25%] tests/test_parametrize.py::test_shared_base_url PASSED [ 50%]解读:fixture 加params后,引用它的用例被自动展开成 3 条(chrome/edge/firefox 各一遍),这就是 fixture 级参数化,常用于多浏览器、多环境(dev/test/staging)切换 ✅。
六、@pytest.mark.parametrize 数据驱动
6.1 代码:11 组参数化用例(含异常用例)
@pytest.mark.parametrize("raw,expected",[("123",123),(" 42 ",42),("-7",-7),("0",0),("+8",8),("007",7),("999999999999",999999999999),],ids=["普通","带空格","负数","零","显式正号","前导零","大数"])deftest_str_to_int_ok(raw,expected):assertstr_to_int(raw)==expected@pytest.mark.parametrize("raw,exc",[("abc",ValueError),("",ValueError),("12.3",ValueError),(None,TypeError),],ids=["字母","空串","小数","None类型"])deftest_str_to_int_fail(raw,exc):withpytest.raises(exc):str_to_int(raw)6.2 真实运行输出
tests/test_parametrize.py::test_str_to_int_ok[普通] PASSED [ 81%] tests/test_parametrize.py::test_str_to_int_ok[带空格] PASSED [ 63%] tests/test_parametrize.py::test_str_to_int_ok[负数] PASSED [ 90%] tests/test_parametrize.py::test_str_to_int_ok[零] PASSED [100%] tests/test_parametrize.py::test_str_to_int_ok[显式正号] PASSED [ 78%] tests/test_parametrize.py::test_str_to_int_ok[前导零] PASSED [ 68%] tests/test_parametrize.py::test_str_to_int_ok[大数] PASSED [ 54%] tests/test_parametrize.py::test_str_to_int_fail[字母] PASSED [ 18%] tests/test_parametrize.py::test_str_to_int_fail[空串] PASSED [ 36%] tests/test_parametrize.py::test_str_to_int_fail[小数] PASSED [ 27%] tests/test_parametrize.py::test_str_to_int_fail[None类型] PASSED [ 9%]解读:
- 两个函数各 7 组、4 组数据,共 11 条用例全过 ✅。新增一组数据只需在列表里加一行,真正的数据驱动。
ids参数给每组数据起中文名,报告里可读性拉满。- 异常用例用
pytest.raises(exc)断言"必须抛出指定异常",(None, TypeError)这组验证了类型校验分支。
七、unittest.mock 打桩外部依赖
7.1 被测代码
importrequestsdeffetch_username(base_url,user_id):"""调用外部接口获取用户名"""resp=requests.get(f"{base_url}/users/{user_id}",timeout=5)resp.raise_for_status()returnresp.json()["name"]7.2 mock 用例
fromunittest.mockimportMock,patchdeftest_fetch_username_mock():fake_resp=Mock()fake_resp.json.return_value={"id":1,"name":"张三"}fake_resp.raise_for_status.return_value=Nonewithpatch("myfunc.requests.get",return_value=fake_resp)asm:name=fetch_username("https://api.example.com",1)assertname=="张三"m.assert_called_once_with("https://api.example.com/users/1",timeout=5)deftest_fetch_username_http_error():fake_resp=Mock()fake_resp.raise_for_status.side_effect=Exception("404 Not Found")withpatch("myfunc.requests.get",return_value=fake_resp):withpytest.raises(Exception,match="404"):fetch_username("https://api.example.com",999)7.3 真实运行输出
tests/test_mock_demo.py::test_fetch_username_http_error PASSED [ 12%] tests/test_mock_demo.py::test_fetch_username_mock PASSED [ 15%]解读:
patch("myfunc.requests.get")的关键是打桩位置要在被测模块的命名空间(myfunc 里 import 的 requests),而不是requests.get本身——这是 mock 最常见的错误 ❌。side_effect模拟异常分支,assert_called_once_with还能反向验证被测代码发起的请求参数是否正确。- 全程零网络请求,用例跑得又快又稳 ✅。
八、pytest-html 测试报告
8.1 生成命令与真实结果
$ /root/venv/bin/pytest --html=report.html --self-contained-html ---------- Generated html report: file:///root/pytest-lab/report.html ---------- ============================== 32 passed in 0.18s ============================== $ ls -l report.html -rw-r--r-- 1 root root 56599 Sep 5 17:11 report.html--self-contained-html把 CSS/JS 全部内联进单个 HTML 文件(约 55KB),方便邮件发送和归档。从报告内嵌 JSON 中提取到的统计信息:
$ python3 -c "...(解析report.html内嵌jsonblob)..." 报告环境: {'Python': '3.12.3', 'Platform': 'Linux-6.8.0-106-generic-x86_64-with-glibc2.39', 'Packages': {'pytest': '9.1.1', 'pluggy': '1.6.0'}, 'Plugins': {'html': '4.2.0', 'metadata': '3.1.1'}} 用例总数: 32报告包含:环境信息(Python/平台/插件版本)、32 条用例的通过状态、每条用例的执行耗时、失败用例的完整 traceback 与捕获的 print 输出。对更美观的报告可换 allure-pytest,但 pytest-html 胜在零依赖单文件 ✅。
九、hook 机制:pytest_collection_modifyitems
9.1 conftest.py 实现
defpytest_collection_modifyitems(items):"""hook演示1:给名字含 login 的用例自动加 smoke 标记 hook演示2:用例按名称排序(重排序)"""foriteminitems:if"login"initem.name:item.add_marker(pytest.mark.smoke)items.sort(key=lambdai:i.name)9.2 效果验证
回看第三节-m smoke的真实输出:全量 32 条里只有 1 条 smoke 被选中,说明两个 hook 都生效了:
- 用例执行顺序从"文件顺序"变成了"按用例名排序"(对照全量输出,PASSED 顺序按字母排列)✅
- 实际项目中这个 hook 常用于:按标记自动分流、动态 skip、给超时用例自动加
pytest.mark.timeout。
hook 机制是 pytest 插件体系的根基(pluggy),插件作者写的pytest_xxx函数和我们写在 conftest.py 里的没有本质区别。
9.3 常用 hook 速查表
| hook 函数 | 触发时机 | 典型用途 |
|---|---|---|
| pytest_collection_modifyitems | 用例收集完成后 | 重排序、自动加标记、动态 skip |
| pytest_runtest_setup | 每条用例执行前 | 按标记检查环境、前置拦截 |
| pytest_runtest_makereport | 每条用例出结果时 | 失败自动截图、写失败日志 |
| pytest_sessionfinish | 整个会话结束后 | 推送结果到测试平台、发通知 |
| pytest_addoption | 解析命令行参数时 | 给 pytest 增加自定义命令行选项 |
以失败自动截图为例:在 UI 自动化项目里实现pytest_runtest_makereport,当 report.failed 时调用浏览器的截图 API 并把图片路径挂到报告里,这是几乎所有 pytest 系 UI 框架(如 pytest-selenium 生态)的标配做法。hook 的本质是 pytest 在生命周期的关键节点上预留的回调,conftest.py 里同名函数会被 pluggy 自动发现并按序调用,多个插件实现同一 hook 时按注册顺序形成调用链。
十、插件生态简介
pytest 真正的护城河是插件生态,目前 PyPI 上以 pytest- 开头的插件超过 1600 个。测试工程师最该知道的几个:
| 插件 | 作用 | 一句话评价 |
|---|---|---|
| pytest-html | 生成单文件 HTML 报告 | 轻量零依赖,本章已实操 ✅ |
| allure-pytest | 生成 Allure 美观报告 | 颜值高,需装 Allure 命令行,企业项目首选 |
| pytest-xdist | 多进程并行执行 | 千级用例提速利器,pytest -n auto |
| pytest-rerunfailures | 失败自动重跑 | 对付偶发 flaky 用例,但会掩盖真问题,慎用 |
| pytest-timeout | 用例超时强制中断 | 防止某个用例卡死拖垮整个流水线 |
| pytest-ordering | 控制用例执行顺序 | 有依赖的用例才用,能用 hook 就别装插件 |
| pytest-assume | 软断言(失败后继续后续断言) | 一条用例验证多个字段时很有用 |
选型建议:先吃透内置能力(fixture/参数化/marker/hook),再按需引入插件。插件装多了执行变慢、版本冲突概率上升,而且很多插件功能用 conftest.py 十几行代码就能实现——比如本章第九节的用例重排序和自动加标记,就不需要 pytest-ordering。
十一、踩坑记录
坑1:中文 parametrize ids 被转义(本次实操真实遇到)
tests/test_parametrize.py::test_str_to_int_fail[None类型] <- 修复前显示 None\u7c7b\u578b tests/test_parametrize.py::test_str_to_int_fail[\u5b57\u6bcd] PASSED解决:pytest.ini 加一行配置即可(上面 -k 输出已是修复后的效果):
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True这个配置项名字又臭又长(社区自嘲),但确实有效 ✅。
坑2:patch 打桩位置错误
patch("requests.get")不会生效,必须patch("myfunc.requests.get")——打桩打到被测模块引用它的位置。
坑3:Ubuntu 24.04 系统 pip 拒绝安装
externally-managed-environment 错误,✅ 正确姿势是 venv 虚拟环境,❌ 不要用--break-system-packages污染系统 Python。
十二、总结
| 知识点 | 实操结果 |
|---|---|
| unittest vs pytest 对比 | 5 条同功能用例双框架各跑一遍,全过 ✅ |
| pytest.ini + -k/-m | 32 条用例,-m smoke 命中 1 条,-k 命中 11 条 ✅ |
| fixture 四种 scope | 打印顺序完整证明生命周期 ✅ |
| conftest 共享 + fixture 参数化 | browser 参数自动展开 3 条用例 ✅ |
| parametrize 数据驱动 | 11 组用例含 4 组异常断言,全过 ✅ |
| unittest.mock | 正常+异常两分支 mock,零网络 ✅ |
| pytest-html 报告 | 单文件 55KB 报告,32 passed ✅ |
| hook 机制 | 自动加标记+用例重排序 ✅ |
全量执行结果:32 passed in 0.18s,代码留存在/root/pytest-lab。
参考链接
- pytest 官方文档:https://docs.pytest.org/
- pytest-html 插件:https://pypi.org/project/pytest-html/
- unittest.mock 文档:https://docs.python.org/zh-cn/3.12/library/unittest.mock.html