1. 为什么我最终选了PytestRequests这套组合先交代一下背景。我在团队里负责接口层的自动化测试之前用的是Unittest配Requests后来因为用例越写越多、依赖关系越来越复杂整个维护成本已经高到让人不想碰了。换了Pytest之后最大的感受不是“快”而是“顺”——fixture机制天然适合做接口的依赖共享参数化也让数据驱动变得非常直白再加上插件生态里有一堆好用的轮子报告、重试、并发、顺序控制基本不需要自己造基建。Requests这个库就更不用说了。虽然现在有人用httpx、aiohttp但Requests在接口测试里依然是第一选择API设计直观、处理Session和Cookie很方便、支持文件上传和流式响应、底层有urllib3的自动重试机制这个后面会踩坑。更重要的是Pytest和Requests的组合用的人最多网上踩坑帖子几乎覆盖了各种诡异场景出了问题一搜就有答案这在团队协作里是很大的隐形收益。有的同事问过我为什么不用Postman导出的脚本直接转我的回答是Postman适合临时调试不适合做长期的自动化资产。它没法方便地写复杂的断言逻辑也没法和CI/CD流水线搞好关系。而PytestRequests是你自己掌控剧本什么时候发请求、从哪儿取数据、怎么断言、怎么依赖全部代码化后期调试和扩展都好得多。哪怕团队里有新人看几遍测试代码也能明白逻辑。这套组合还有一个容易被忽略的好处它可以轻松扩展到HTTP层的爬虫、接口监控、数据回放不只是做“测试”。团队后来甚至拿同一套框架去跑生产环境的定时巡检任务基本零成本迁移。2. 项目实战第一步搭建一个能跑的接口自动化骨架2.1 目录结构和环境准备先别急着写接口测试用例先把项目骨架搭好。这是我的常用目录结构直接搬过去改改就能用api_test_project/ ├── common/ # 公共工具模块 │ ├── __init__.py │ ├── requests_util.py # 二次封装的请求类 │ ├── logger.py # 日志固定配置 │ ├── config.py # 环境配置读取 │ └── assertions.py # 通用断言封装 ├── testcases/ # 测试用例目录 │ ├── __init__.py │ ├── test_login.py │ ├── test_order.py │ └── test_pay.py ├── conftest.py # Pytest全局fixture ├── data/ # 测试数据文件JSON/YAML ├── reports/ # 报告输出目录 ├── requirements.txt └── pytest.ini环境准备上我强烈建议用虚拟环境避免污染系统Python。执行下面的命令就能装齐所有依赖python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install pytest requests pytest-html pytest-rerunfailurespytest-rerunfailures是后面要用的失败重试插件pytest-html用于生成报告。还有一个常用的pytest-xdist但我一般不加因为接口并发测试容易触发服务端的频率限制这个后面细说。2.2 用类封装Requests请求统一入口管理直接使用requests.get()写脚本能做但项目一复杂就乱。我习惯封装一个RequestsUtil类把公共逻辑收敛到一处日志记录、超时设置、响应的规范化、异常捕获。核心逻辑就这么几条import requests import logging import time logger logging.getLogger(api_tests) class RequestsUtil: def __init__(self, base_url, timeout5, retry_times2): self.session requests.Session() self.base_url base_url self.timeout timeout self.retry_times retry_times def _preprocess(self, url, paramsNone, headersNone, **kwargs): if url.startswith(http): full_url url else: full_url self.base_url url merged_headers {Content-Type: application/json} if headers: merged_headers.update(headers) return full_url, params, merged_headers, kwargs def request(self, method, url, **kwargs): full_url, params, headers, extra self._preprocess(url, **kwargs) for attempt in range(self.retry_times 1): try: response self.session.request(method, full_url, paramsparams, headersheaders, timeoutself.timeout, **extra) logger.info(f[{method.upper()}] {full_url} - {response.status_code} 耗时{response.elapsed.total_seconds():.3f}s) return response except requests.exceptions.Timeout: logger.warning(f[{method.upper()}] {full_url} 请求超时第{attempt1}次尝试) time.sleep(1) except requests.exceptions.ConnectionError as e: logger.warning(f[{method.upper()}] {full_url} 连接错误: {e}) raise RuntimeError(f请求失败: {full_url}) def get(self, url, **kwargs): return self.request(GET, url, **kwargs) def post(self, url, **kwargs): return self.request(POST, url, **kwargs) def put(self, url, **kwargs): return self.request(PUT, url, **kwargs) def delete(self, url, **kwargs): return self.request(DELETE, url, **kwargs)封装之后有两点好处第一所有测试用例统一走一个入口想加认证、加密签名、响应缓存都只改一个地方第二日志和超时逻辑不会散落在几十个测试函数里排查问题效率高得多。初学者最常犯的错是每个用例自己写一遍requests.get()写到后面要么忘了处理异常要么超时时间不一致造成假性失败。2.3 配置驱动的多环境切换热搜词里有一条“python接口自动化如果配置自动切换环境”这确实是最常见的痛点。不同环境dev/test/prod通常情况下只是base_url不同但token、账号、数据可能也不同。我推荐的做法是在config.py里用环境变量控制并用pytest.ini注册自定义参数import os import json ENV_MAP { dev: { base_url: http://dev-api.example.com, default_user: dev_user }, test: { base_url: http://test-api.example.com, default_user: test_user }, prod: { base_url: https://api.example.com, default_user: prod_user } } def get_config(): env os.getenv(API_ENV, test) return ENV_MAP[env]接着在conftest.py中添加一个base_url的fixtureimport pytest from common.config import get_config pytest.fixture(scopesession) def base_url(): return get_config()[base_url]这样在命令行运行时只需要设置环境变量就可以切换API_ENVdev pytest -s -q API_ENVprod pytest -s -q这个方法看起来简单但在项目中的价值很大。之前团队一直是改代码里的常量来切环境经常有人忘了改回来把测试环境的用例跑到了生产环境。现在环境变量统一控制再也没出过这个问题。3. 接口关联从登录到下单一次真正的串联实战3.1 为什么接口关联是接口自动化的分水岭单接口测试很简单——发请求、断言响应。但实际项目中很多核心业务是多个接口依赖的。比如登录后拿token下单接口必须要token和数据权限校验支付接口还要订单号而订单号是下单接口返回的。这个过程中后一个接口的请求参数往往依赖前一个接口的响应值。这就需要接口关联。接口关联本质上就是数据流的传递。如果实现得不好测试代码里就会到处是全局变量、到处是sleep(2)等待接口执行完维护成本高得吓人。正确做法是借助Pytest的fixture和钩子函数把依赖数据变成工厂流水线——每个接口只需要关心自己消费的数据不关心数据从哪儿来。3.2 用Session级fixture存token避免每个用例重复登录我的方案是定义一个auth_token的fixture作用域设为session整个测试会话只执行一次登录然后返回token供所有用例使用import pytest from common.requests_util import RequestsUtil pytest.fixture(scopesession) def auth_token(base_url): util RequestsUtil(base_url) payload {username: test, password: 123456} resp util.post(/api/login, jsonpayload) assert resp.status_code 200 token resp.json().get(token) assert token is not None return token请注意这里的util是每次新建的但fixture是session级的所以整个测试过程只有一个Session实例。Requests库的Session会自动管理Cookie如果登录接口是通过Cookie方式认证的那么后续所有请求都会自动带上登录后的Cookie不需要显式传token。只有用JWT一类的token认证时我们才需要手动放到Authorization头里。下单接口的测试就可以直接消费这个fixturedef test_create_order(auth_token, base_url): util RequestsUtil(base_url, headers{Authorization: fBearer {auth_token}}) resp util.post(/api/orders, json{product_id: 123, quantity: 2}) assert resp.status_code 200 order_id resp.json().get(data, {}).get(order_id) assert order_id is not None这样整个测试过程中登录只需要执行一次。但还要注意一点如果token有有效期比如15分钟整个测试会话跑太久会过期。此时就要把fixture的作用域改成function或者module或者做一个token自动续期的缓存机制。我在实际项目里是维护了一个token_manager过期之后会自动用refresh_token重新换取并更新缓存。3.3 动态参数传递上游响应值注入下游请求除了token这种全局级别的数据还有一种更细粒度的关联A接口返回的订单号要传给B接口。在Pytest里最简单的实现是每个测试函数之间通过return值传递。官方的做法是让下游的测试函数直接调用上游的测试函数或者通过fixture分层依赖。我用一个实例来说明。假设下单接口返回order_id支付接口需要这个order_id。那么可以先把下单逻辑封装成一个fixture返回订单号pytest.fixture() def created_order(base_url, auth_token): util RequestsUtil(base_url, headers{Authorization: fBearer {auth_token}}) resp util.post(/api/orders, json{product_id: 123, quantity: 1}) assert resp.status_code 200 return resp.json().get(data).get(order_id)然后支付接口直接依赖这个fixturedef test_pay_order(base_url, auth_token, created_order): util RequestsUtil(base_url, headers{Authorization: fBearer {auth_token}}) resp util.post(f/api/orders/{created_order}/pay, json{payment_method: balance}) assert resp.status_code 200 assert resp.json().get(status) paid这个设计的好处是如果以后下单逻辑变了比如从下单接口里新增了一个优惠券字段我只需要改一个fixture所有依赖它的用例都自动更新不用几十个用例挨个改。这就是fixture依赖注入比传统测试继承方案更优雅的地方。如果你是刚开始做接口关联不建议用全局变量去存token、订单号。虽然能跑通但用例之间会形成隐形的耦合一个用例失败可能会导致后面一堆用例莫名其妙失败。fixture体系至少能清楚地看见依赖关系排查起来快得多。3.4 一个真实的串联案例注册-登录-下单-支付-查询为了更有体感我给出一个完整的业务串联案例。这个案例很适合拿来做团队内的教学模板。import pytest from common.requests_util import RequestsUtil pytest.fixture(scopefunction) def registered_user(base_url): import random username fuser_{random.randint(100000, 999999)} util RequestsUtil(base_url) resp util.post(/api/register, json{username: username, password: pass123}) assert resp.status_code in [200, 201] uid resp.json().get(data).get(user_id) return {username: username, password: pass123, user_id: uid} pytest.fixture() def logged_in_token(registered_user, base_url): util RequestsUtil(base_url) resp util.post(/api/login, json{username: registered_user[username], password: registered_user[password]}) token resp.json().get(data).get(token) return token pytest.fixture() def paid_order_id(logged_in_token, base_url): util RequestsUtil(base_url, headers{Authorization: fBearer {logged_in_token}}) create_resp util.post(/api/orders, json{product_id: 1, quantity: 1}) order_id create_resp.json()[data][order_id] pay_resp util.post(f/api/orders/{order_id}/pay, json{payment_method: balance}) assert pay_resp.status_code 200 return order_id def test_query_order_status(paid_order_id, logged_in_token, base_url): util RequestsUtil(base_url, headers{Authorization: fBearer {logged_in_token}}) resp util.get(f/api/orders/{paid_order_id}) assert resp.status_code 200 assert resp.json()[data][status] paid这里的执行顺序是test_query_order_status依赖paid_order_id-paid_order_id依赖logged_in_token-logged_in_token依赖registered_user。Pytest会自动处理好这个依赖链保证按顺序执行并传递数据。你不需要写任何手动关联代码也没有通过全局变量。如果某个环节想跳过直接改fixture的scope即可。4. 遇到的关键坑超时、重试和429限流4.1 requests的timeout不是“响应时间限制”有太多人在用Requests时忽略了timeout参数。timeout实际上是一个元组(连接超时, 读取超时)或者只传一个数字两种超时都用这个值。最常见的坑是接口响应慢时如果不设置timeout请求会一直阻塞在那里测试进程就像卡死了一样。我强烈建议每个请求都固定一个合理的timeout比如连接3秒、读取5秒。response requests.get(url, timeout(3, 5))你以为这就完了还没。在实际项目中信号的响应时间可能因为网络波动或者服务端缓存而偶尔变慢如果固定死timeout测试就会出现偶发性的失败。所以还要配套重试机制。Requests底层自带的urllib3虽然支持重试但Requests的Session没有暴露简单的重试配置方式需要做一个HTTPAdapter的定制。from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retries Retry( total3, backoff_factor1, status_forcelist[500, 502, 503, 504], allowed_methods[GET, POST, PUT, DELETE] ) session.mount(https://, HTTPAdapter(max_retriesretries))这段代码的意思是遇到500、502、503、504这类服务端错误时自动重试3次指数退避时间依次是1秒、2秒、4秒。但注意这并不包含429。429Too Many Requests表示服务端限流了而限流这个请求本身可能已经被服务端接收并处理了。如果盲目重试反而会加重服务端负担让限流更严重。4.2 我的429实战复盘有一次我做并发遍历数据接口的测试用pytest-xdist起了10个worker每个worker循环刷接口很快就撞上了“exceeded retry limit, last status: 429 too many requests”。整个测试会话崩溃日志里全是这个词条。排查思路是这样的先看是哪个接口被限流再看限流阈值是多少通常响应头里有X-RateLimit-Remaining或Retry-After字段。如果是限流策略1分钟最多允许10次那就不能靠重试解决需要降低并发或做定时控制。如果是临时性限流可以提升重试次数。我采用的方案是把全局并发去掉pytest用单进程跑然后在请求封装里增加固定的限流等待时间import time import requests class RateLimitedSession: def __init__(self, min_interval1.0): self.session requests.Session() self.min_interval min_interval self.last_request_time 0 def request(self, *args, **kwargs): wait self.min_interval - (time.time() - self.last_request_time) if wait 0: time.sleep(wait) resp self.session.request(*args, **kwargs) self.last_request_time time.time() return resp这种做法是“人走不走端”的解决方案核心思路是降低对服务端的冲击。但更好的做法是让测试数据更集中减少请求量。比如分页接口能不能把页大小调大减少总请求数比如创建数据的接口能不能批量创建而不是循环单个创建这些优化往往比单纯调代码更有效。4.3 pytest-rerunfailures的正确用法在接口自动化里我推荐在失败重试插件上再包一层只在网络类或偶发性断言问题时重试而在业务断言错误时不重试。否则一旦服务端逻辑真有问题重试只会让测试多跑几遍浪费时间。可以参考下面的配置# pytest.ini [pytest] addopts --reruns 1 --reruns-delay 2 --htmlreports/report.html但如果你在pytest.ini里全局加了reruns那么所有用例失败都会重跑一次有时代价很大。我实际更常用的做法是在单个用例上标记pytest.mark.flaky(reruns2, reruns_delay3) def test_flaky_api(...): ...这个插件的另一个坑是如果用例失败是因为fixture级初始化失败比如token获取不了reruns不会重新执行fixture它会跳过或者直接报错。所以重试机制不能替代修复fixture的根因。4.4 从429看服务端限流设计对做自动化的人来说429不算坏消息它意味着服务端保护机制在工作。但对测试框架来说频繁触发429说明自动化脚本可能本身就在给服务端压力。我之前重点排查的几个问题是不是每次请求都新创建了连接在调用RequestsUtil时是否每次都NewSession连接没有复用服务端把一个个短暂连接识别为攻击行为。是不是用例没有持续化token导致每个用例各自登录登录接口被限流这种情况我见过很多。是不是测试数据重复导致服务端判断恶意比如用同一个手机号反复验证码登录。对应解决办法也很简单尽量复用同一个Session对象把session作用域设为session级、把登录token缓存复用、在关键接口请求间增加短暂sleep。这些技巧看上去很小但在面对严格限流的服务端时能保住整套用例的通过率。5. 收尾从一份能跑的脚本到一套能用的测试体系5.1 断言策略不要只断言状态码很多从Postman转过来的同学断言只写assert resp.status_code 200。这在接口自动化里是远远不够的。200只能说明服务端没有抛出异常但业务可能已经失败了——比如返回一个success: false的JSON体状态码仍是200。所以断言必须分三层状态码层是否符合预期200/400/401等业务码层响应体里的code或status是否符合预期数据层核心字段的值是否符合预期以及字段类型是否正确我把这些封装进了assertions.pydef assert_resp_ok(resp, expected_biz_codeNone): assert resp.status_code 400, fHTTP状态码异常: {resp.status_code} body resp.json() if expected_biz_code is not None: assert body.get(code) expected_biz_code, f业务码异常: {body.get(code)} assert body.get(success) in [True, true, 1], 业务结果异常这样在用例里只写assert_resp_ok(resp, expected_biz_code0)就够了后续如果业务码结构变了只改一处全局生效。我见过很多项目在几十个用例里各自为政地写assert resp.json()[code] 0一旦接口升级改字段名改到你怀疑人生。5.2 日志和报告让失败不再是“黑盒”接口自动化一定不要忽略日志。我在封装RequestsUtil时会把请求方法、完整URL、状态码、响应耗时都打到日志里。这样用例失败时先看日志就能定位是网络问题还是服务端问题。报告上pytest-html是最快的出报告方式简单配置就能在reports/下生成带失败原因和traceback的HTML报告。如果想要更美观的趋势图可以上Allure。但Allure有个前提需要安装allure命令行工具而且生成报告本身要消耗额外时间。团队小、项目初期用pytest-html完全够。一条我个人强烈推荐的技巧把pytest.ini里的log_cli打开用--log-cli-levelINFO跑测试能看到每条请求的实时日志。开发调试阶段特别好用定位问题能少走很多弯路。5.3 还有一点想对你说的做完这个项目我的真实体会是接口自动化真正的价值不在于用例数量多而在于用例是否稳定、是否能在业务变更时快速跟进。PytestRequests这套组合最大的优势是它足够轻、足够透明你可以完全掌控每一行代码。另外以后扩展的时候可以思考往这两个方向走。第一把测试数据和测试逻辑彻底分离用YAML或Excel维护用例数据写代码的人不用碰测试数据测试数据的人不用碰代码。第二往业务层抽象下沉比如把“创建订单”“支付”“退款”这些操作抽象成公共的“业务动作”上层用例只需要声明业务流程而不是反复重复那些HTTP请求细节。这个方向能把你的接口自动化从“测试脚本集合”变成“测试平台底座”。我最后再分享一个小经验接口自动化框架不要为了过度设计而一开始引入很多复杂的机制比如庞大的关键字驱动、自研断言框架。先把最核心的用PytestRequests跑通让团队能看到成果然后再按照实际痛点逐步迭代。很多项目死在第一步——框架搭了三个月测试用例一条没跑通。从这个项目起步我已经带着两个新人跑通了一个核心接口链路稳定运行了两个迭代版本这就是最实际的价值。