pytest接口自动化工程化实战:conftest管理、token共享与CI集成
2026/9/8 17:38:59 网站建设 项目流程

先说清楚,这篇不是入门篇。标题写了“续集”,那就意味着默认你已经知道 pytest 怎么收集用例、怎么做基础断言、怎么用 requests 发请求。如果这些还没搞明白,建议先把基础章节翻出来过一遍再回来。这篇要聊的是当你把接口用例写到几十条、上百条之后,真正会拦住你的那些事:工程目录怎么组织、conftest 怎么放才合理、token 如何跨模块共享、失败用例怎么一眼看出是接口返回问题还是脚本问题、以及怎么把 mock 和 CI 接进来。

我自己的感受是,pytest 框架做接口测试的最大优势不是某个现成功能,而是你几乎能把它做成一套完全贴合自己业务的“测试开发框架”。这种能力是把双刃剑,架子搭得好,后面每加一条用例都是顺手的事;架子搭得乱,用例越多维护成本越高,最后变成跑一次要修半小时脚本。所以这篇我按实际项目落地顺序来讲,每一步都会给你可以直接抄走的代码和配置,并且会解释为什么这样写,而不是把它当成命令行工具用完就扔。

1. 续集开篇前,先把 pytest 项目目录工程化

很多人的接口测试项目一开始就是一个 test_login.py,里面塞了三四十个测试函数,requests.post 写得到处都是。这在前一百条用例时还能忍,一旦你开始维护第二个项目、第三个项目,或者要在 CI 上跑回归,你就会发现所有东西都耦合在一起,改一个公共请求头要全局搜索替换。做接口测试的第一件事,不是急着写用例,而是把目录结构定下来。

1.1 目录分层这样安排,后面维护才不痛

我目前比较推荐的基础骨架是这样:

api_test/ ├── pytest.ini # pytest 总入口配置 ├── requirements.txt ├── common/ │ ├── __init__.py │ ├── api_client.py # 统一 HTTP 请求封装 │ ├── logger.py # 日志封装 │ ├── assert_utils.py # 断言工具 │ └── data_loader.py # 从 YAML/JSON 读取用例参数 ├── config/ │ ├── __init__.py │ ├── dev.yaml │ ├── test.yaml │ └── prod.yaml ├── data/ │ ├── login_cases.yaml │ ├── user_cases.yaml │ └── order_cases.yaml ├── testcase/ │ ├── __init__.py │ ├── conftest.py │ ├── test_login.py │ ├── test_user.py │ └── order/ │ ├── conftest.py │ └── test_create.py └── reports/ ├── allure-results/ └── allure-report/

这套结构的分层原则很清楚:common 里放的是所有用例都能复用的能力,比如统一发请求的 ApiClient,日志实例,通用断言;config 放不同环境的 baseUrl、账号信息;data 放用例的入参和预期结果;testcase 下面按业务模块继续拆目录,每个业务包可以有自己的 conftest.py。

有一点非常重要:不要把 requests 直接散落在测试用例里。我见过太多项目,每个测试函数里都来一句requests.post(url, json=payload),一旦登录接口的鉴权方式从 header 里带 token 改成走 cookie,你就要把所有用例翻出来逐个改。统一封装一层 ApiClient 之后,鉴权、日志、超时、重试这些横切逻辑都只需要改一处。

1.2 pytest.ini 是整套配置的中枢,不是摆设

紧接着要解决的问题是:pytest 一跑起来,参数到底从哪里给?你当然可以在命令行后面缀上一长串参数,但每个接手项目的人都要去翻文档才知道该跑什么。更合理的做法是把公共默认值写进 pytest.ini,让项目自解释。

我项目里的 pytest.ini 大概是这样的:

[pytest] addopts = -v -s --maxfail=5 --tb=short testpaths = ./testcase python_files = test_*.py python_classes = Test* python_functions = test_* markers = smoke: 冒烟用例,覆盖主链路 p0: 核心业务用例,阻塞发版 p1: 重要业务用例 slow: 耗时较长的用例 log_cli = true log_cli_level = INFO log_cli_date_format = %Y-%m-%d %H:%M:%S log_cli_format = %(asctime)s %(levelname)s %(message)s

这里的几个点解释一下。

-s是最容易被人忽略的选项。pytest 默认会捕获 print 输出,只有断言失败时才显示相关输出。但接口测试里你经常需要在调试阶段打印请求体、响应体,如果没有 -s,这些打印会被吞掉,查问题会特别吃力。当然你如果已经在用 loguru 或 logging,一样要确保日志能输出到终端,-s能保证不被 pytest 捕获机制屏蔽。

--tb=short是让 traceback 更精简。接口测试失败的根因大多数在某个断言条件没满足,而不是代码逻辑冒出来一个深层异常,没必要展示一大堆调用堆栈。--maxfail=5适合本地调试,跑全量回归的时候如果连续挂掉 5 个用例,脚本会提前终止,避免浪费一整个晚上的时间在无用请求上。

