
先说清楚这篇不是入门篇。标题写了“续集”那就意味着默认你已经知道 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, jsonpayload)一旦登录接口的鉴权方式从 header 里带 token 改成走 cookie你就要把所有用例翻出来逐个改。统一封装一层 ApiClient 之后鉴权、日志、超时、重试这些横切逻辑都只需要改一处。1.2 pytest.ini 是整套配置的中枢不是摆设紧接着要解决的问题是pytest 一跑起来参数到底从哪里给你当然可以在命令行后面缀上一长串参数但每个接手项目的人都要去翻文档才知道该跑什么。更合理的做法是把公共默认值写进 pytest.ini让项目自解释。我项目里的 pytest.ini 大概是这样的[pytest] addopts -v -s --maxfail5 --tbshort 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 捕获机制屏蔽。--tbshort是让 traceback 更精简。接口测试失败的根因大多数在某个断言条件没满足而不是代码逻辑冒出来一个深层异常没必要展示一大堆调用堆栈。--maxfail5适合本地调试跑全量回归的时候如果连续挂掉 5 个用例脚本会提前终止避免浪费一整个晚上的时间在无用请求上。markers这一段在 pytest 7 之后特别重要。如果你不在 ini 里注册自定义标记每条带pytest.mark.smoke的用例执行时都会冒出一个 warning。注册之后你才能在命令行里按标记筛选跑批比如pytest -m smoke and not slow。配置优先级我再多啰嗦一句如果同级别存在 tox.ini、setup.cfg、pyproject.tomlpytest 会尝试从这些文件里读 [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(scopesession) def env(pytestconfig): return pytestconfig.getoption(--env, defaultdev) pytest.fixture(scopesession) def config(env): return load_config(env) pytest.fixture(scopesession) def base_url(config): return config[base_url] pytest.fixture(scopesession) 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]}, timeout10, ) assert resp.status_code 200, f登录失败响应{resp.text} return resp.json()[data][token] pytest.fixture(scopesession) def api_client(base_url, admin_token): client ApiClient( base_url, headers{Authorization: fBearer {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 参数化不够用怎么办把用例数据挪到 YAMLpytest.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, encodingutf-8) as f: raw_data yaml.safe_load(f) cases [] for item in raw_data[cases]: cases.append( pytest.param( item[payload], item[expected], iditem[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, jsonpayload) 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烦死了。正确做法是让环境名成为一个可选项跑测试时用--envtest来切换。在 conftest.py 顶部写一个 hook# conftest.py def pytest_addoption(parser): parser.addoption(--env, actionstore, defaultdev, help指定运行环境dev/test/prod默认 dev)然后在 fixture 里通过 pytestconfig 或 request.config 读取这个参数。前面第 2 节的 env fixture 已经展示过了。这里补充一个细节如果你希望命令行参数的优先级高于配置文件还可以再加一个--host参数当指定 host 时直接覆盖 config 文件里的 base_url。这样既能满足大多数情况用配置文件跑又能应付某个同事临时想对着一台特殊环境调试的情况。pytest.fixture(scopesession) def base_url(pytestconfig, config): custom_host pytestconfig.getoption(--host, defaultNone) 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