先说一个在团队里反复出现的场景:
r = requests.get(url, timeout=10)
assert r.status_code == 200
这段代码在 CI 上跑了三个月,绿色的。然后某天业务反馈对不上账——因为那个接口「200 了但业务失败」。
问题不在 assert 写错了。问题在于「成功」被当成了一个布尔值,而它其实有五层。
一、五层「成功」,信号完全不同
| 层 | 检查什么 | 出问题时的信号 | 谁负责 |
|---|---|---|---|
| 1 | 连上了吗 | ConnectionError | requests |
| 2 | HTTP 状态码 | status_code 是 404 / 500 | 给值,要自己判 |
| 3 | body 是 JSON 吗 | .json() 抛 JSONDecodeError | requests |
| 4 | 字段齐、类型对吗 | 需要校验库 | Pydantic |
| 5 | 内容对吗 | 没有工具会告诉你 | 自己 |
前四层可以自动化,第五层不行。
把「成功」写成布尔值,等于把五层压成一层——压掉的那四层,正是出事的时候你最需要看见的那几层。
二、requests 给你的和没给你的
r = requests.get(url, timeout=10)
实跑结果:
/ok status=200 ok=True
/not-found status=404 ok=False
/server-error status=500 ok=False
三个都正常返回,没有一个抛异常。 requests 的契约是「拿到响应就算完成任务」。
r.ok 不够用
r.ok 只是个布尔值。你拿不到「是 404 还是 500」——而这恰恰是重试策略的依据:
- 404:地址或参数错了,重试一万次还是 404
- 500:服务端可能只是抖了一下,重试也许能好
所以要写重试,必须自己按 status_code 分支,不能用 r.ok。
timeout 不是可选项
不写 timeout,服务不响应时程序会无限期挂起。自动化脚本里这是最难查的一类问题:没有报错、没有日志,只有一个永远不结束的运行。
超时值怎么定? 定成「比业务能接受的等待时间长一点」。写死的 5 秒在小数据上够用,生产上通常从连接超时和读取超时分开设置。
只有连不上才抛异常
try:
requests.get("http://127.0.0.1:9/nothing", timeout=2)
except requests.exceptions.ConnectionError as error:
print(type(error).__name__) # ConnectTimeout
实际拿到的是 ConnectTimeout,它是 ConnectionError 的子类。注意别写成 except Exception 全部接住——那会把超时、连接失败、以及你自己的逻辑错误混成一类,日志里就看不出到底是哪种了。
Session 什么时候值得开
Session 做两件事:复用 TCP 连接,以及把公共配置集中一处(请求头、认证、默认超时)。
只请求一次不值得开;同一 host 请求很多次时收益才明显。判断标准很简单:你会不会发第二次请求? 会,就用 Session。配置写法见 requests 官方文档。
三、Pydantic 值在哪一步
先看一个不完整但很常见的检查:
if "payments" not in body:
return "第4关 缺少外层 payments"
它只看了外层有没有 payments 这个键。里面每条记录缺不缺字段、类型对不对,它不知道。
这和 Day23 的 read_rows 是同一种盲区。Pydantic 就是来补这个的:
from pydantic import BaseModel
class Payment(BaseModel):
payment_no: str
order_id: str
status: str
amount: float
class Envelope(BaseModel):
payments: list[Payment]
最被低估的一个收益
body = requests.get(f"{base_url}/ok", timeout=5).json()
envelope = Envelope.model_validate(body)
envelope.payments[0].amount # 199.8 —— float,不是字符串
接口给的是字符串 "199.80",过完 Pydantic 变成数字 199.8。
从 Day21 到 Day23 我们反复写 float(...)。这件事看着琐碎,但它正是 Day21 开头那个字符串排序 bug 的根源——让类型在入口处就归位,比在每个使用点补 float() 可靠得多。
把类型转换放在校验这一层,不要散落到业务代码里。
报错信息是你排查的起点
字段缺失时:
校验失败,一共 3 处:
('payments', 0, 'order_id') -> Field required
('payments', 0, 'status') -> Field required
('payments', 0, 'amount') -> Field required
loc 的读法是 (外层键, 第几条, 哪个字段)。一次报出全部问题,而不是修一个跑一次——这在接口一次返回几十条记录时差别很大。
快速失败 vs 挑出坏的
model_validate(整个对象) 的行为是「有一条坏,整份失败」。这在工程上通常是你想要的:一批数据要进系统,宁可全部拒绝,也不要一半进去。
但如果场景是「采集回来的数据,挑能用的留下」:
good, bad = [], []
for raw in rows:
try:
good.append(Payment.model_validate(raw))
except ValidationError:
bad.append(raw["payment_no"])
先问清楚你要哪种语义,再选写法。 我见过把「快速失败」当成 bug 改掉的,其实那正是最想要的行为。
四、第五层:没有任何工具会帮你
教学服务里有一个路径 /business-fail,返回:
状态码:200
JSON:合法
字段:payment_no / order_id / status / amount 一个不缺
类型:amount 是 float
前四层全过。 requests 顺利返回,Pydantic 顺利放行。
但内容是:
P1001-FAIL FAILED 199.80
P1001-OK SUCCESS 199.80
同一笔钱的两次尝试。要算「成功支付总额」,全加会重复,只加成功的才对。
requests 只帮你过前两层,Pydantic 只帮你过第四层。第五层必须你自己写。
def check_business(items):
problems = []
for item in items:
if item.status == "FAILED" and item.amount != 0:
problems.append((item.payment_no, "失败但金额不是 0"))
return problems
这段业务代码本身也有三个问题
我把它写进脚本注释里了,因为能说出它哪里不够,比会写它本身更有用:
- 它只认死规则。 「失败金额必须是 0」是我编的,真实业务可能根本不是这条规则。业务规则应该来自文档或产品,不是来自示例数据。
expected_count写死了。 接口一旦分页,这个数就不对——而错误的条数检查会掩盖真实的分页问题。- 出问题只告诉你哪条不对,不告诉你卡在哪一层、该不该重试。
这三点正好是 Day25 用 pytest 要解决的。
五、接口测试最该先写的不是请求
今天花了最多篇幅的不是 requests 怎么用,而是怎么搭那个假服务。
理由很实际:
| 依赖真实接口 | 依赖本地假服务 | |
|---|---|---|
| 可重复性 | 服务变了就挂 | 你控制的 |
| 错误路径 | 只能测碰到的 | 想要哪种造哪种 |
| 网络 | 会抖、会断 | 不需要网络 |
| 别人能不能跑 | 要权限、要环境 | 克隆就能跑 |
用真实接口写的测试,本质上是在测那个接口还能不能连通,不是在测你的代码对不对。 假服务才能稳定地告诉你「404 时我的代码会怎样」「body 不是 JSON 时会怎样」。
我自己的顺序永远是:先写假服务 → 再写请求 → 最后写断言。 反过来做的话,你会发现自己很难造出那些不常见的错误路径。
六、最后
/ok 那三条加起来是 359.60。
| 天 | 实现 | 合计 |
|---|---|---|
| Day21 | 函数 | 359.60 |
| Day22 | 类 | 359.60 |
| Day23 | 命令行工具 | 359.60 |
| Day24 | HTTP + Pydantic | 359.60 |
换了四次实现,数字没变。 这是判断重构有没有搞坏东西的唯一标准。
顺带一句工程价值:这四天的数据来自同一份订单/支付样例,所以每一个实现都能被同一个数字卡住。 一旦某天算出来的不是 359.60,不用讨论「是不是浮点误差」,直接就知道有东西被改坏了。
代价是前面几天的准备工作——要建立能互相校验的数据基线,得从第一天就统一。Day19 用假数据学语法时其实断过一次,是 Day21 换回订单数据才接上的。中间那几天的数字没人能对照,是有代价的。
今天真正值得带走的只有两句:
「成功」有五层,每层的信号不一样。 前四层能自动化,第五层只能你自己写。
下一篇是 Day24 之后的 Day25《Python测试与AI协作:用pytest验证代码而不是盲信答案》,我们把今天手写的死规则变成可运行的断言,并用一段故意写错的代码走完「先失败、再修复、再确认没改坏原规则」。