1. 为什么我最终选了Pytest+Requests这套组合
先交代一下背景。我在团队里负责接口层的自动化测试,之前用的是Unittest配Requests,后来因为用例越写越多、依赖关系越来越复杂,整个维护成本已经高到让人不想碰了。换了Pytest之后,最大的感受不是“快”,而是“顺”——fixture机制天然适合做接口的依赖共享,参数化也让数据驱动变得非常直白,再加上插件生态里有一堆好用的轮子(报告、重试、并发、顺序控制),基本不需要自己造基建。
Requests这个库就更不用说了。虽然现在有人用httpx、aiohttp,但Requests在接口测试里依然是第一选择:API设计直观、处理Session和Cookie很方便、支持文件上传和流式响应、底层有urllib3的自动重试机制(这个后面会踩坑)。更重要的是,Pytest和Requests的组合用的人最多,网上踩坑帖子几乎覆盖了各种诡异场景,出了问题一搜就有答案,这在团队协作里是很大的隐形收益。
有的同事问过我:为什么不用Postman导出的脚本直接转?我的回答是,Postman适合临时调试,不适合做长期的自动化资产。它没法方便地写复杂的断言逻辑,也没法和CI/CD流水线搞好关系。而Pytest+Requests是你自己掌控剧本,什么时候发请求、从哪儿取数据、怎么断言、怎么依赖,全部代码化,后期调试和扩展都好得多。哪怕团队里有新人,看几遍测试代码也能明白逻辑。
这套组合还有一个容易被忽略的好处:它可以轻松扩展到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="", timeout=5, retry_times=2): self.session = requests.Session() self.base_url = base_url self.timeout = timeout self.retry_times = retry_times def _preprocess(self, url, params=None, headers=None, **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, params=params, headers=headers, timeout=self.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} 请求超时,第{attempt+1}次尝试") 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的fixture:
import pytest from common.config import get_config @pytest.fixture(scope="session") def base_url(): return get_config()["base_url"]这样在命令行运行时,只需要设置环境变量就可以切换:
API_ENV=dev pytest -s -q API_ENV=prod 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(scope="session") def auth_token(base_url): util = RequestsUtil(base_url) payload = {"username": "test", "password": "123456"} resp = util.post("/api/login", json=payload) 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头里。
下单接口的测试就可以直接消费这个fixture:
def test_create_order(auth_token, base_url): util = RequestsUtil(base_url, headers={"Authorization": f"Bearer {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": f"Bearer {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")然后支付接口直接依赖这个fixture:
def test_pay_order(base_url, auth_token, created_order): util = RequestsUtil(base_url, headers={"Authorization": f"Bearer {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(scope="function") def registered_user(base_url): import random username = f"user_{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": f"Bearer {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": f"Bearer {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( total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET", "POST", "PUT", "DELETE"] ) session.mount("https://", HTTPAdapter(max_retries=retries))这段代码的意思是:遇到500、502、503、504这类服务端错误时自动重试3次,指数退避时间依次是1秒、2秒、4秒。但注意,这并不包含429。429(Too 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_interval=1.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 --html=reports/report.html但如果你在pytest.ini里全局加了reruns,那么所有用例失败都会重跑一次,有时代价很大。我实际更常用的做法是在单个用例上标记:
@pytest.mark.flaky(reruns=2, reruns_delay=3) 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.py:
def assert_resp_ok(resp, expected_biz_code=None): assert resp.status_code < 400, f"HTTP状态码异常: {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_code=0)就够了,后续如果业务码结构变了,只改一处全局生效。我见过很多项目在几十个用例里各自为政地写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-level=INFO跑测试,能看到每条请求的实时日志。开发调试阶段特别好用,定位问题能少走很多弯路。
5.3 还有一点想对你说的
做完这个项目,我的真实体会是:接口自动化真正的价值不在于用例数量多,而在于用例是否稳定、是否能在业务变更时快速跟进。Pytest+Requests这套组合最大的优势是它足够轻、足够透明,你可以完全掌控每一行代码。
另外,以后扩展的时候,可以思考往这两个方向走。第一,把测试数据和测试逻辑彻底分离,用YAML或Excel维护用例数据,写代码的人不用碰测试数据,测试数据的人不用碰代码。第二,往业务层抽象下沉,比如把“创建订单”“支付”“退款”这些操作抽象成公共的“业务动作”,上层用例只需要声明业务流程,而不是反复重复那些HTTP请求细节。这个方向能把你的接口自动化从“测试脚本集合”变成“测试平台底座”。
我最后再分享一个小经验:接口自动化框架不要为了过度设计而一开始引入很多复杂的机制(比如庞大的关键字驱动、自研断言框架)。先把最核心的用Pytest+Requests跑通,让团队能看到成果,然后再按照实际痛点逐步迭代。很多项目死在第一步——框架搭了三个月,测试用例一条没跑通。从这个项目起步,我已经带着两个新人跑通了一个核心接口链路,稳定运行了两个迭代版本,这就是最实际的价值。