有个接口,金额传 "199.8"(少一位小数)、传 "-19.80"(负数)、传 "abc"——全被收下,状态码 201。

因为它的校验只有一行:判断这是不是个字符串。

这个漏洞是 Mock 发现的。真实环境里,要等到有人真传了负数金额、月底对账对不上,才会有人来查。

配套脚本在 examples/lab/day29/,四个文件都能直接跑。代码在 Windows、Python 3.11 下实跑,pytest 3 passed。

🎭 Mock 的价值:造出「等不到」的错误

真实接口出的错分五类:连不上、超时、4xx、5xx、200 但内容不对。 requests 的超时与异常处理见官方文档,状态码语义见 MDN 参考。

前四类你运气好能碰到。最后一类你碰不到——因为它只在特定数据下发生。

哪个订单会「状态码 200 但金额算错」?猜不到。只能自己造。

我们的假服务提供这些路径:

路径方法模拟什么
/orders/9999GET资源不存在
/broken-jsonGET200 但内容不是合法 JSON
/wrong-shapeGET200 但结构不对
/slow?delay=2GET慢到触发超时
/boomPOST500 服务端崩了

逐个试:

--- 200,但内容不是合法 JSON ---
  状态码:200
  .text  :{"orders": [
  .json():抛异常了 -> JSONDecodeError

--- 200,但结构不对 ---
  状态码:200
  .json():{'orders': '这不是列表,是一段文字'}

注意最后这条:状态码 200,一切看起来正常。 但 orders 是文字不是列表,下游一 for 就炸。

客户端在这两种情况下的行为完全不同:

  • 坏 JSON:.text 给你字符串,.json() 抛 JSONDecodeError
  • 结构错:.json() 正常返回一个 dict,但字段类型不对

所以测试断言不能只查状态码,得查你实际要用的那个字段。 这正是 Day28 讲的四层断言。

🧾 造数据:整齐的数据是有问题的

一开始我以为造数据很简单——编号连着、金额两位小数,五笔就够:

def 造一批订单(数量=5, 起始编号=2001):
    订单们 = []
    金额 = 99.80
    for i in range(数量):
        订单们.append(造订单(str(起始编号 + i), f"{金额:.2f}"))
        金额 = 金额 + 100
    return 订单们

这种数据只能测出「正常时对不对」,测不出「不正常时崩不崩」。

所以数据要分两批。边界值和坏值:

def 造边界金额():
    """金额的边界和坏值。"""
    return [
        ("0.00", "零元单,边界"),
        ("0.01", "最小金额,边界"),
        ("99999999.99", "最大金额,边界"),
        ("199.8", "少一位小数,坏值"),
        ("199.800", "多一位小数,坏值"),
        ("199.80 ", "尾部有空格,坏值"),
        ("-19.80", "负数,坏值"),
        ("abc", "不是数字,坏值"),
        ("", "空字符串,坏值"),
    ]

挨个发给接口:

本该拒绝、却被放行的坏值,一共 6 个:
  '199.8'      少一位小数,坏值         状态码 201
  '199.800'    多一位小数,坏值         状态码 201
  '199.80 '    尾部有空格,坏值         状态码 201
  '-19.80'     负数,坏值            状态码 201
  'abc'        不是数字,坏值          状态码 201
  ''           空字符串,坏值          状态码 201

全放行了。 服务只检查 isinstance(amount, str),所以连 "abc" 和空字符串都收。

这是接口的业务漏洞,Mock 让它十分钟就能发现。

边界值有两层

"0.00" 也被收下了,但这个不算错——零元单在有些业务里合法(比如全额优惠)。

层例子谁来答
技术上合不合法负数、空字符串服务端该拦
业务上允不允许零元单得问产品

第二个问题代码回答不了。 测试能测出「服务端没收住负数」,但「零元单该不该允许」要去问人。

🤖 AI 造数据:问题都在「整批」层面

让 AI 造 5 笔订单数据,它给的:

AI_原始数据 = [
    {"order_id": "1001", "status": "PAID", "amount": "199.80"},
    {"order_id": "1002", "status": "PAID", "amount": "199.80"},
    {"order_id": "1003", "status": "PAID", "amount": "199.80"},
    {"order_id": "1004", "status": "PAID", "amount": "199.80"},
    {"order_id": "1005", "status": "PAID", "amount": "199.80"},
]

单条看每一条都合规:字符串、两位小数、状态合法、编号不重复。

所以我的第一版审查函数审出 0 个问题。

但它明明有问题。问题不在单条上,在整批上。

单条审查

def 审一条(记录, 已知编号集合):
    问题 = []
    金额 = 记录.get("amount")
    if not isinstance(金额, str):
        问题.append(f"amount 不是字符串,是 {type(金额).__name__}")
    else:
        try:
            数值 = float(金额)
        except ValueError:
            问题.append(f"amount {金额!r} 不是数字")
        else:
            if 数值 < 0:
                问题.append(f"amount {金额!r} 是负数")
            if f"{数值:.2f}" != 金额:
                问题.append(f"amount {金额!r} 不是两位小数")
    if 记录.get("status") not in ("CREATED", "PAID", "CANCELLED"):
        问题.append(f"status {记录.get('status')!r} 不是接口认的状态")
    if 记录.get("order_id") in 已知编号集合:
        问题.append(f"order_id {记录.get('order_id')!r} 重复了")
    return 问题

整批审查

if 记录们:
    状态们 = {r.get("status") for r in 记录们}
    if len(状态们) == 1:
        全部问题.append(("整批", f"状态全是 {状态们.pop()!r},等于只测了一种情况"))

    金额们 = [r.get("amount") for r in 记录们]
    if len(set(金额们)) == 1:
        全部问题.append(("整批", f"金额全是 {金额们[0]!r},等于没造数据"))

    撞上的 = 已知编号 & 固定快照编号
    if 撞上的:
        全部问题.append(("整批", f"编号 {sorted(撞上的)} 和固定快照撞了,会覆盖掉它"))

补上之后:

订单 整批:5 笔的状态全是 'PAID',等于只测了一种情况
订单 整批:5 笔的金额全是 '199.80',等于没造数据
订单 整批:编号 ['1001', '1002', '1003', '1004'] 和固定快照撞了,会覆盖掉它

一共 3 个问题。

第三条最值得警惕

编号 1001—1005 —— 那是 Day19 那份固定快照的编号。

如果这批数据被当成「新数据」传进去,你会覆盖掉固定快照。 而 AI 不知道你项目里已经有哪些编号,它只会从 1001 开始编。

这不是 AI 笨,是它缺上下文。你项目里有哪些编号、哪些数据不能动,提示词里没说,它无从得知。

📝 修法:提示词里多说三句

请造 5 笔订单测试数据,order_id 从 9001 开始连号(不要用 1001—1005,
那是我固定快照的编号),status 要覆盖 CREATED、PAID、CANCELLED 三种,
amount 全部是两位小数的非负数字符串,每个金额都不一样。

重造后审出 0 个问题。

但光靠这些还是不够

这 5 笔全是正常数据。边界和坏值这部分 AI 不会主动给,因为提示词里没写。

更省事的做法:每次让 AI 造完,用固定脚本审一遍。 审的脚本是固定的,AI 换几批数据它都适用。

🧪 把「发现漏洞」变成一条测试

上面那 6 个坏值被放行,是一次性的发现。要让它不再溜过去,得写成测试:

坏值 = ["199.8", "199.800", "199.80 ", "-19.80", "abc", ""]


def test_坏金额应该被拒绝(本地服务):
    放行了 = []
    for 金额 in 坏值:
        响应 = requests.post(f"{本地服务}/orders", json={"amount": 金额}, timeout=5)
        if 响应.status_code == 201:
            放行了.append(金额)
    assert 放行了 == [], f"这些非法金额被收下了:{放行了}"

这条测试现在是红的——因为服务端确实没收住。这正是它该有的样子。

等服务端加上金额格式校验,它就变绿了。

一个好的漏洞发现,最终应该变成一条一直亮着的测试。 否则半年后有人重构把这个校验删了,没人知道。

📊 Mock 该造哪些错误

不是所有错误都值得 Mock。按「等得到的」和「等不到的」分:

错误等得到吗要不要 Mock
404 资源不存在等得到(造个不存在的编号)不用 Mock
400 参数错等得到(传个错值)不用 Mock
超时偶尔等得到建议 Mock
500 服务端崩难得建议 Mock
200 但内容不对等不到必须 Mock
响应特别慢等不到必须 Mock

「必须 Mock」那两行的共同点:真实环境里几乎复现不了。 一次 500 可能一年出一次,你不可能每次回归都等到它。

📋 一份数据审查清单

写完审查脚本之后,可以固化成这样:

单条规则
□ 必填字段都在吗?
□ 类型对吗?(金额是字符串还是数字,别混)
□ 格式对吗?(两位小数、没有前后空格)
□ 值合法吗?(金额非负、编号不重复)
□ 状态是接口认的吗?(不在枚举里就是非法)

整批规则
□ 状态覆盖了几种?(全一样 = 只测了一种情况)
□ 金额是不是全都一样?(全一样 = 没造数据)
□ 编号会不会撞到已有数据?(AI 最爱从 1001 开始编)

边界与坏值
□ 0、最小值、最大值、刚好超一点
□ 少一位小数、多一位小数、尾部空格
□ 负数、空字符串、非数字、空列表
□ 业务上不允许但技术上合法的(零元单)

最后一类最容易漏。 「技术上合法但业务上不允许」,代码查不出来,只能靠人知道。

🧮 一个能自我验证的小设计

审查脚本本身也可以被审查。

def test_审查脚本能抓到坏数据():
    坏数据 = [{"order_id": "1001", "status": "PAID", "amount": "-19.80"}]
    assert 审一批(坏数据), "审查脚本对负数金额竟然没报错"

这条测试保证你的审查脚本不是摆设。

和 Day28 那个「坏响应测试」是同一个思路——检查逻辑本身也要被检查。 这条线索贯穿了 Day28 到 Day30。

🔬 一个延伸:Mock 的参数化测试

Mock 最适合用参数化写——每个错误一条,失败时一眼看出是哪类:

错误场景 = [
    ("资源不存在", "/orders/9999", "GET", 404),
    ("内容不是 JSON", "/broken-json", "GET", "JSONDecodeError"),
    ("结构不对", "/wrong-shape", "GET", None),
    ("服务端崩了", "/boom", "POST", 500),
]


@pytest.mark.parametrize("名字,路径,方法,预期", 错误场景, ids=[x[0] for x in 错误场景])
def test_每类错误客户端都能正确识别(名字, 路径, 方法, 预期, 本地服务):
    if 方法 == "GET":
        响应 = requests.get(f"{本地服务}{路径}", timeout=5)
    else:
        响应 = requests.post(f"{本地服务}{路径}", json={}, timeout=5)

    if 预期 == "JSONDecodeError":
        with pytest.raises(Exception):
            响应.json()
    elif 预期 is None:
        assert 响应.status_code == 200        # 状态码正常,但内容不对
        assert not isinstance(响应.json()["orders"], list)   # 这才是关键断言
    else:
        assert 响应.status_code == 预期

最后那个 assert not isinstance(...) 才是重点。 状态码 200 时,「内容类型不对」这条断言才真正起作用。

这也是 Day28 那个「四层断言」的翻版——响应码正常的时候,你唯一能查的就是内容本身。

📝 三个练习

答案在 04_answer.py,9 条断言,pytest 3 passed。

练习一:审 AI 那批数据,答案是 3 个问题,而且全是「整批」问题。

练习二:审合格批次,答案 0 个问题。

练习三:把 6 个坏值挨个发给接口,答案 全部 201 被放行。测试这边只能如实记录,修法是服务端加金额格式校验。

🧾 一份可复用的审查清单

写完之后可以固化成这样,每次造完数据过一遍:

□ 单条:字段在不在?类型对吗?格式对吗?值合法吗?编号重复吗?
□ 整批:状态覆盖了几种?金额是不是全都一样?
□ 整批:编号会不会撞到已有数据?
□ 边界:0、最大值、刚好超一点,这些试了吗?
□ 坏值:少一位小数、负数、空值、非数字,试了吗?

🧾 一条龙:Mock 出来的问题,怎么变成回归

这是 Mock 最有价值的闭环,前面几节是零件,这里是组装:

1. 用 Mock 造出坏响应        → 复现问题
2. 写一条断言                → 描述「什么算对」
3. 断言对着 Mock 跑          → 红了,确认它抓得住
4. 修服务端                  → 同样的输入,Mock 返回 200
5. 断言再跑                  → 绿了,确认修好了
6. 把这条测试挂到 CI          → 以后每次改动都会验一遍

第 3 步最容易被跳过。 直接从第 2 步跳到第 4 步,你就不知道那条断言到底抓不抓得住错——这正是 Day28 讲的问题。

第 6 步决定了这个闭环是不是临时的。 只修一次不挂 CI,三个月后有人重构把校验删了,问题原样回来,而且没人知道它曾经被修过。

🔍 一个容易忽略的 Mock 陷阱

Mock 造的接口和真接口长得一样,但它们不是同一个东西。

真实接口有个特性叫幂等:同样的请求发两次,第二次要么被拒绝(409),要么返回「没发生新的变更」(changed=False)。这是 Day26 讲的那个细节。

如果你的 Mock 每次都返回 changed=True,那它测出来的东西是假的。

真实接口还有:超时之后服务端可能已经把事做完了(网络断了,不代表服务端没执行)。Mock 复现不了这个,因为它没有「执行到一半」的概念。

所以 Mock 要照着真接口的行为造,不是照着你希望它有的行为造。

最省事的办法:先拿真接口跑一遍,把它的真实输出抄进 Mock。 抄完之后 Mock 才可信。

📌 一句话总结今天的两条线

Mock 那条线:造出你等不到的错误,让「200 但内容不对」这种问题从碰运气变成必测。

数据那条线:让 AI 造数据省下抄参数的力气,但审查的判断必须自己留着——单条审不够,还得整批审。

两条线合起来是一句话:你得自己造出错的条件,也自己判断什么算对。

🚧 今天没做的

  • 没有做 Mock 服务框架。 现在是自己写的教学服务,真实项目里会用现成的 Mock 工具或测试替身。
  • 没有测并发下的数据一致性。 多请求同时创建会不会撞编号,属于 Day33 的链路编排。
  • 没有覆盖性能。 造一万条数据要多久,是 Day30 的内容。

下一步是 Day30《接口性能 + AI 压测分析》,我们关心「快不快」,但会先说清楚一个最容易搞混的事:并发用户数不等于请求速率。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