做测试的人,几乎都绕不开 Pytest。但“怎么才能算对 Pytest 做二次开发”这个问题,我在社区里、面试中、团队内部都见过太多误解了。有人写了几个 fixture 封装一下接口请求,就说自己做了二次开发;有人把项目的公共方法整理到一个工具类里,也说自己搭建了二次开发框架。说实话,这些都属于“工程化封装”,离真正的框架二次开发还有不小的距离。
这篇文章我打算把自己这些年做 Pytest 二次开发的经验完整梳理一遍,重点讲清楚三件事:什么才算真正的二次开发、Pytest 的哪些机制是二次开发的根基、以及一个完整的实战落地方案应该长什么样。文章里会带大量可复制的代码和踩坑记录,适合已经能用 Pytest 写用例、但想往框架层面走的测试开发同学,也适合团队里准备统一测试规范、搭建内部测试平台的人。
1. 先搞清楚:什么才算真正的二次开发
1.1 二次开发的边界:使用、封装与扩展的区别
我在面试候选人的时候,最常问的一个问题是:“你说你对 Pytest 做了二次开发,那你改了它哪些行为?”得到的答案十有八九是“我写了一个 conftest.py,里面定义了一些公共 fixture”。这个回答本身没问题,但它暴露了一个认知误区:在框架里写代码,不等于对框架写代码。
为了把这个边界说清楚,我习惯用一张层级表来区分:
| 层级 | 行为举例 | 是否算二次开发 |
|---|---|---|
| 基础使用 | 写 test_ 开头的用例、用 assert 断言 | 不算 |
| 工程化封装 | 封装 requests、封装数据库操作、整理公共 fixture | 不算,属于应用层 |
| 框架扩展 | 自定义 hook 函数、修改用例收集/执行流程 | 算 |
| 框架定制 | 开发独立插件包、重写报告体系、接入自有配置 | 算 |
| 源码改造 | fork Pytest 源码、修改核心逻辑 | 原则上算,但极少需要 |
我见过不少团队,实际只做到了第二层“工程化封装”,但对外宣称做了二次开发。不是说封装没有价值,而是方向不同。封装解决的是“怎么让用例写起来更顺手”,二次开发解决的是“怎么改变框架对用例的调度和反馈方式”。前者是内容创造,后者是容器改造,价值量级完全不同。
1.2 什么场景才真正需要二次开发
我自己判断是否需要做二次开发的标准很简单:如果只是自己写用例,永远不需要二次开发;但如果要建设一套团队级或公司级的测试基座,就可能需要。
三个典型场景供你对照:
第一个场景是统一团队测试规范。团队 20 个人都在用 Pytest,但每个人对用例组织、环境切换、数据清理的方式都不一样。有人把环境变量写死在 setUp 里,有人用 conftest 全局变量,有人干脆不搞环境切换、只跑本地。这种混乱无法通过口头约定解决,必须在框架层面提供一套强制约束机制。
第二个场景是打通测试与外部系统。测试报告要自动同步到项目管理平台,失败的用例要自动创建缺陷单,每晚的回归结果要在企业微信群里推送。这些逻辑显然不应该散落在每个用例里,而应该挂在 Pytest 的运行生命周期上统一处理。
第三个场景是改造 Pytest 的默认行为。比如 Pytest 默认的用例 ID 很长,展示在报告里不直观;失败信息输出过于冗长,团队希望精简;参数化的展示方式不符合业务诉求。这些都属于框架层行为,只能用框架层手段去改。
如果你遇到的痛点能归到上面三类之一,那确实值得考虑投入二次开发。
2. Pytest 的可扩展机制:二次开发的基石
Pytest 之所以适合做二次开发,核心在于它的三层扩展体系:hook 函数机制、fixture 体系、插件加载机制。这三层设计得非常干净,做二次开发的人只要吃透这三样,就能比较自由地定制框架行为。
2.1 hook 函数机制:运行生命周期的控制面板
Pytest 内部跑完一条用例,会触发几十个 hook 点位。对二次开发来说,最常用的是下面这几个点位,我按执行顺序整理成了表格:
| hook 点位 | 触发时机 | 典型用途 |
|---|---|---|
| pytest_configure | 配置加载完成,测试会话初始化 | 注册自定义标记、初始化全局资源、读取配置 |
| pytest_addoption | 解析命令行参数时 | 自定义命令行参数,比如 --env |
| pytest_collection | 收集用例之前 | 干预收集行为,过滤目录 |
| pytest_collection_finish | 用例收集完成后 | 对用例排序、过滤、打标签 |
| pytest_runtest_setup | 每个用例执行前 | 用例级前置逻辑,结合 fixture 使用 |
| pytest_runtest_call | 真正执行测试函数 | 统计执行耗时、做超时控制 |
| pytest_runtest_makereport | 每个用例执行完,生成报告数据 | 失败重跑、结果通知、数据收集 |
| pytest_sessionfinish | 整个测试会话结束 | 生成汇总报告、推送通知、销毁资源 |
这里面,我个人用得最多的是pytest_configure、pytest_runtest_makereport和pytest_sessionfinish,后面实战章节会详细展开。
先强调一个新手最容易犯的错:hook 函数的名字必须和 Pytest 注册的名字完全一致,一个字母都不能差。很多人把pytest_runtest_makereport拼成pytest_runtest_makereports,Pytest 不会报错,因为它发现不了这个函数,只会在启动时静默忽略。这种 bug 极难排查,浪费的时间完全可以避免,写完后建议用pytest --trace-config检查插件和 hook 是否被正确加载。
2.2 fixture 体系:不仅仅是数据准备工具
大部分测试同学对 fixture 的理解停留在“数据准备器”层面。但从二次开发角度,你还要看到 fixture 的另外两个价值维度。
第一个维度是autouse 自动化生效。如果想让一个 fixture 对目录下所有用例自动生效,而不需要每个用例都声明参数,就给 fixture 加上autouse=True:
@pytest.fixture(autouse=True) def auto_logging(request): logging.info(f"用例 {request.node.name} 开始执行") yield logging.info(f"用例 {request.node.name} 执行结束")这个机制在框架层非常有用,比如自动采集执行上下文、自动初始化报告上下文,都不需要使用者感知。
第二个维度是作用域设计。Pytest 的 fixture 有 function、class、module、package、session 五个作用域。在框架设计里,作用域决定了状态的生命周期。
我踩过最大的坑就在 session 作用域。当时接口自动化项目的登录 token 放在 session 级 fixture 里,想着整个测试会话只登录一次,效率高。结果某条用例在业务逻辑里调用了刷新 token 的接口,把变量给重新赋值了,导致后续几十条用例全部 401。排查了很久才定位到是状态污染。
后来的设计原则是:
- session 级 fixture 只提供不可变数据,比如配置信息
- 可变状态(token、临时数据)放在 function 级 fixture 中,用完即销毁
- 如果实在需要跨用例共享可变状态,必须提供受控的 setter 方法,而不是直接暴露变量
2.3 conftest.py:从本地化配置到插件入口
几乎每个 Pytest 项目里都有 conftest.py,但它的定位值得重新审视。Pytest 加载插件的途径有三条:
- 通过 pip 安装的第三方插件,Pytest 自动加载
- 通过命令行
-p参数指定的插件模块 - 通过 conftest.py 在目录层级中逐级加载
第三条意味着 conftest.py 本质上是一个目录级别的内置插件。你在里面定义的 hook 函数和 fixture,会自动对当前目录及其子目录下的所有用例生效。既然本质是插件,就应该按插件的组织哲学来设计。
我见过太多项目把 conftest.py 写成一个几百行的大杂烩,fixture、hook、工具函数全塞在里面,非常难维护。更合理的组织方式是把 conftest.py 当作插件的“薄入口”,核心逻辑拆分到独立模块:
tests/ ├── conftest.py # 全局 hook 和 fixture 的入口 ├── framework/ │ ├── hooks.py # hook 实现 │ ├── fixtures.py # 公共 fixture 定义 │ └── config_loader.py # 配置加载逻辑conftest.py 里只做两件事:注册 hook、导入 fixture:
from framework.hooks import * # noqa from framework.fixtures import * # noqa这样分层之后,后续要做成独立插件包,迁移成本极低。
2.4 插件机制:做二次开发和做插件开发的分水岭
如果你只是在 conftest.py 里写 hook,对单个项目来说是二次开发;但如果你要做一个可复用的框架,让多个项目只通过pip install就能获得同样能力,那就要做成独立插件包。
Pytest 插件包的灵魂是入口点声明。在 pyproject.toml 里加上:
[project.entry-points.pytest11] my_test_plugin = "my_test_plugin.hooks"这样 Pytest 启动时会自动扫描并加载my_test_plugin.hooks模块中定义的所有 hook 函数。相比 conftest.py 方案,插件包的优势是:
- 一次安装,全局生效,不用每个项目都贴一份 conftest.py
- 有独立的命名空间,避免 fixture 名冲突
- 可以做版本管理,升级回滚都方便
当你走到了这一步——把核心能力抽成独立插件包,提供配置入口,做出版本和文档——才配得上“二次开发”这四个字。
3. 从零搭建一个 Pytest 二次开发的脚手架
理论讲多了容易飘,直接上手做一个能用的东西才是正道。这章我以自己的一个内部框架为例,完整演示从零搭建 Pytest 二次开发脚手架的过程。这个框架要解决三个问题:统一配置与多环境切换、数据驱动执行、结果收集与报告定制。
3.1 项目目录结构设计
二次开发项目的目录结构跟普通测试项目完全不同。普通测试项目的顶层是 tests,里面全是用例;而框架项目的顶层是你的“产品”——框架本身,用例只是示例。
我推荐的结构:
my_framework/ ├── pyproject.toml # 插件包声明,pytest11 入口点 ├── README.md # 使用文档 ├── my_framework/ │ ├── __init__.py │ ├── hooks.py # 核心 hook 实现 │ ├── config.py # 配置加载模块 │ ├── fixtures.py # 公共 fixture │ ├── data_driver.py # 数据驱动引擎 │ └── reporter.py # 报告数据处理 ├── examples/ │ ├── conftest.py # 示例项目的 conftest │ └── test_demo.py # 示例用例 └── tests/ └── test_framework.py # 框架自测用例之所以一定要包含一个 examples 目录和一个 tests 目录,是因为框架本身也是软件,需要示例文档和自动化测试。很多测试开发同学在搭框架时只顾着写功能,忘了给框架写测试,后面框架改起来越来越慌,这肯定是不可持续的。
3.2 配置体系设计:分层覆盖
框架里的配置管理一定不能像普通项目那样写死一个 config.py。我推荐的分层覆盖优先级是:命令行参数 > 环境变量 > 配置文件 > 默认值。
基于这个思路,config.py 可以这样设计:
import os import yaml class FrameworkConfig: def __init__(self, env: str = "dev"): self.env = env self._config = {} self._load_defaults() self._load_file() self._load_env() def _load_defaults(self): self._config.update({ "base_url": "http://localhost:8080", "timeout": 10, "retry_count": 0, "report_enabled": True, }) def _load_file(self): env_file = f"config.{self.env}.yaml" if os.path.exists(env_file): with open(env_file, "r", encoding="utf-8") as f: file_config = yaml.safe_load(f) if file_config: self._config.update(file_config) def _load_env(self): prefix = "FW_" for key in list(self._config.keys()): env_key = prefix + key.upper() if env_key in os.environ: self._config[key] = os.environ[env_key] def get(self, key: str, default=None): return self._config.get(key, default)代码本身不复杂,关键是为什么要分层。因为在 CI/CD 流水线里,你不能要求部署人员去改代码或配置文件,唯一方便的入口就是环境变量。比如 Jenkins 上只需要配置一个FW_BASE_URL,就能把整个回归从测试环境切到预发环境,不用动任何代码。
配置文件我用 YAML 而不是 JSON,因为 YAML 支持注释,业务同学维护数据文件更友好。每个环境一个独立文件:config.dev.yaml、config.staging.yaml、config.prod.yaml,内容结构保持一致,值不同。
3.3 自定义命令行参数的注册
配置系统的第一个入口是命令行参数。Pytest 要支持--env=staging这种用法,需要先注册参数:
def pytest_addoption(parser): parser.addoption( "--env", action="store", default="dev", help="指定运行环境: dev/staging/prod" )注意,参数注册本身不在 conftest.py 中散写,如果你做的是插件包,这个函数就写在 hooks.py 模块里。pytest_addoption 的触发时机比较早,在配置加载之前,所以你可以在 pytest_configure 中安全地读取它:
def pytest_configure(config): env = config.getoption("--env", default="dev") fw_config = FrameworkConfig(env) config.fw_config = fw_config把 fw_config 挂到 config 对象上,是一种非常常见的注入手法。为什么挂到 config 而不是全局变量?因为 config 是 Pytest 在测试会话期间传递的上下文对象,fixture 和 hook 都能访问,用它的属性传递状态既安全又规范。
3.4 把配置暴露给用例:fixture 桥接
配置对象挂到了 config 上,但用例代码不能直接访问 config 对象——除非你通过 request fixture 间接拿。为了使用友好,再定义一个 fixture 做桥接:
@pytest.fixture def fw_config(request): return request.config.fw_config用例的用法变得非常干净:
def test_login(fw_config): base_url = fw_config.get("base_url") # ...这个桥接是必要的,它让用例层和框架层解耦。以后配置来源从环境变量变成配置中心,只需要改 FrameworkConfig 内部实现,用例代码一行都不用变。
3.5 数据驱动引擎:从参数化到数据文件驱动
Pytest 自带的parametrize已经能解决一部分参数化需求,但真实业务场景下,测试数据往往由业务同学维护,他们不愿意改 Python 代码。所以框架里要做一个数据文件驱动的能力。
先写一个数据加载器:
import os import yaml def load_yaml_data(data_file: str) -> list: if not os.path.exists(data_file): return [] with open(data_file, "r", encoding="utf-8") as f: return yaml.safe_load(f)再通过装饰器把数据和用例绑在一起:
import pytest def data_driver(file_path: str = None): """ 从 YAML 文件加载测试数据并参数化。 不传 file_path 时,自动查找与用例函数同名的 .yaml 文件。 """ def decorator(func): data_file = file_path or os.path.join("testdata", f"{func.__name__}.yaml") data = load_yaml_data(data_file) if not data: return func params = [] ids = [] for item in data: params.append(item) ids.append(item.get("case_id", str(len(params) + 1))) return pytest.mark.parametrize("case_data", params, ids=ids)(func) return decorator用例侧变成这样:
@data_driver() def test_create_order(case_data): user = case_data["user"] amount = case_data["amount"] # 业务逻辑YAML 数据文件长这样:
- case_id: create_order_success user: "alice" amount: 100 expected_code: 200 - case_id: create_order_wrong_amount user: "bob" amount: -1 expected_code: 400这种数据驱动和 Pytest 原生 paramtrize 的本质区别在于:数据不再以 Python 对象形式存在,而是以数据文件形式存在,业务同学可以直接维护,不需要理解代码,也不需要了解 Python 的运行逻辑。这就是框架层考虑和业务层考虑的分道点。
4. 实操过程:一个接口自动化测试框架底座的完整落地
脚手架搭建完,这一章我演示怎么把前面这些能力串起来,形成一个真正能用的接口自动化测试框架底座。这里我会走完三个核心环节:环境切换、结果收集、报告与通知。
4.1 环境切换的实际运行效果
假设团队有 dev、staging、prod 三个环境,配置文件分别是config.dev.yaml、config.staging.yaml、config.prod.yaml。每个文件内容大致是:
base_url: "http://staging.example.com" timeout: 15 retry_count: 1日常开发跑本地:
pytest --env=dev tests/在 CI 里跑预发:
pytest --env=staging --html=report.html需要临时覆盖某个配置,但又不想改任何文件,可以在流水线里设置环境变量:
export FW_BASE_URL="http://preview.internal.example.com" pytest --env=staging由于配置加载优先级是命令参数 > 环境变量 > 配置文件,环境变量会覆盖配置文件的值。这个机制在应对“线上问题复现”时非常有用,不需要改代码,不需要改文件,就能把请求打到指定域名。
4.2 hook 组合拳:结果收集与耗时统计
现在最核心的环节:怎么把每个用例的执行结果收集起来。
我前面已经引入了 hookwrapper 概念,这里看一个完整实现:
import time import json import pytest class ResultCollector: def __init__(self): self.results = [] def add_result(self, node_id, status, duration, exception_msg): self.results.append({ "node_id": node_id, "status": status, "duration": duration, "exception_msg": exception_msg, }) def summary(self): total = len(self.results) passed = sum(1 for r in self.results if r["status"] == "passed") failed = sum(1 for r in self.results if r["status"] == "failed") skipped = sum(1 for r in self.results if r["status"] == "skipped") pass_rate = round(passed / total * 100, 2) if total else 0 return { "total": total, "passed": passed, "failed": failed, "skipped": skipped, "pass_rate": pass_rate, "duration_avg": round(sum(r["duration"] for r in self.results) / total, 3) if total else 0, } collector = ResultCollector() @pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(item): start = time.time() yield duration = time.time() - start item.stash["duration"] = round(duration, 4) @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call": collector.add_result( node_id=report.nodeid, status=report.outcome, duration=item.stash.get("duration", 0), exception_msg=str(report.longrepr) if report.failed else "", )这里的关键点是时机:
pytest_runtest_call的 hookwrapper 在用例主体执行前后各有一段代码,适合统计耗时pytest_runtest_makereport也只能用 hookwrapper,因为你要拿到 report 对象,需要 yield 之后从 outcome 里取
为什么结果收集要放在 makereport 而不是简单的 finalizer?因为 makereport 能拿到所有维度的执行结果——setup 阶段失败的、call 阶段失败的、teardown 阶段失败的,都能有对应的 report 数据结构。如果想在报告里区分“执行失败”和“初始化失败”,这里不区分就没法准确呈现。
4.3 定制化报告输出
框架底座不能只依赖 pytest-html,因为领导要看的数据不一定和 HTML 报告里的一样。最常见的需求是:生成一个趋势可追踪的 JSON 报告,方便后续做数据可视化。
在 pytest_sessionfinish 里汇总输出:
def pytest_sessionfinish(session, exitstatus): summary = collector.summary() output_file = session.config.fw_config.get("report_output", "report_summary.json") with open(output_file, "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) # 这里还可以调用通知接口,比如企业微信机器人 notify_webhook(summary) # 伪代码如果你想给使用者保留更多信息,可以不止输出 summary,而是把 collector.results 完整写到 JSON,后续数据分析团队可以用 pandas 直接读。我自己惯用的做法是同时生成两个文件:一个精简 summary 给人看,一个完整 results 给机器分析。
4.4 集成失败自动重跑与外部通知
失败重跑是接口自动化框架里非常实用的能力。虽然 pytest-rerunfailures 插件很好用,但自己搭框架时,最好把这个能力封装进框架本身,这样你可以统一控制重跑次数和策略。
我的实现思路是:
@pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: max_retries = item.config.fw_config.get("retry_count", 0) attempt = 0 while attempt < max_retries: attempt += 1 # 重新执行当前用例的 call 阶段 rerun_call = item.runtest() if not rerun_call.excinfo: report.outcome = "passed" report.longrepr = None break严格来说,自己写 rerun 逻辑要处理很多细节,不如直接调用 pytest-rerunfailures 的底层 API。这里展示代码是为了让你理解它内部并不神秘,本质就是重新调用 item.runtest(),然后修改 report 的状态。
外部通知的接入点也是 makereport 或者 sessionfinish。我的习惯是:把通知逻辑和报告逻辑分开,通知走独立的 service 类,报告走 collector,避免一个类里面堆太多职责。
4.5 框架自测与示例用例
框架开发完,要保证后续迭代不把已有功能改坏。我给框架本身写测试用例:
def test_config_default(env: str = "dev"): fw = FrameworkConfig(env) assert fw.get("base_url") == "http://localhost:8080" def test_config_env_override(monkeypatch): monkeypatch.setenv("FW_BASE_URL", "http://override.example.com") fw = FrameworkConfig("dev") assert fw.get("base_url") == "http://override.example.com"这几条用例是框架自己的回归测试,跑框架的测试目录用普通 Pytest 命令就能执行。这个习惯是我在建第二个框架时开始养成的,现在回头看,节省了大量返工时间。
5. 常见问题与排查技巧实录
从普通 Pytest 使用到框架层开发,这个过程中遇到的坑明显更隐蔽,不打印日志的时候基本无法发现。我把这几年踩过的坑整理成一份速查表,按频率排序。
| 问题 | 一句话原因 | 排查方法 |
|---|---|---|
| hook 函数不生效 | 函数名拼写错误,Pytest 静默忽略 | pytest --trace-config检查加载情况 |
| fixture 不生效 | 作用域或文件名拼写有误 | pytest --fixtures查看已注册 fixture |
| session fixture 被修改 | 可变对象在用例间共享 | 改为只读或 function 作用域 |
| 多个插件 hook 冲突 | 同时注册了同一个 hook | 用tryfirst/trylast控制顺序 |
| 自定义参数报错 | 参数未在 pytest_addoption 中注册 | 检查注册函数与使用函数的顺序 |
| 用例执行顺序不符合预期 | Pytest 按照文件内定义顺序,不是字典序 | 在 collection_finish 中自定义排序 |
| 报告输出为空 | result 数据未被正确收集 | 检查 hookwrapper 的 yield 时机 |
5.1 hook 函数不生效,还没有任何报错
这个问题我提过多次,但因为太常见,值得单独列一节。Pytest 插件机制有一个默认行为:它只加载它“认得”的 hook 函数。如果你的函数名不在 Pytest 的 hookspec 注册表里,Pytest 会将其视为一个普通函数,不会调用,也不会报错。
我见过一个真实的案例:同事写的 hook 叫pytest_runtest_makereport因为一个字符拼错写成了pytest_runtest_markreport,整个跑了两个小时,报告里全是空的。排查到最后是这个拼写错误。所以我的建议是:在 hook 函数第一行加个 print 或者 logger.debug,跑通了再删,否则就别删,留着当调试开关。
5.2 session fixture 引发的数据污染
这是一个非常经典的坑。我前面已经用一个 token 的例子讲过了,这里再补充一种调试思路。
如果怀疑 session fixture 导致数据污染,可以在每个用例开始时打印该 fixture 对象的内存地址,或者记录对象的哈希值。如果地址完全相同,且对象是可变类型,那基本可以断定是共享状态被修改。
一个更稳的方案:外部传入的数据一律深拷贝后再交给用例。Python 里用copy.deepcopy或者dict(item)等方式,创建独立副本,让用例之间彻底隔离。代价是内存占用稍高,但对测试场景完全可以接受。
5.3 多插件冲突时怎么控制优先级
你引入了 pytest-html、pytest-rerunfailures,还写了自研插件,三个插件可能都注册了同一个 hook。Pytest 对同一 hook 的多个实现默认按照插件加载顺序调用,但你可以用@pytest.hookimpl(tryfirst=True)或@pytest.hookimpl(trylast=True)来指定优先级。
举个实际例子,pytest-html 的报告逻辑在 pytest_sessionfinish 中输出报告文件。如果你也在该 hook 中改写了报告数据,可能覆盖它或者被它覆盖。我当时的问题是我需要在 pytest-html 生成报告之前,把自定义结果写入报告文件。解决办法是给自研插件的实现加trylast=False(默认),同时用 hookwrapper 包一层,确保自研逻辑在 pytest-html 逻辑之前执行。另外,插件的加载顺序可以通过-p参数控制,比如-p my_framework.hooks -p pytest_html,前者先加载,其 hook 优先级更高。
5.4 排查效率提升:日志要刻意做分级
二次开发中最大的时间黑洞是“不知道代码有没有走到”。所以框架从一开始就应该有清晰的日志策略。核心 hook 都加 debug 日志,默认不输出,调试时用-s -o log_cli=true开启。
我惯用的写法:
import logging logger = logging.getLogger("my_framework") @pytest.hookimpl() def pytest_configure(config): logger.debug("pytest_configure: env=%s", config.getoption("--env", "dev"))跑的时候:
pytest -s --log-cli-level=DEBUG tests/这样 Pytest 运行到哪个阶段,框架的哪个 hook 被触发,一目了然。别嫌麻烦,等你排查过一次静默失效的问题,你就会把这条写进框架规范里。
5.5 关于 fixture 命名冲突
当团队规模变大,不同人可能在不同 conftest.py 里定义了同名 fixture,导致覆盖。Pytest 的 fixture 解析遵循“最近的 conftest 优先”,但如果两个 conftest 在同一目录层级,就存在不确定行为。
我的处理策略是:框架提供的公共 fixture 统一加“fw_”前缀,比如 fw_config、fw_client,业务层自定义 fixture 不使用该前缀。这样从命名上规避冲突,也方便代码 review 时一眼识别哪些是框架能力、哪些是业务能力。
结尾
最后说点个人感受。做 Pytest 二次开发,真正难的不是技术,而是身份转换——你不再是一个只对着某个被测系统写用例的人,而是变成了一个“为写用例的人设计工具”的人。这两种思维方式需要的抽象能力完全不同。
我当初第一次做框架时,犯了所有新人都会犯的错:一开始就想着做大而全的平台,配置中心、用例管理、多环境支持、分布式执行全部塞进来,结果两周过去了,连环境切换都还没跑通。后来我把目标缩小,先只做一个功能:让 Pytest 在跑完用例之后自动生成一份 JSON 报告,并推送到企业微信。这个功能很小,但从设计 hook、接入插件、处理结果到通知,整条链路完整走了一遍。做完之后,对二次开发的理解一下子通透了。
如果你也想入这个门,我建议按同样的路径来:先不加任何高深的技术,只挑一个你当前最头疼的痛点,比如“切换环境要改代码”或者“报告看了没结论”,然后用 Pytest 的 hook 机制解决它。把这条链路走通,你就能摸清框架运行的脉搏。到那时候,你再回头看之前写的那些“封装”,视角会完全不同。