markers这一段在 pytest 7 之后特别重要。如果你不在 ini 里注册自定义标记,每条带@pytest.mark.smoke的用例执行时都会冒出一个 warning。注册之后,你才能在命令行里按标记筛选跑批,比如pytest -m "smoke and not slow"

配置优先级我再多啰嗦一句:如果同级别存在 tox.ini、setup.cfg、pyproject.toml,pytest 会尝试从这些文件里读 [tool:pytest] 或 [pytest] 段,但 pytest.ini 的优先级最高。为了让新人不迷惑,项目根目录只保留 pytest.ini 就够了,配置文件别留一堆。

2. conftest 与 fixture 才是接口自动化的灵魂

讲完工程结构,接着是整套框架里最容易写丑、也最能看出设计水平的部分:fixture。我发现很多新手写接口自动化时,一个 fixture 搞不定就再写一个,所有逻辑堆在顶层 conftest.py 里,最后导致很多业务模块互相感知对方不应该知道的东西。fixture 用好了能大幅减少重复代码,用不好就是一座难以维护的屎山。

2.1 conftest.py 放的位置决定了 fixture 的可见范围

conftest.py 对 fixture 的作用域影响是很多人一开始没意识到的。根目录下的 conftest.py 定义的 fixture 对整个 testcase 下的所有用例可见;某个业务子目录下的 conftest.py 定义的 fixture,只对该子目录下的用例可见。pytest 在收集用例时,会从用例所在目录逐级向上寻找 conftest.py。

这里有两个实操原则可以分享。

第一个原则:公共基建放顶层 conftest。比如统一读取环境变量的 fixture、日志相关的 fixture、以及最核心的 api_client,这些几乎每个用例都需要,放在根目录下最合适。

第二个原则:业务专属数据准备逻辑放在业务包自己的 conftest 里。比如 order 目录下的用例需要提前创建订单、需要跟专用的订单测试数据打交道,那就在testcase/order/conftest.py里定义 order 相关的 fixture。这样做的好处非常直接:当你维护到 user 模块时,根本不会看到一堆 order 相关的 fixture 干扰视线,pytest 的收集速度也会更快,依赖关系更清晰。

如果某个子目录下的 fixture 在另一个子目录也要用,那说明这个 fixture 的定位不是“业务专属”,而是“公共能力”,应该提升到顶层 conftest 或 common 模块里去,而不是用 import 强行拉过来。

2.2 token 与登录态共享,别在用例里到处请求登录

接口测试里最高频的一件事就是登录,拿 token,然后带着 token 请求业务接口。很多人的写法是在每个测试函数前面加一个 login 函数,每个用例都调一次登录接口。单个用例还好,一旦用例变成几百条,每次都走一次完整登录流程,整个回归时间会被白白拉长好几倍。

正解是把登录收敛成一个 session 级 fixture,整个 pytest 进程里只真正登录一次。

import pytest import requests from common.api_client import ApiClient from common.config_loader import load_config @pytest.fixture(scope="session") def env(pytestconfig): return pytestconfig.getoption("--env", default="dev") @pytest.fixture(scope="session") def config(env): return load_config(env) @pytest.fixture(scope="session") def base_url(config): return config["base_url"] @pytest.fixture(scope="session") def admin_token(base_url, config): account = config["login_account"] resp = requests.post( f"{base_url}/auth/login", json={"username": account["username"], "password": account["password"]}, timeout=10, ) assert resp.status_code == 200, f"登录失败,响应:{resp.text}" return resp.json()["data"]["token"] @pytest.fixture(scope="session") def api_client(base_url, admin_token): client = ApiClient( base_url, headers={"Authorization": f"Bearer {admin_token}"}, ) yield client client.close()

这套写法的关键在 ApiClient。它内部持有一个 requests.Session,所有请求都从同一个 Session 发出,好处是可以复用底层 TCP 连接,请求性能会比每次新建连接高不少。而且我们把鉴权 header 统一放进 Session 后,每个测试用例根本不需要关心 token 怎么传,写用例的人只需要专注于业务参数和断言。

这中间有几个容易踩的坑。

第一个坑是 token 过期。如果你的被测服务 token 有效期只有 30 分钟,而回归用例要跑一小时,那后 30 分钟的用例可能集体 401。我处理这个问题的方式,是在 ApiClient 内部加一个“401 后自动重试一次”的逻辑:发现响应是 401 时,重新调用登录接口刷新 token,更新 Session header,然后重放当前请求。这里要注意并发场景下不能无脑刷新,否则多个线程同时 401 会刷出多个新 token,后续请求可能互相覆盖。最简单的做法是加一个线程锁,保证同一时间只有一个线程在刷新 token。

