
1. 写在前面这篇续集要解决的是能跑到能扛的距离看到标题里那个俳字我猜多半是篇的手滑先帮你对齐一下预期这篇博客不是pytest入门的翻来覆去而是接口测试框架做到中后期、用例开始堆积、维护成本肉眼可见往上涨时你真正需要的那部分内容。简单说就是让测试框架从本地能跑通进化成团队能长期用、改起来不骂娘的状态。如果你正在用pytest写接口自动化而且已经遇到过下面这些情况那这篇就是写给你的用例多了之后每个模块都要去拿token、建数据、做清理代码复制粘贴到处都是数据驱动只会把参数写在pytest.mark.parametrize里一旦要改几十条用例数据就得动代码文件不知道fixture的session、module、function作用域到底该怎么分更不知道什么时候该用yield断言只会assert resp.status_code 200嵌套十几层的json响应要用一长串[data][list][0][name]去取想加mock又不知道该用unittest.mock还是自己起服务最后还要把整套东西丢到Jenkins或流水线里跑却发现一换环境就一堆报错。这六个问题基本对应着接口测试框架进阶的六道坎。下面我按自己的实战顺序一个个拆开讲每个环节都给具体的代码和踩坑记录不整虚的。先说为什么是pytest而不是Postman或JMeter。做接口测试工具型的方案很多很多人会拿Apifox、Postman、JMeter来说我们也是接口自动化。我不否定工具的价值但当你需要处理复杂的业务依赖、灵活的组装参数、和代码项目共用同一套环境配置的时候pytest这种代码型框架优势就出来了。用一个表格放在这里方便你还是团队决策时做对比对比维度Postman/JMeter/Apifoxpytest requests上手门槛低点鼠标就行中需要写代码数据驱动有限文件数据还要配合脚本非常灵活yaml/json/db/excel都行断言能力基础断言够用可以无限扩展结合jsonpath、jsonschema都可以与CI/CD集成有命令行方案但依赖工具环境pip install后一条命令能跑天然亲和团队协作靠导出的文件共享diff能力弱一个git仓库搞定处理复杂业务链路勉强但代码越写越别扭本身就是代码逻辑随便写一句话工具型方案适合少、快、临时pytest这类代码框架适合多、稳、长期。接口自动化一旦超过两三百条用例靠工具维护就是给自己挖坑。运行入口上我建议从第一天开始就用pytest.ini把行为和路径钉死。很多团队一上来就依赖命令行参数时间长了不知道哪些参数是谁加的。我的做法是pytest.ini里至少配置这么几项[pytest] testpaths testcase addopts -ra --strict-markers markers smoke: 冒烟用例 core: 核心链路用例testpaths固定用例目录--strict-markers是常用的防呆手段写了没注册的标签会直接报错而不是静默忽略这在用例多了之后能救你一次。元凶场景很典型某天有人打了pytest.mark.p0但pytest.ini里没注册这个标签静默失效结果流水线上红色的人崩溃了半小时没人管。--strict-markers一开这种问题直接暴露。2. fixture的正确打开方式2.1 作用域选不对测试全白费fixture是pytest的灵魂但很多人用了半年还在只用function作用域token每次都重新调登录接口拿一次全量跑下来光登录就占了一半时间。先记住一个原则能复用的绝不重复执行有状态的绝不静态共享。session级用来初始化全局只有一份的东西比如token、数据库连接、日志对象module级用来准备整个模块共用的数据比如某个测试模块固定的订单class级类似module但属于类function级是默认的每个用例前都执行一遍适合必须隔离的数据。package级和动态作用域在生产中很少用知道存在就行。我来举个实际例子。假设被测服务是登录后带token去操作订单token本身是全局有效的那token就应该放session级# conftest.py import pytest import requests pytest.fixture(scopesession) def global_token(): auth_resp requests.post( https://api.example.com/auth/login, json{username: tester, password: secret}, timeout5, ) assert auth_resp.status_code 200, 登录接口不通后面全别跑了 token auth_resp.json()[data][token] print(f\n[setup] 获取全局token: {token[:8]}...) yield token print(\n[teardown] session结束这里可以做token销毁等动作)这个fixture只执行一次后续所有用例拿到的都是同一个token。如果你把它写成function级每次用例都登录接口慢的时候一次全量跑直接超时给你看。yield前后分别是setup和teardown逻辑这个语法在需要清理资源的场景里是关键。2.2 yield式fixture与资源清理接口测试的fixture不只是拿个token那么简单经常还要造数据、删数据。比如创建订单的用例如果前置条件是必须有一个商品在售那你要在setup里调商品接口创建用完之后再调删除接口清理。这个场景yield是标准解法pytest.fixture def created_product(global_token): create_resp requests.post( https://api.example.com/product/create, headers{Authorization: fBearer {global_token}}, json{name: test_product, price: 9.9}, timeout5, ) assert create_resp.status_code 200 product_id create_resp.json()[data][product_id] print(f[setup] 创建商品: {product_id}) yield product_id del_resp requests.post( https://api.example.com/product/delete, headers{Authorization: fBearer {global_token}}, json{product_id: product_id}, timeout5, ) print(f[teardown] 删除商品: {product_id}, 状态: {del_resp.status_code})这里有个非常容易踩的坑如果create_resp断言失败fixture会直接抛异常下面的yield不会执行teardown自然也不会跑。这其实是合理的——前置都没准备好还清理什么但如果你的清理逻辑必须执行比如创建了一个只给自己用的临时测试账号那就要用try/finally包起来或者把清理逻辑放到独立的finally里。我自己的习惯是业务数据用yield写法只要生产环境别执行这类用例就行但如果是会污染环境的强清理数据我会单独写一个cleanup的fixture用finally确保case失败也不留垃圾。2.3 conftest.py的分层别什么都往根目录放很多项目的conftest.py会越写越长最后几千行功能和模块全混在一起。正确做法是分层管理project_root/ │ ├── conftest.py # 全局级别token、日志、公共请求session ├── pytest.ini ├── config/ │ └── settings.yaml │ ├── common/ │ ├── __init__.py │ ├── http_client.py # 请求封装 │ ├── assert_utils.py # 断言封装 │ └── logger.py │ ├── data/ │ ├── test_login.yaml │ └── test_order.yaml │ └── testcase/ ├── test_login.py ├── test_order.py │ ├── order/ # 子模块如果有独立前置 │ ├── __init__.py │ ├── conftest.py # 模块级订单数据准备 │ └── test_refund.py └── payment/ ├── __init__.py ├── conftest.py └── test_pay.py根目录conftest只放session级的东西子模块的conftest只放本模块的fixture命名也不要用fixture_xxx这种没信息量的名字。特别提醒一句conftest.py里的fixture作用范围是当前目录及其所有子目录不是全局自动生效的。你把订单数据的fixture放在根目录那全项目都能用反而破坏了隔离性放在testcase/order/底下就只对order相关用例可见。这是pytest一个比较绕但很重要的规则我见过不少团队把自己绕进去了。3. 数据驱动参数化不只是parametrize3.1 yaml管数据parametrize消费数据pytest.mark.parametrize是pytest内置的参数化利器但如果参数数据直接写在代码里每次加数据都要改动代码很快你就会发现改动的成本越来越高。我的做法是测试代码只负责逻辑测试数据全部外置成yaml文件。比如登录接口的用例我在data/test_login.yaml里维护一组数据login_success: - case: 正确用户名密码 payload: username: user01 password: pass123456 expect: code: 0 method: 登录成功 login_fail: - case: 密码错误 payload: username: user01 password: wrong_password expect: code: 1001 message: 密码错误 - case: 用户不存在 payload: username: not_exist_user password: pass123456 expect: code: 1002 message: 用户不存在然后在测试文件里消费import json import pytest import yaml def load_data(path): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f) login_cases load_data(data/test_login.yaml) pytest.mark.parametrize(case_data, login_cases[login_success]) def test_login_success(case_data): payload case_data[payload] resp requests.post( https://api.example.com/auth/login, jsonpayload, timeout5, ) assert resp.status_code 200 assert resp.json()[code] case_data[expect][code] assert resp.json()[message] case_data[expect][method]这样做的好处很直接业务同事或者新来的同学完全可以不看代码只改yaml就能添加用例。你也不用担心yaml和参数数量对不上加载时做了数据校验结构不对直接报错。注意yaml文件路径别写相对当前工作目录的路径最好用Path(__file__).parent.parent / data这种方式拼接不然从别的目录跑pytest时直接FileNotFoundError。3.2 参数组合与ids可读性数据驱动一旦多了用例名会变成test_login_success[case_data0]、test_login_success[case_data1]这种跑挂了根本看不出是哪条数据。解决方法是ids参数pytest.mark.parametrize( case_data, login_cases[login_fail], idslambda item: item[case], ) def test_login_fail(case_data): ...这样报告里显示的就是test_login_fail[密码错误]和test_login_fail[用户不存在]一眼就知道哪条挂了。如果ids函数对每条数据都要处理也可以直接用列表推导式pytest.mark.parametrize( case_data, login_cases[login_fail], ids[data[case] for data in login_cases[login_fail]], )另外一个常见需求是笛卡尔积组合用两个parametrize叠加就行pytest.mark.parametrize(platform, [android, ios]) pytest.mark.parametrize(role, [admin, normal, guest]) def test_permission(platform, role): ...这个会生成6条用例两个参数互相组合。但组合数量大的时候要谨慎我见过有人用一个3个参数的parametrize各取10个值生成1000条用例结果全量跑了一个多小时。组合前先问自己一句是不是每个组合都有业务意义没有意义就用pytest.skip或者数据筛选过滤掉别让无效用例消耗流水线时间。3.3 hook当你需要pytest在收集完用例后动点手脚pytest的hook机制是很多进阶用户用不上的部分但接口测试场景里有两个hook几乎必用pytest_addoption和pytest_collection_modifyitems。pytest_addoption用来加自己的命令行参数比如切换测试环境。很多团队有dev、test、staging多套环境如果每次靠改配置文件容易误提交也不方便流水线传参。我习惯这么写# conftest.py import pytest def pytest_addoption(parser): parser.addoption( --env, actionstore, defaulttest, choices[dev, test, staging], help切换测试环境: --envdev/test/staging ) pytest.fixture(scopesession) def env(request): return request.config.getoption(--env)然后在测试用例里用env这个fixture读取数据。在执行流水线时可以用--envstaging一键切换。这个方案的思路是环境相关数据域名、账号、配置全部集中在config里用环境名做key避免换环境改代码的坏味道。注意addoption的hook函数名必须叫pytest_addoption不能拼错否则命令行参数静默失效这个坑我踩过一次。pytest_collection_modifyitems的用途之一是处理用例顺序和筛选。pytest默认按照文件名字母序、文件内从上到下执行但接口测试有时希望冒烟用例优先。可以通过修改items列表实现def pytest_collection_modifyitems(session, config, items): # 把标记了smoke的用例放到最前面 items.sort( keylambda item: 0 if item.get_closest_marker(smoke) else 1 )从这里还能延伸出自定义标签过滤的需求比如团队规定接口用例必须带core或smoke标签否则CI不通过你就可以在这个hook里做校验收集完成后发现没有标签的用例直接RuntimeError。我实操过这个方案效果很好相当于把流程约束写进了框架里而不是靠人肉提醒。4. 核心代码架构与请求层封装4.1 请求层用requests.Session搞定复用和统一header接口测试里最常见的陋习是每个用例各自requests.get()、requests.post()header每次都要重新拼。这会造成三个问题token变了到处都要改、超时和重试逻辑没法统一、出错时上下文信息太少。正确做法是封装一个HttpClient核心是复用requests.Sessionimport requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry class HttpClient: def __init__(self, base_url, tokenNone): self.session requests.Session() self.session.headers.update({ Content-Type: application/json, User-Agent: pytest-interface-test/1.0, }) if token: self.session.headers.update({Authorization: fBearer {token}}) retry_strategy Retry( total2, backoff_factor0.5, status_forcelist[500, 502, 503, 504], ) adapter HTTPAdapter(max_retriesretry_strategy) self.session.mount(https://, adapter) self.session.mount(http://, adapter) self.base_url base_url def request(self, method, path, **kwargs): kwargs.setdefault(timeout, 10) url f{self.base_url}{path} resp self.session.request(method, url, **kwargs) return resp def get(self, path, **kwargs): return self.request(GET, path, **kwargs) def post(self, path, **kwargs): return self.request(POST, path, **kwargs)Session会帮你自动管理连接池、cookie和默认header。Retry配置了最多重试2次只重试5xx的服务器错误。这个细节很关键如果不加限制默认requests对超时是不重试的但如果对4xx也重试反而会放大问题——客户端参数错错了重试3次只是浪费时间。那token是怎么注入到这个HttpClient里的和前面的全局token组合起来pytest.fixture(scopesession) def http_client(env, global_token): from config.config import ENV_CONFIG return HttpClient(base_urlENV_CONFIG[env][base_url], tokenglobal_token)这里我又用到了env这个fixture从配置文件里取出对应的base_url。这样环境切换、token获取、session复用就都统一在一个地方管理了。4.2 断言层设计精确匹配还是路径提取接口测试的断言是你对业务的理解沉淀不能只是assert resp.status_code 200。我的建议是把断言拆成三个层次第一层是基础状态码断言确认HTTP层面通第二层是业务code断言确认业务成功或失败第三层是具体数据断言确认关键字段的值符合预期。但响应体通常是个嵌套很深的json比如{ code: 0, message: success, data: { order: { order_id: SO12345, items: [ {sku: A001, price: 19.9, qty: 2}, {sku: A002, price: 29.9, qty: 1} ], total_amount: 69.7 } } }你要取total_amount如果写resp.json()[data][order][total_amount]一方面很长另一方面如果中间某个key拼错了抛KeyError你都不能快速判断到底是接口结构变了还是代码拼错了。我建议用jsonpath-ng来提取维护成本和可读性都更好from jsonpath_ng import parse import requests resp requests.get(https://api.example.com/order/SO12345, timeout5) body resp.json() match parse($.data.order.total_amount).find(body) assert match, 响应中找不到 total_amount 字段 assert match[0].value 69.7用这个写法数据路径和断言逻辑分离接口结构一变你只需要去改jsonpath表达式不会牵连一大堆索引代码。再配合一个简单的断言工具函数def assert_json_path(body, jsonpath, expected_value): matches parse(jsonpath).find(body) assert matches, fJSONPath未命中: {jsonpath} actual matches[0].value assert actual expected_value, fJSONPath {jsonpath} 期望 {expected_value}, 实际 {actual}还有一类需求是我只关心接口返回结构对不对这时可以把jsonschema也引入进来。比如校验一个商品对象必须包含name、price、stock三个字段且price是number类型。接口测试在接口不稳定阶段schema断言比value断言更实用。4.3 日志与报告自记录和pytest-html接口测试调试最烦的问题自动化跑挂了但看不出当时发了什么请求、响应是什么。解决方案是给请求层加上日志记录把请求URL、method、请求体、状态码、响应体输出到日志文件。我在HttpClient里加一个简单的调用记录import logging logger logging.getLogger(http_client) def request(self, method, path, **kwargs): ... resp self.session.request(method, url, **kwargs) logger.info( [HTTP] %s %s - %s | cost%sms | resp%s, method, url, resp.status_code, resp.elapsed.total_seconds() * 1000, resp.text[:200], ) return resp配合conftest里初始化一个FileHandler每次运行自动生成带时间戳的日志文件后续排查问题直接翻日志不用靠现场复现。日志不要乱打关键是包括耗时这样你能看到是不是某个接口突然到了2秒、3秒及时预警。报告方面我推荐先用pytest-html快速落地一条命令就能生成HTML报告pytest testcase/ --htmlreport.html --self-contained-html而且pytest-html会原生展示每条用例的入参和断言失败原因这个信息密度刚好。如果团队有allure服务再考虑接allure它的allure.title、allure.description能给用例加更多语境。注意一点如果用了pytest.assume做软断言pytest-html会把assume的失败信息统计到报告里而普通assert失败会直接中断用例。软断言适合那种同一个响应里我要校验10个字段不希望第一个失败就停下的场景但软断言失败时用例会以失败收尾并不是通过理解清楚之后再决定用不用。5. mock模拟接口测试把外部依赖挡在测试之外5.1 mock的三个典型场景接口测试里的mock不是造假数据骗人而是在测试环境中用可控的替身替代掉不稳定或者不可控的外部依赖。我实际遇到过三种典型场景第一种是第三方接口还没开发完。比如你们测的支付系统对了个短信服务短信服务在联调期经常不稳定但我们的冒烟用例又不关心短信到底发没发出去只关心我们自己的回调逻辑。这时把短信服务mock掉返回固定成功响应用例就能稳定跑了。第二种是依赖的环境数据不可控。比如用例需要用户余额不足这个状态但真实测试环境里很难精确控制用户余额恰好不足。mock掉余额查询接口让它返回余额为0场景可控了测试也稳定了。第三种是模拟异常响应。比如要测试你自己系统对下游超时、5xx的处理逻辑你在真实环境里没法让第三方稳定地返回一个500但mock可以把响应码设成任意值轻松覆盖边界条件。5.2 unittest.mock足够吗先说结论单元测试层面用unittest.mock足够但接口测试层面多数情况需要的是起一个轻量的HTTP mock服务。unittest.mock.patch适合你控制的是Python代码内部的对象比如from unittest.mock import patch patch(common.http_client.requests.post) def test_login_with_mock(mock_post): mock_post.return_value.status_code 200 mock_post.return_value.json.return_value {code: 0, message: success} # 然后是正常测试逻辑但接口测试往往是用一个真实的HTTP客户端去请求一个真实的HTTP服务路径上经过网络栈、证书校验、代理。你用patch去mock掉requests.post只能测试自己封装层的逻辑验证不了整个链路。这和接口测试的目标是背离的。所以我更推荐在接口测试框架里对外部依赖用轻量HTTP mock服务常见的有两种一是Flask快速起一个假服务二是利用pytest-mock配合responses库拦截HTTP请求。5.3 用Flask起一个可控制的mock服务Flask是Python里最轻的Web框架用来做mock服务非常合适。思路是在测试代码里启动一个Flask进程监听本地端口伪装成第三方服务测试完成后关闭。好处是你的服务端代码完全不需要感知到mock的存在它只是HTTP调用了另一个服务而已。来个实际样例模拟第三方短信服务from flask import Flask, jsonify, request app Flask(__name__) app.route(/sms/send, methods[POST]) def send_sms(): data request.get_json() if data[phone] 13800000000: return jsonify({code: 0, message: success}), 200 return jsonify({code: 4001, message: phone blocked}), 200然后在fixture里启动和销毁import threading import pytest from werkzeug.serving import make_server pytest.fixture(scopemodule) def mock_sms_server(): server make_server(127.0.0.1, 5001, app) thread threading.Thread(targetserver.serve_forever, daemonTrue) thread.start() yield http://127.0.0.1:5001 server.shutdown()测试用例里就把下游服务的地址指向这个mock服务。你可能会问那如果被测试系统不支持动态配置下游地址怎么办这就是另一个工程问题了通常被测系统在测试环境都会把第三方地址配到config里你的mock服务地址写进去或者通过环境变量注入别让它写死。如果这个前提都不满足再往下聊就是系统改造成本不是mock本身能解决的。responses库是另一个思路它不需要真的起服务直接拦截requests库的HTTP调用。适合测试的HTTP客户端本身就用requests的场景但被测系统如果用的不是requests库比如Java写的服务那就没法用。这也回到为什么我习惯用Flask起一个真HTTP服务因为它对任何语言实现的下游调用都有效。每条用例能实时控制返回内容可以把响应码改为200、500、超时等组合来测试边界。6. 常见问题与排错实录接口测试框架做到这个阶段下面这些坑不是我一次踩完的但每个都让团队付出过代价。整理成速查表方便你遇到问题直接查问题现象根本原因解决办法明明改了pytest.ini但命令行参数不生效启动时指定了-c另一个配置文件或者当前目录不对在项目根目录执行pytest用pytest -c pytest.ini显式指定用例间执行顺序不稳定测试数据共享前一个用例改了后一个用例依赖的字段用fixture的function级隔离数据不要用全局变量存中间状态登录token过期导致后阶段用例批量失败token有有效期session级fixture只初始化一次增加token自动刷新机制fixture里检测到401就重新登录并更新header半路加入的标签不生效使用了--strict-markers但没在pytest.ini里注册在markers中注册所有自定义标签并发跑用例时资源冲突多个进程同时创建相同测试数据给每条用例数据加随机后缀或引入独立的测试环境断言的异常信息不可读直接assert resp.json()[data][x] 1用jsonpath提取 自定义断言函数失败时打印实际结构有重试但依然一堆5xx报错重试策略里没加status_forcelist或backoff系数太大按Retry手动配置status_forcelist[500,502,503,504]backoff_factor0.3HTML报告里看不到请求日志把日志打到了控制台而不是report插件能捕获的地方配置logging的FileHandler输出到文件pytest-html只展示自身捕获的日志需要配置log_clitrue和log_file相关参数从CI里跑yaml或json路径找不到了工作目录是CI的项目根目录不是测试用例所在目录不要使用相对当前工作目录的路径用Path(__file__).resolve().parent拼接用例集合太大每次全量跑太久没有按场景分层打smoke、core、full标签CI分别跑冒烟和全量这里挑一个最有代表性的说细一点token过期批量失败。我负责的项目里token有效期是2小时。白天全量跑没问题但凌晨的CI任务往往因为token过期而整片飘红。一开始我在conftest里把token写死后来改成fixture获取但过期问题仍然存在。最终方案是在HttpClient里加了一个响应拦截逻辑检测到401时自动重新登录并重放请求class HttpClient: def __init__(self, base_url, login_func): self.session requests.Session() self.base_url base_url self._login_func login_func def request(self, method, path, **kwargs): resp self.session.request(method, f{self.base_url}{path}, **kwargs) if resp.status_code 401: new_token self._login_func() self.session.headers.update({Authorization: fBearer {new_token}}) resp self.session.request(method, f{self.base_url}{path}, **kwargs) return resp思路是遇到401不直接失败而是调用登录函数拿新token更新session header后重放一次请求。只重放一次如果还401就说明不是token过期的问题。这种自愈策略让凌晨CI任务的稳定性从60%提升到95%以上。实现的关键点是登录函数不能是写死的账号密码而是读配置、能鉴权、能返回有效token的独立函数。另一个想起来比较多的坑是pytest和requests库版本兼容问题。有次升级requests之后原来的session.headers里中文直接乱码排查了半小时发现是requests新版本的默认charset策略改了。做法很简单在UI层请求头里不要放中文项目配置里所有中文统一用变量另外把requests版本锁在一个已知稳定版本比如requests2.31.0配合requirements.txt统一管理避免流水线环境重建后版本漂移。还有一点值得单独说mock服务别一启动就bind到固定端口因为CI上并行跑多个任务会端口冲突。我后来都改成启动时绑定0端口让操作系统分配空闲端口server make_server(127.0.0.1, 0, app) # 端口0表示自动分配 port server.server_portfixture里return的时候带上实际的端口地址用例里动态拼接不用写死。这个改动看起来小但能避免很多跨任务互相干扰的问题。到了这个阶段pytest框架的进阶基本就是围绕复用、隔离、可控、可追踪四个词做文章。复用靠fixture和Session隔离靠作用域和测试数据设计可控靠hook和mock可追踪靠日志和报告。把这四个维度理顺接口测试框架才真正称得上是一个基础设施而不是一堆散落在角落里的脚本。