用 requests 写接口测试:参数编码、data 与 json、超时与会话
一个接口测试脚本跑了半年,一直是绿的。有一天接口真的坏了,返回 500,脚本还是绿的;另一天测试环境的网关卡住,整个 CI 流水线挂了四个小时,最后被人手动取消。
Python 的 requests 是写接口测试最常用的库之一,用起来非常简单,几行代码就能发出请求、拿到 JSON。也正因为简单,它的几个默认行为很容易被忽略:查询参数要不要自己拼、data 和 json 有什么区别、请求会不会超时、500 会不会抛异常、Cookie 和连接会不会复用。每一个都可能让测试「通过」了一个本该失败的情况,或者让测试本身变得不可靠。
本文用 Python 3.14 和 requests 2.34.2,在一个本地回显服务上把这几个默认行为逐个跑一遍。实验见文末。
一、查询参数:交给 params,不要自己拼
自己拼 URL「?q=a b&c」:服务端收到 {'q': ['a b'], 'c': [''], 'tag': ['x']}
params={'q': 'a b&c', 'tag': ['x', 'y']}:查询串 q=a+b%26c&tag=x&tag=y,服务端收到 {'q': ['a b&c'], 'tag': ['x', 'y']}用 f-string 把参数拼进 URL,值里的 & 被当成了参数分隔符,q 只剩下 a b,还多出一个空的参数 c。中文、+、#、= 都有类似的问题。交给 params,requests 会按规则编码;列表会展开成重复的参数(tag=x&tag=y),这也是大多数服务端框架期望的格式。
测试数据里特意放上这些字符是值得的:搜索关键词、用户名、备注里迟早会出现 & 和空格,测试应该比用户先遇到它们。
二、请求体:data 是表单,json 才是 JSON
data=dict:Content-Type application/x-www-form-urlencoded,请求体 sku=A1&qty=2
json=dict:Content-Type application/json,请求体 {"sku": "A1", "qty": 2}
data=json.dumps(...):Content-Type None,请求体 {"sku": "A1", "qty": 2}
data 里放嵌套结构:请求体 items=sku&items=skudata= 传字典时,requests 按表单编码;只有 json= 才会序列化成 JSON 并设置 Content-Type: application/json。把 json.dumps 的结果交给 data=,请求体看起来对了,但没有 Content-Type,按 Content-Type 选择解析方式的服务端不会把它当 JSON。嵌套结构交给 data= 更糟:表单编码无法表达嵌套,内容被压扁成了 items=sku&items=sku,原来的数据全丢了。
规则很简单:接口收 JSON,就用 json=。
三、超时:默认不设
不设 timeout 请求一个 3 秒才响应的接口:等了约 3 秒;requests 默认不设超时,对方一直不响应就一直等
timeout=1:约 1 秒后抛出 ReadTimeoutrequests 默认没有超时。对方接受了连接但一直不响应(网关卡住、服务死锁),请求就一直挂着,测试进程也一直挂着,直到 CI 的全局超时把整个流水线杀掉,失败报告里只有「超时」,不知道是哪个请求。
每个请求都要设置 timeout,可以是一个数,也可以是 (连接超时, 读取超时) 的二元组。在测试框架里,最简单的做法是封装一个带默认超时的会话,所有请求都通过它发出。超时该设多少、重试怎样与超时配合,见每层都重试三次,最底下的服务收到二十七倍请求。
四、状态码:500 不抛异常
500 响应:不抛异常,resp.ok=False;只断言 resp.json() 有内容的测试照样通过
raise_for_status():HTTPError:500 Server Error: Internal Server Errorrequests 把任何收到的响应都当作成功:4xx、5xx 都正常返回。如果测试只检查响应体里有某个字段,而错误响应也是 JSON({"error": "boom"}),断言「响应是一个字典」「有内容」就会照样通过。开头那个一直是绿的脚本就是这样。
每个测试都要明确断言状态码:期望成功时断言 200(或 raise_for_status()),期望失败时断言具体的错误码,例如 409、422。只断言「不是 500」也不够,404 一样不是你要的结果。Postman 脚本里同样的问题,见 Postman 自动管理 Token:脚本能跑通,不代表失败时会报错。
五、会话:Cookie 与连接
不用 Session:先登录再访问 /me,状态 401
Session:登录后访问 /me,状态 200;7 次请求用了 1 个 TCP 连接
不用 Session 发 7 次请求:用了 7 个 TCP 连接requests.get 每次调用都是独立的:登录返回的 Cookie 不会带到下一个请求,连接也不复用。requests.Session 会保存 Cookie、复用连接,还可以统一设置请求头和认证信息。
需要多个步骤的场景测试(登录、下单、查询)应该使用会话,否则要在每一步手动传递 Cookie。连接复用对测试的速度也有明显影响,特别是 HTTPS 下每个新连接都要握手。反过来,需要验证「不带凭证时被拒绝」的测试,要用一个新的会话,不能复用已经登录的那个。
测试准备的数据怎样区分静态参数、运行时状态和环境配置,见从行为到场景到断言;单接口与跨步骤测试的分工,见这个风险该由哪一层测试发现。
六、一个最小的测试客户端
把上面的要点放在一起:
class ApiClient:
def __init__(self, base_url, timeout=(3, 10)):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
def request(self, method, path, expected_status, **kwargs):
kwargs.setdefault("timeout", self.timeout)
resp = self.session.request(method, self.base_url + path, **kwargs)
assert resp.status_code == expected_status, f"{method} {path}: {resp.status_code} {resp.text[:200]}"
return respexpected_status 是必填参数,测试必须说清期望的状态码;失败信息带上方法、路径、状态码和响应体的开头,在 CI 报告里就能看出是哪个请求出了问题。
七、检查清单
- 查询参数交给
params,测试数据里包含&、空格、中文。 - 接口收 JSON 就用
json=;不要把json.dumps交给data=。 - 每个请求都设置
timeout。 - 每个测试都断言具体的状态码。
- 多步骤场景用
Session;验证未认证的测试用新的会话。 - 失败信息包含方法、路径、状态码和响应体开头。
配套实验
- codesphere-labs/testing/requests-api-testing:Python 3.14.8、requests 2.34.2:查询参数编码、data 与 json、默认无超时、500 不抛异常、Session 的 Cookie 与连接复用(验证记录)
参考资料