第二个坑是不要把账号密码硬编码在 conftest 里。我见过有人搞了一个admin_tokenfixture,密码直接写在代码里,然后整个仓库推到代码托管平台,这等于把测试环境的管理员账号公开了。账号密码应该从 config 文件或环境变量读取,config 文件也要用 .gitignore 排除掉真实密码,仓库里只保留脱敏的模板。

2.3 参数化不够用怎么办:把用例数据挪到 YAML

@pytest.mark.parametrize是 pytest 最基础的参数化手段,但它有个显而易见的问题:用例数据和测试代码耦合在一起。每次想加一个测试场景,都得去改 Python 文件、改代码、再 review。对于接口测试这种“用例数据经常被产品和测试同学一起 review”的场景,最好把数据和代码拆开。

我比较常用的方案是把用例数据放到 YAML 文件里,然后用一个 loader 读出来,转成 parametrize 需要的结构。

# data/login_cases.yaml cases: - name: 正常登录 payload: username: admin password: "123456" expected: code: 0 data: token: not_empty - name: 密码错误 payload: username: admin password: "wrong" expected: code: 1001 msg: 用户名或密码错误

对应的 loader 可以这样写:

# common/data_loader.py import yaml import pytest def load_cases(path): with open(path, "r", encoding="utf-8") as f: raw_data = yaml.safe_load(f) cases = [] for item in raw_data["cases"]: cases.append( pytest.param( item["payload"], item["expected"], id=item["name"], ) ) return cases

在测试文件里,只需要这么用:

import pytest from common.data_loader import load_cases class TestLogin: @pytest.mark.parametrize("payload, expected", load_cases("data/login_cases.yaml")) def test_login(self, api_client, payload, expected): resp = api_client.request("POST", "/auth/login", json=payload) assert resp.status_code == 200 body = resp.json() if expected.get("code") is not None: assert body["code"] == expected["code"]

数据从 YAML 里剥离之后,产品或者测试同事想加一条“用户名不存在”的用例,只需要在 YAML 里复制一段修改数据,不需要动代码。跑出来的报告里,每条用例的 id 会直接显示“正常登录”“密码错误”,一眼就能看出是哪个场景挂了。

如果你的用例数据有层级关系,或者你想动态拼接多条用例,pytest_generate_tests是更强的方案。它允许你在收集阶段动态生成参数,比如从数据库查出一批用户 ID,然后为每一个 ID 生成一条测试用例。这个钩子写起来比 parametrize 稍微复杂一点,但遇到“用例数量取决于线上数据”的场景时就非常好用。

3. 让 pytest“听话”:hook 扩展与报告集成

我经常跟团队里同学说,pytest 比很多工具强的地方在于,它的执行流程并不是一个黑盒,而是暴露了很多 hook 点让你插手。比如你想在命令行里增加一个--env参数,想在用例失败时自动把请求日志打出来,想在报告里按模块分组展示,这些都靠 hook 机制实现。

3.1 pytest_addoption:把环境参数变成命令行选项

接口测试一定绕不开多环境问题:开发环境、测试环境、预发布环境。如果不做参数化,你就得在代码里不断改 base_url,烦死了。正确做法是让环境名成为一个可选项,跑测试时用--env=test来切换。

在 conftest.py 顶部写一个 hook:

# conftest.py def pytest_addoption(parser): parser.addoption("--env", action="store", default="dev", help="指定运行环境:dev/test/prod,默认 dev")

然后在 fixture 里通过 pytestconfig 或 request.config 读取这个参数。前面第 2 节的 env fixture 已经展示过了。这里补充一个细节:如果你希望命令行参数的优先级高于配置文件,还可以再加一个--host参数,当指定 host 时直接覆盖 config 文件里的 base_url。这样既能满足大多数情况用配置文件跑,又能应付某个同事临时想对着一台特殊环境调试的情况。

@pytest.fixture(scope="session") def base_url(pytestconfig, config): custom_host = pytestconfig.getoption("--host", default=None) if custom_host: return custom_host.rstrip("/") return config["base_url"]

有一点要注意,pytest_addoption 里定义的参数不是“默认就能被所有 fixture 直接看到”,而是通过 request.config 或 pytestconfig 去拿。如果你在某个测试函数里也想读这个选项,可以直接把 pytestconfig 作为 fixture 参数传进去,这个内置 fixture 不需要你额外声明,用起来很方便。

3.2 失败用例自动打印请求日志,告别大海捞针

我见过太多接口自动化跑挂之后的场景:测试报告里只有一句AssertionError: assert 500 == 200,既看不到请求 URL,也看不到返回体,排障的人只能重新打开 fiddler 或者手工复制请求再发一次,效率非常低。

要让失败的用例自动展示它自己发出的请求和响应,可以利用 pytest 的pytest_runtest_makereport这个 hook。它会为测试的每个阶段生成一个 report,我们在 report 标记为 failed 的时候,把当前测试挂载的请求日志全部打到终端。

前置条件是 ApiClient 要把每次请求都记录下来:

# common/api_client.py import

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询