图片引自微信公众号,扫码关注阅读原文我干测试10 多个年头了,见过太多这样的场景:
接口返回 HTTP 200,用例 PASS,测试报告绿油油一片,截图发群里还挺有成就感。第二天上线,客服群炸了:“我下单成功了,钱也扣了,但库存没减,仓库那边根本没收到单。”
你回头翻用例,就两行断言:assert resp.status_code == 200、assert resp.json()["code"] == 0。
接口本身没坏。是你测的方式太浅了。
这类返工在接口测试里特别常见。不是能力问题,是习惯问题——我们被 Postman 那个绿色的 200 惯坏了,看到它就觉得”这个接口过了”。
今天分享一套我这些年慢慢磨出来的四维验证框架。我用它带过三个团队,接口用例的有效覆盖从不到 40% 提到 80% 以上,每个迭代的返工工时砍掉一半左右。不是开发突然变强了,是我们把问题提前抓出来了,需要返工的东西本来就变少了。
四个维度:
结构校验 → 数据一致性 → 业务链路 → 并发幂等
听着简单,但我见过太多团队卡在头一个维度就动不了。咱们一个一个拆,全篇都是可以直接抄走改改就能跑的代码。
先说明一下,文章后半段有个完整的实战案例——一个电商订单接口,从接口文档到用例设计,到代码,到跑出来的 4 个真实问题,到修复验证,全流程走一遍。如果你时间紧,可以直接跳到第 05 章看那个案例,回头再补前面的原理。
01维度一:响应结构校验
1.1 只验状态码,到底会漏掉什么
HTTP 200 这个东西,它只说明一件事:服务器把这个请求处理完了,没崩。至于处理得对不对、返回的数据能不能用,它一个字都没说。
我随手举三个我亲手踩过的坑,你八成也遇到过:
|
坑 A:订单号是 null。 下单接口返回 200, |
|
坑 B:积分字段变成了字符串。 用户积分查询接口, |
|
坑 C:返回了不该返回的字段。 商品详情接口里悄悄多了个 |
这三个 bug 有个共同点:接口都返回 200,用例都 PASS,问题都是上线后才发现的。
|
真实故障复盘 · 被 try 吞掉的异常 某年双十一预热,我们的下单接口在大促当天出现了一批”假成功”订单:用户看到下单成功,订单表里有记录,但库存一件没扣。 扒代码才发现,库存扣减那段逻辑被包在一个大 后来我们加了一条硬规则:凡是涉及钱和库存的接口,返回体的每个关键字段都要被断言,同时必须查库确认。这条规则就是四维框架里前两维的雏形。 |
1.2 用 JSON Schema 把预期结构写死
解法其实很朴素:把接口”应该长什么样”提前写下来,每次调用完自动比对。
Python 里用 jsonschema 这个库就够了。它的好处是——你写一份 Schema,后面上百条用例的结构校验全都自动覆盖,不用每条用例都手写一堆 assert。
命令行 · 安装依赖
# jsonschema 做结构校验,pytest-xdist 用来并行跑用例pip install jsonschema requests pytest pytest-xdist pymysql pyyaml
下面这份 Schema 是我们线上订单创建接口在用的(脱敏过),它同时管住了字段存在性、类型、枚举值、字符串格式、数字范围、数组长度这六件事:
order_schema.py — 订单创建接口的响应结构定义
# -*- coding: utf-8 -*-# JSON Schema 官方文档: https://json-schema.org/import jsonschema# ============ 订单创建接口 POST /api/v1/orders ============# 这份 Schema 写一次,所有下单相关用例的结构校验就都覆盖了ORDER_CREATE_SCHEMA = {"$schema": "http://json-schema.org/draft-07/schema#","type": "object",# required:这三个顶层字段少一个就直接报错"required": ["code", "message", "data"],"properties": {"code": {"type": "integer","enum": [0], # 成功场景只接受 0},"message": {"type": "string"}, # 允许空串,但不接受 null"data": {"type": "object","required": ["order_id", "status", "total_amount", "items"],"properties": {# 订单号:20 位大写字母 + 数字,防 null、防空串、防截断"order_id": {"type": "string","pattern": "^[A-Z0-9]{20}$",},# 订单状态:枚举卡死,防后端偷偷加新状态没通知测试"status": {"type": "string","enum": ["pending", "paid", "shipped","delivered", "completed", "closed"],},# 金额:number 类型 + 范围,防负数、防 0 元单、防字符串金额"total_amount": {"type": "number","minimum": 0.01,"maximum": 999999.99,},# 时间:ISO 8601,防后端某天改成毫秒时间戳"created_at": {"type": "string","pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}",},# 商品列表:至少 1 件,每件的 sku/数量/单价都要合法"items": {"type": "array","minItems": 1,"maxItems": 99,"items": {"type": "object","required": ["sku_id", "quantity", "price"],"properties": {"sku_id": {"type": "string", "pattern": "^[A-Z0-9]{10}$"},"quantity": {"type": "integer", "minimum": 1},"price": {"type": "number", "minimum": 0},},},},},# 关键一行:不允许出现 Schema 里没定义的字段# 成本价、内部备注这类字段一旦漏出来,这里直接红"additionalProperties": False,},},}def validate_order_response(response_json: dict) -> None:"""校验下单接口响应结构,失败时抛出带定位信息的断言错误。"""try:jsonschema.validate(instance=response_json, schema=ORDER_CREATE_SCHEMA)except jsonschema.ValidationError as e:# e.path 能告诉你是哪一层的哪个字段挂了,排查时特别省事path = " → ".join(str(p) for p in e.path) or "根节点"raise AssertionError(f"响应结构校验失败\n 位置: {path}\n 原因: {e.message}\n 实际值: {e.instance}") from eexcept jsonschema.SchemaError as e:raise RuntimeError(f"Schema 自己写错了,先改 Schema: {e}") from e
这份 Schema 里特别点出三行,它们是真正干活的:
"pattern": "^[A-Z0-9]{20}$"
—— 订单号是 null、是空串、被截断成 18 位,全都会红。前面说的坑 A 就是被它挡下来的。 "type": "number"
—— 金额一旦变成字符串就红。坑 B 那种类型漂移,Schema 是天然克星。 "additionalProperties": False
—— 多出来的字段直接红。坑 C 的成本价泄露,就是这一行抓到的。
|
💡 一个容易被忽略的红利 Schema 其实是接口文档的”可执行版本”。后端改字段没通知你?Schema 校验立刻失败,比等前端反馈快得多。 而且很多团队的前端会自己写兜底逻辑( |
1.3 在 Pytest 里把它变成一行断言
Schema 写好了,别让每个人在自己的用例文件里 import jsonschema 然后各写各的。封装成一个通用断言,放公共模块里,全组统一调用:
utils/assertions.py — 通用断言封装
# -*- coding: utf-8 -*-import pytestimport requestsimport jsonschemafrom typing import Dict, Any, Optionaldef assert_schema(response: requests.Response,schema: Dict[str, Any],status_code: int = 200,) -> dict:"""维度一 · 结构校验统一入口参数:response: requests 的响应对象schema: JSON Schema 字典status_code: 期望的 HTTP 状态码,默认 200返回:解析后的响应 JSON(dict),方便后续继续断言业务字段"""# Step1 先卡 HTTP 状态码,失败时把响应体前 300 字打出来assert response.status_code == status_code, (f"[维度一·结构] HTTP 状态码不符:期望 {status_code},实际 {response.status_code}\n"f"响应体: {response.text[:300]}")# Step2 能不能解析成 JSON,这一步经常挂在网关返回 HTML 错误页上try:data = response.json()except ValueError:pytest.fail(f"[维度一·结构] 响应不是合法 JSON:{response.text[:300]}")# Step3 结构校验try:jsonschema.validate(instance=data, schema=schema)except jsonschema.ValidationError as e:path = " → ".join(str(p) for p in e.path) or "根节点"pytest.fail(f"[维度一·结构] 校验失败\n"f" 位置 : {path}\n"f" 原因 : {e.message}\n"f" 实际值: {e.instance}")return datadef assert_biz_code(response: requests.Response, expected: int = 0) -> dict:"""校验业务状态码(响应体里的 code 字段),并把 data 返回出来团队里常见的业务码约定:0 成功401 未登录 / Token 过期403 无权限4004 库存不足4005 优惠券不可用"""body = response.json()actual = body.get("code")assert actual == expected, (f"[维度一·结构] 业务码不符:期望 {expected},实际 {actual},"f"接口提示: {body.get('message', 'N/A')}")return body.get("data") or {}
用起来就两行:
用例里的调用方式
resp = session.post(f"{BASE_URL}/api/v1/orders", json=payload)data = assert_schema(resp, ORDER_CREATE_SCHEMA) # 结构 + 状态码一起过order_id = data["data"]["order_id"] # 拿到就能往下传
1.4 边界值:Schema 能替你盯住的那些细节
Schema 的价值不只是”字段在不在”,更在于它能一次性把边界规则全钉死。我整理了一张常用对照表,你可以照着往自己的 Schema 里补:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
可能有同学要问了:每个接口都写这么一大坨 Schema,是不是太费时间?
我的实际做法是分级:
- 核心接口(涉及钱、库存、用户身份、权限)
必须写完整 Schema,一个字段都不放过。这类接口出问题就是资损或者数据泄露,没得商量。 - 普通业务接口
写一份”精简 Schema”,只列 required 里的关键字段 + 类型,边界规则挑重点写。 - 边缘查询、历史遗留接口
assert "xxx" in data这种轻校验就行,别为了完美主义把时间耗在这。
有个经验数字我印象很深:一个接口的响应字段里,大约 20% 是关键字段,剩下 80% 是辅助展示字段。Schema 的 required 只需要盯住那 20%,就能覆盖绝大部分风险。不要追求把每个字段都定义得完美,那是在给自己找活干。
|
💡 Schema 怎么维护才不累 把 Schema 文件和接口文档放在同一个目录,命名对齐( 这样接口质量的门禁就前移到了开发阶段,而不是等提测了才由测试来发现——这中间省下来的沟通成本,比你想象的大。 |
图片引自微信公众号,扫码关注阅读原文02维度二:数据库一致性校验
2.1 接口返回成功 ≠ 数据真的写进去了
这是我认为被低估得较严重的一个维度。
接口返回 200,不代表数据落库了。我列几种真实发生过的情况:
- 事务悄悄回滚了
后端在一个大事务里干了三件事,第 3 件失败触发回滚,但异常被 catch 住了,外层照样返回成功。响应体看起来一切正常,库里啥也没有。 - 异步写入还没落地
接口只往 MQ 里丢了条消息就返回了,消费者慢半拍。你紧接着查库,查了个寂寞。 - 写错库了
这个听起来离谱但真发生过——配置文件里数据源写错,测试环境的写操作打到了另一套库上。接口返回成功,你查的那个库当然没数据。 - 缓存没更新
写接口更新了 DB,但缓存 key 没清。查询接口从 Redis 拿到旧值返回给你,你还以为写入失败了。反过来的情况更坑:DB 更新失败但缓存写成功了,查出来是新值,其实库里还是旧的。
解法特别朴素,就一句话:调完接口,直接连库查一次。
响应体是后端”告诉你”的,数据库里的行才是事实。这两者之间的差异,就是维度二要抓的东西。
我带新人的时候有个必考题:给你一个下单接口,怎么证明它真的下单成功了?
回答 “看返回 200″的,回去重想。
回答 “查 orders 表”的,及格。
回答 “查 orders 表 + order_items 表 + inventory 表,三张表数据要对得上”的,可以直接上手干活。
2.2 用 pymysql 做写入校验(含避坑)
直接上代码。这是我们项目里在用的数据库校验模块,去掉了业务细节:
utils/db_helper.py — 数据库校验工具
# -*- coding: utf-8 -*-import timeimport pymysqlfrom contextlib import contextmanagerfrom typing import Optional, List, DictDB_CONF = {"host": "10.20.30.40","port": 3306,"user": "qa_readonly", # 强烈建议给测试单独开只读账号"password": "***", # 实际从环境变量读,别硬编码进仓库"database": "mall_order","charset": "utf8mb4","cursorclass": pymysql.cursors.DictCursor, # 查出来直接是 dict,好用}def db_conn():"""每次用完就关,别让连接跨用例复用,事务状态会互相污染。"""conn = pymysql.connect(**DB_CONF)try:# 关键:把隔离级别降到 READ COMMITTED# 默认的 REPEATABLE READ 会让你在同一个连接里反复查到旧快照,# 明明接口已经写进去了,你就是查不到——我在这上面栽过一整个下午with conn.cursor() as cur:cur.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")yield connfinally:conn.close()def query_one(sql: str, args: tuple = ()) -> Optional[Dict]:"""查单行。一条 SQL 把需要的字段一次查全,别为每个断言查一次库。"""with db_conn() as conn:with conn.cursor() as cur:cur.execute(sql, args)return cur.fetchone()def query_all(sql: str, args: tuple = ()) -> List[Dict]:with db_conn() as conn:with conn.cursor() as cur:cur.execute(sql, args)return cur.fetchall()def wait_until(sql: str, args: tuple = (), timeout: float = 5.0,interval: float = 0.3) -> Optional[Dict]:"""轮询等待数据出现,专治异步写入。比 time.sleep(3) 好在两点:1. 数据早到就早返回,不浪费用例执行时间2. 超时时间可控,不会因为环境慢一点就整批用例挂掉用法:row = wait_until("SELECT * FROM orders WHERE order_id=%s", (oid,))assert row, "5 秒内订单没落库""""deadline = time.time() + timeoutwhile time.time() < deadline:row = query_one(sql, args)if row:return rowtime.sleep(interval)return None
这个模块里有两个点是我踩坑踩出来的,单独说说。
第 1 个:事务隔离级别
MySQL 默认是 REPEATABLE READ。如果你用同一个连接查了两次,第 1 次没查到,即便中间接口已经写进去了,第 2 次你还是查不到——因为你读的是同一个事务快照。我当年为这个问题怀疑过后端、怀疑过网络、怀疑过人生,后来是 DBA 一句话点醒的。
第 2 个:主从延迟
如果测试环境是主从架构,写完立刻查从库,很可能查到旧数据。我们的做法是:测试用的数据库连接直接指向主库,或者在需要验证主从同步时,单独写一条用例来测延迟,而不是让所有用例都被延迟折腾。
test_db_consistency.py — 下单接口的数据一致性校验
# -*- coding: utf-8 -*-import pytestfrom utils.db_helper import query_one, query_all, wait_untilfrom utils.assertions import assert_schemafrom schemas.order_schema import ORDER_CREATE_SCHEMAdef test_order_create_db_consistency(api_session, base_url, test_sku):"""维度二:下单成功后,三张表的数据必须全部对得上。"""# --- 前置:记录下单前的库存,作为对照基准 ---before = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(test_sku,),)assert before, f"测试 SKU {test_sku} 在库存表里不存在,先造数据"# --- 调接口 ---payload = {"user_id": "U100000001","address_id": "A20001","items": [{"sku_id": test_sku, "quantity": 2}],}resp = api_session.post(f"{base_url}/api/v1/orders", json=payload)body = assert_schema(resp, ORDER_CREATE_SCHEMA)order_id = body["data"]["order_id"]# --- 校验点1:订单主表 ---# 用 wait_until 而不是 sleep,异步写入也能兜住order = wait_until("SELECT order_id, user_id, status, total_amount, is_deleted ""FROM orders WHERE order_id=%s",(order_id,), timeout=5,)assert order, f"[维度二·数据] 接口返回成功,但 5 秒内 orders 表查不到 {order_id}"assert order["status"] == "pending", (f"[维度二·数据] 新建订单状态应为 pending,实际 {order['status']}")assert order["is_deleted"] == 0# 注意:金额一定要用 Decimal 或者转字符串比,别用 float 直接 ==assert str(order["total_amount"]) == str(body["data"]["total_amount"]), (f"[维度二·数据] 接口返回金额与库中金额不一致:"f"接口 {body['data']['total_amount']} vs 库 {order['total_amount']}")# --- 校验点2:订单明细子表 ---items = query_all("SELECT sku_id, quantity, price FROM order_items WHERE order_id=%s",(order_id,),)assert len(items) == 1, f"[维度二·数据] 明细行数不对,期望 1,实际 {len(items)}"assert items[0]["quantity"] == 2# --- 校验点3:库存表(这一步很容易被漏掉) ---after = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(test_sku,),)assert after["available_qty"] == before["available_qty"] - 2, (f"[维度二·数据] 可售库存没扣:下单前 {before['available_qty']},"f"下单后 {after['available_qty']},期望减 2")assert after["locked_qty"] == before["locked_qty"] + 2, ("[维度二·数据] 预占库存没加,下单锁库逻辑可能没走到")
|
💡 写数据库校验的四条纪律 1)一次查全。同一张表的多个字段,一条 SQL 查出来,别为每个断言单独跑一次 SQL。用例慢就慢在这种地方。 2)连接不复用。每个用例独立连接,避免事务状态互相干扰,并行跑用例时尤其重要。 3)SQL 尽量标准。如果项目里 MySQL 和 PostgreSQL 混用,少用方言函数,不然换个环境全挂。 4)金额别用 float 比。 |
2.3 事务回滚验证:故意让它失败
很多团队不测事务回滚,理由是”这是后端该保证的”。这话没错,但接口测试是唯一能端到端验证事务边界的环节——单元测试里事务是被 mock 掉的,你根本不知道真实场景下的事务范围有多大。
测法很简单:故意构造一个必然失败的请求,然后去库里确认什么都没留下。
test_transaction.py — 事务回滚验证
def test_order_rollback_on_insufficient_stock(api_session, base_url, low_stock_sku):"""维度二·事务回滚:库存不足时下单,订单表 / 明细表 / 库存表都不该有变化。low_stock_sku 这个 fixture 会先把某个 SKU 的库存改成 1,然后我们下单 5 件,必然失败。"""before_inv = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(low_stock_sku,),)before_cnt = query_one("SELECT COUNT(*) AS c FROM orders")["c"]resp = api_session.post(f"{base_url}/api/v1/orders", json={"user_id": "U100000001","address_id": "A20001","items": [{"sku_id": low_stock_sku, "quantity": 5}],})# 业务上应该明确失败,而不是返回一个"部分成功"assert resp.status_code == 200assert resp.json()["code"] == 4004, "库存不足时业务码应为 4004"# --- 回滚校验:三处都不能有残留 ---after_cnt = query_one("SELECT COUNT(*) AS c FROM orders")["c"]assert after_cnt == before_cnt, (f"[维度二·回滚] 下单失败却多了 {after_cnt - before_cnt} 条订单,事务没回滚干净")after_inv = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(low_stock_sku,),)assert after_inv == before_inv, (f"[维度二·回滚] 下单失败但库存被改了:{before_inv} → {after_inv}")
这条用例我们跑出过一个挺有意思的 bug:库存不足确实返回了 4004,订单表也确实没记录,但 locked_qty 涨了 5。原因是后端先锁库存再校验可用量,校验失败后忘了释放锁定量。这个”幽灵预占”在测试环境积累一周之后,那个 SKU 就再也下不了单了。
再进阶一点的玩法是模拟部分失败。下单涉及订单写入 + 库存扣减 + 积分发放三步,你可以把积分服务的地址指到一个必然超时的桩上,然后验证前两步是否完整回滚。这类测试需要环境配合,但价值很高——线上大部分数据不一致,都是这种”部分成功”造成的。
|
真实故障复盘 · 幽灵订单 某次测试环境里冒出一批诡异订单:状态显示”已支付”,但支付流水表里查不到对应记录,用户账户也没扣款。 查了两天才定位到:支付回调接口在并发写入时,写主库、读从库,从库有大约 200ms 延迟。回调逻辑里有个”先查后写”的判断,并发时两个请求都从从库读到”未支付”,于是各写了一条状态更新。 修复方案是回调链路强制走主库 + 加分布式锁。但更值得说的是:这个 bug 是我们加上数据库一致性校验之后第 3 天跑出来的,在此之前它已经在环境里躺了两个月,因为接口一直返回 200。 |
2.4 软删除与级联:删除接口没那么简单
“删除订单”这个动作,测起来比想象中复杂。接口返回 200 只是起点,你至少要确认这几件事:
-
主表的 is_deleted是不是真的置成了 1 updated_at/ deleted_at 有没有被刷新成删除时间-
子表(订单明细、支付记录、发票记录)有没有同步软删除 -
再调一次列表查询接口,这条订单还能不能被查出来(JOIN 里有没有过滤 is_deleted) -
再调一次删除接口,会不会报错或者重复计数(幂等性,这里已经开始和维度四交叉了)
我见过一个真实案例:订单软删除后,订单列表接口过滤了,但”我的优惠券”页面里的关联订单没过滤,用户点进去看到一个 404 页面。这就是典型的级联过滤漏网——主流程测了,边缘入口没测。
还有个更隐蔽的:删除顺序。理论上应该先标记子表再标记主表,如果顺序反了,在并发删除时可能出现”主表已删、子表还在”的中间态。这个用普通用例测不出来,得配合维度四的并发测试。
图片引自微信公众号,扫码关注阅读原文03维度三:业务链路关联校验
3.1 单接口都通了,链路却断了
这个场景我敢说 90% 的团队都遇到过:每个接口单独测都是绿的,串起来跑就出事。
举个我们真实经历过的例子。下单 → 支付 → 发货 → 签收,四个接口,单测全过。上线后运营找过来:一批订单支付成功了,商家后台显示”已支付”,但仓库那边始终没收到发货任务。
查下来是这么回事:下单接口把订单状态写死成 pending;支付成功后的状态更新,走的是 MQ 异步消费者;那个消费者因为连接池配置问题,处理时偶发超时失败,且没有重试。于是订单状态卡在 pending,而发货任务的触发条件是 status == 'paid',自然永远不触发。
你看,每个接口都”正确”地完成了自己那部分工作。问题出在接口与接口之间的那段空白——而这段空白,单接口测试永远看不到。
链路测试难做,主要卡在三点:
- 维护成本高
一条链路涉及 4-6 个接口,任何一个接口改字段,整条链路的用例都得跟着改。 - 数据准备复杂
链路的每个节点都要求前置数据处于正确状态,造数据的代码经常比测试代码还长。 - 失败定位难
链路挂了,是哪个环节的锅?是数据没准备好还是逻辑真有问题?排查一次半小时起步。
所以我的建议很实际:别想着覆盖所有链路组合,抓主干就行。
优先级排序:
P0 — 核心正向主链路(下单→支付→发货→签收→完成),1 条;
P1 — 核心逆向链路(退款→库存回补→退款到账),1-2 条;
P2 — 高频异常链路(超时未支付自动关单、支付失败重试),2-3 条。
加起来 5 条左右的链路用例,能覆盖掉大部分跨接口问题。别贪多,链路用例贵在稳,不在多。
3.2 数据传递校验:一个订单号走完全程
链路测试的核心动作,是验证数据在接口之间传递时没有变形。
一个订单号,从下单接口的响应里出来,进到支付接口的请求参数,再进到发货接口的查询条件,一路上不能被截断、不能大小写变化、不能被重新生成。金额同理——从下单到支付到对账,一分钱都不能差。
听起来是废话,但真出过事。我们有次遇到金额从 99.90 变成 99.9,本来无所谓,但下游对账系统用的是字符串比对,直接判定金额不符,把整批订单打进了人工审核队列。
test_order_chain.py — 主链路数据传递校验
# -*- coding: utf-8 -*-import pytestfrom decimal import Decimalfrom utils.db_helper import query_one, wait_untilfrom utils.assertions import assert_schema, assert_biz_codedef test_order_pay_ship_chain(api_session, base_url, test_sku):"""维度三 · 主链路:下单 → 支付 → 发货 → 查询校验重点不是"每步返回 200",而是:· order_id 一路不变· 金额一路不变· 每一步之后,状态和关联字段同步更新"""# ===== 环节1:下单 =====r1 = api_session.post(f"{base_url}/api/v1/orders", json={"user_id": "U100000001","address_id": "A20001","items": [{"sku_id": test_sku, "quantity": 1}],})d1 = assert_biz_code(r1)order_id = d1["order_id"]amount = Decimal(str(d1["total_amount"]))assert d1["status"] == "pending"# ===== 环节2:支付回调(把上一步的 order_id 原样带进来) =====r2 = api_session.post(f"{base_url}/api/v1/payments/callback", json={"order_id": order_id,"pay_amount": str(amount), # 金额用字符串传,防精度丢失"channel": "alipay","trade_no": f"TRADE{order_id[-8:]}",})d2 = assert_biz_code(r2)# 传递校验①:支付接口返回的订单号,必须和下单时一模一样assert d2["order_id"] == order_id, (f"[维度三·链路] 订单号在支付环节变了:{order_id} → {d2['order_id']}")# 传递校验②:金额不能有任何漂移assert Decimal(str(d2["pay_amount"])) == amount, (f"[维度三·链路] 支付金额漂移:下单 {amount},支付 {d2['pay_amount']}")# 状态校验:支付后订单必须变成 paid,同时支付流水要落库order = wait_until("SELECT status, paid_at FROM orders WHERE order_id=%s AND status='paid'",(order_id,), timeout=8,)assert order, "[维度三·链路] 支付回调返回成功,但 8 秒内订单状态没变成 paid"assert order["paid_at"] is not None, "[维度三·链路] 状态变了但支付时间没写"pay = query_one("SELECT trade_no, amount FROM payments WHERE order_id=%s", (order_id,))assert pay, "[维度三·链路] 订单状态是 paid,但支付流水表里没记录(幽灵支付)"# ===== 环节3:发货 =====r3 = api_session.post(f"{base_url}/api/v1/shipments", json={"order_id": order_id,"carrier": "SF",})d3 = assert_biz_code(r3)tracking_no = d3["tracking_number"]assert tracking_no, "[维度三·链路] 发货成功但物流单号是空的"# ===== 环节4:反查订单详情,确认全链路数据闭环 =====r4 = api_session.get(f"{base_url}/api/v1/orders/{order_id}")d4 = assert_biz_code(r4)assert d4["order_id"] == order_idassert d4["status"] == "shipped"assert d4["tracking_number"] == tracking_no, ("[维度三·链路] 详情接口返回的物流单号与发货接口不一致")assert Decimal(str(d4["total_amount"])) == amount, ("[维度三·链路] 走完全链路后,订单金额和下单时对不上了")
这条用例里我埋了一个断言,是被血泪教训逼出来的:
assert pay, "订单状态是 paid,但支付流水表里没记录(幽灵支付)"
订单状态和支付流水是两张表,理论上应该在同一个事务里写。但如果后端把它们拆成了两步,中间任何一步失败,就会出现”订单显示已支付,财务对不上账”的情况。这种问题在响应体里完全看不出来。
3.3 状态流转校验:把状态机走一遍
业务流程本质上是个状态机。链路测试的另一半工作,就是顺着状态机把每个节点走一遍,每走一步都确认状态和关联字段同步更新了。
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这张表里的“容易漏测的点”那一列,才是真正值钱的部分。它们的共同特征是:状态字段本身是对的,但伴随状态变化本该发生的副作用没发生。
还有个坑得单独提:状态值的映射。数据库里状态可能存的是整数 1/2/3,接口返回的是字符串 "pending"/"paid"/"shipped"。这层映射写在代码里,一旦某个开发手滑写错一个,状态流转就乱套。所以做数据库校验时,接口返回的状态和库里的状态值,两边都要断言,并且要能对得上。
另外,状态机测试要专门测非法流转。比如:
-
直接对一个 pending订单调发货接口,应该被拒绝(我们真的遇到过没校验的版本) -
对一个已 closed的订单发支付回调,应该幂等忽略而不是把它复活 -
对一个已 refunded的订单再发一次退款,应该返回明确的错误码
3.4 反向链路:线上问题的重灾区
正向链路测完,逆向链路才是真正容易出事的地方。因为逆向流程通常写得晚、改得急、测得少。
- 退款后库存回补
退了款,库存加回来没有?如果用户同时发起两次退款请求,库存会不会加两次? - 取消订单优惠券返还
下单用掉的券,取消后有没有退回账户?退回后有效期是原来的还是重新计算的? - 删除评价后评分重算
用户删了差评,商品的平均分有没有重新计算?评价数有没有减 1? - 超时关单积分返还
下单时预扣的积分,关单后有没有还回去?
逆向链路里还有一类特别难测的:超时自动触发。订单 30 分钟未支付自动关闭、签收 7 天后自动完成——你总不能真的在用例里等半小时。
两个可行办法:
|
办法一:直接改数据库时间(推荐,简单粗暴有效) 下单后,用 SQL 把 |
|
办法二:用 freezegun 冻结/推进时间 适合定时逻辑在被测服务内部、且测试和服务同进程的场景。接口测试里用得少,更多是单元测试在用。 |
def test_order_auto_close_and_restore_stock(api_session, base_url, test_sku, db_write):"""维度三·逆向:超时未支付自动关单,预占库存必须释放。"""before = query_one("SELECT locked_qty FROM inventory WHERE sku_id=%s", (test_sku,))# 1) 正常下单,此时库存被预占d = assert_biz_code(api_session.post(f"{base_url}/api/v1/orders", json={"user_id": "U100000001", "address_id": "A20001","items": [{"sku_id": test_sku, "quantity": 1}],}))order_id = d["order_id"]mid = query_one("SELECT locked_qty FROM inventory WHERE sku_id=%s", (test_sku,))assert mid["locked_qty"] == before["locked_qty"] + 1, "下单没有预占库存"# 2) 把创建时间往前拨 31 分钟,制造"超时"条件db_write("UPDATE orders SET created_at = DATE_SUB(created_at, INTERVAL 31 MINUTE) ""WHERE order_id=%s", (order_id,))# 3) 手动触发关单任务(测试环境提供的内部接口)api_session.post(f"{base_url}/internal/jobs/close-expired-orders")# 4) 校验:状态变 closed + 预占库存回到原点row = wait_until("SELECT status FROM orders WHERE order_id=%s AND status='closed'",(order_id,), timeout=10,)assert row, "[维度三·逆向] 超时订单没有被自动关闭"after = query_one("SELECT locked_qty FROM inventory WHERE sku_id=%s", (test_sku,))assert after["locked_qty"] == before["locked_qty"], (f"[维度三·逆向] 关单了但预占库存没释放:"f"关单前 {mid['locked_qty']},关单后 {after['locked_qty']},期望回到 {before['locked_qty']}")
locked_qty,但忘了把 available_qty 加回去。结果就是每关一单,可售库存就实打实少掉一件。这个 bug 在测试环境跑了两周才被发现——因为在此之前,关单用例只断言了”状态变成 closed”。04维度四:并发与幂等校验
4.1 为什么并发问题在测试环境几乎测不出来
单线程顺序执行时,接口是对的。同一时刻来 20 个请求,结果可能完全变样。
并发场景在真实世界里到处都是,而且大多数不是”秒杀”这种高大上的场景:
-
用户手快,下单按钮连点了两下(这个占比高得惊人) -
手机信号不好,APP 觉得超时了自动重发一次 -
网关配置了超时重试,后端其实处理成功了,网关又打了一遍 -
用户在两个设备上同时操作同一个订单 -
运营在后台批量操作,脚本没做限流
这些情况下,如果后端没做好幂等控制,会发生什么?
-
同一个用户瞬间生成两笔一模一样的订单,但他只点了一次(然后来投诉) -
库存只有 1 件,两笔订单都成功,库存变成 -1(超卖,运营得去道歉) -
用户付了一笔钱,积分发了两次(这个用户不会来投诉,财务会) -
限领一张的优惠券,被并发领了三张
问题是,常规用例是单线程顺序跑的,天然不会触发这些条件。你的用例集哪怕有 500 条,也一条都测不出超卖。必须写专门的并发用例。
还有个容易被忽略的因素:测试环境的架构和生产不一样。
测试环境往往是单节点数据库、无主从、无分库分表、Redis 也是单实例。而生产环境有主从复制、有分布式锁、有 MQ 解耦。同一段代码在两种环境下的并发行为可能完全不同。
所以如果条件允许,并发测试尽量在预发或压测环境做,那里的架构和生产更接近,测出来的问题才有说服力。
4.2 用 ThreadPoolExecutor 写并发测试
Python 里做并发测试,concurrent.futures.ThreadPoolExecutor 就够用了,不需要上 Locust 这种重型工具(当然如果你要同时看性能指标,那另说)。
test_concurrency.py — 并发下单与幂等验证
# -*- coding: utf-8 -*-import uuidimport pytestimport requestsfrom collections import Counterfrom concurrent.futures import ThreadPoolExecutor, as_completedfrom utils.db_helper import query_one, query_alldef test_no_oversell_under_concurrency(base_url, token, db_write, test_sku):"""维度四 · 超卖验证场景:库存精确设为 5,20 个线程同时各下 1 件。期望:成功 5 单,失败 15 单,库存归零,一件都不能多卖。"""# --- 关键前置:把库存重置为已知值,别指望环境是干净的 ---STOCK = 5CONCURRENCY = 20db_write("UPDATE inventory SET available_qty=%s, locked_qty=0 WHERE sku_id=%s",(STOCK, test_sku),)url = f"{base_url}/api/v1/orders"headers = {"Authorization": f"Bearer {token}"}def place_order(idx: int):"""单个下单请求。每个线程用独立 session,避免连接池竞争。"""payload = {# 每个线程用不同用户,排除"同用户限购"逻辑的干扰"user_id": f"U1000000{idx:02d}","address_id": "A20001","items": [{"sku_id": test_sku, "quantity": 1}],}try:r = requests.post(url, json=payload, headers=headers, timeout=10)body = r.json()return {"idx": idx, "http": r.status_code,"code": body.get("code"),"order_id": (body.get("data") or {}).get("order_id")}except Exception as e:return {"idx": idx, "http": -1, "code": None, "error": str(e)}# --- 并发发射 ---results = []with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:futures = [pool.submit(place_order, i) for i in range(CONCURRENCY)]for f in as_completed(futures):results.append(f.result())# --- 校验点1:成功数量必须等于库存数量 ---success = [r for r in results if r["code"] == 0]assert len(success) == STOCK, (f"[维度四·并发] 库存 {STOCK} 件,却成功了 {len(success)} 单。\n"f"业务码分布: {Counter(r['code'] for r in results)}")# --- 校验点2:库存不能变成负数(超卖的直接证据) ---inv = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(test_sku,),)assert inv["available_qty"] >= 0, (f"[维度四·并发] 库存被扣成负数:{inv['available_qty']},确认超卖")assert inv["available_qty"] == 0, (f"[维度四·并发] 库存应恰好扣完,实际剩 {inv['available_qty']},"f"可能有请求扣了库存却没建单")# --- 校验点3:订单数量与成功数一致,不能有多出来的脏单 ---order_ids = [r["order_id"] for r in success]assert len(set(order_ids)) == STOCK, "[维度四·并发] 出现了重复的订单号"def test_idempotency_with_same_key(base_url, token, test_sku):"""维度四 · 幂等验证场景:拿同一个幂等键,并发打 10 次下单请求。期望:只产生 1 笔订单,10 次响应内容完全一致。"""idem_key = f"IDEM-{uuid.uuid4().hex}"url = f"{base_url}/api/v1/orders"payload = {"user_id": "U100000099","address_id": "A20001","items": [{"sku_id": test_sku, "quantity": 1}],}headers = {"Authorization": f"Bearer {token}","Idempotency-Key": idem_key, # 关键:10 次请求共用同一个键}def fire(_):r = requests.post(url, json=payload, headers=headers, timeout=10)return r.json()with ThreadPoolExecutor(max_workers=10) as pool:bodies = list(pool.map(fire, range(10)))# 校验点1:所有响应的订单号必须相同(返回同一笔单)ids = {b.get("data", {}).get("order_id") for b in bodies}assert len(ids) == 1, f"[维度四·幂等] 同一幂等键产生了多个订单号:{ids}"# 校验点2:库里确实只有一条记录rows = query_all("SELECT order_id FROM orders WHERE idempotency_key=%s", (idem_key,))assert len(rows) == 1, (f"[维度四·幂等] 幂等键 {idem_key} 在库里对应了 {len(rows)} 条订单")# 校验点3:真正的幂等,是每次响应体都完全一致# 很多接口做到了"不重复创建",但重复请求返回的是错误码而不是首次结果,# 这会让客户端的重试逻辑很难写first = bodies[0]for i, b in enumerate(bodies[1:], start=2):assert b == first, (f"[维度四·幂等] 第 {i} 次响应与首次不一致\n"f"首次: {first}\n第{i}次: {b}")
关于并发测试的参数,说几个我踩出来的经验值:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
max_workers 不是越大越好。线程开太多,客户端自己的上下文切换开销就成了瓶颈,请求发不出去,反而测不准。判断标准是看请求的实际发出时间是否集中——可以在每个线程里记录发起时间戳,跑完看看时间分布,如果 20 个请求分散在 3 秒内发出,那这根本不叫并发。
|
💡 并发测试前,务必重置数据 不要假设测试环境的库存是干净的。每次并发测试前,用 SQL 把库存精确设成已知值(比如 5),这样”应该成功几单”才是确定的。 我见过有人的并发用例写成 |
图片引自微信公众号,扫码关注阅读原文4.3 并发 bug 的四种典型长相
跑并发用例的时候,失败信息往往一团乱。这里给你一份”症状对照表”,能帮你快速判断是哪类问题:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
UPDATE ... WHERE qty >= n
|
|
|
|
|
|
|
|
|
|
|
|
|
再补两个排查技巧:
技巧一:看 binlog
并发问题的现场,在 MySQL 的 binlog 里看得一清二楚。如果你看到两条 UPDATE 语句的时间戳几乎相同、且都基于同一个旧值,那就是标准的竞态现场,可以直接把截图甩给开发。
技巧二:在代码里埋日志
让开发在库存扣减前后各打一行日志(带线程 ID 和库存值),并发跑一次,把日志按时间排序,交错执行的时序就出来了。这比口头描述”我觉得这里有并发问题”有说服力得多。
还有一点值得说:并发数和问题出现概率是非线性的。我们遇到过并发 20 一切正常、并发 50 才开始超卖的情况。原因是数据库连接池大小是 30,超过之后请求开始排队,时序变了,竞态窗口才被打开。所以如果核心接口的并发用例跑通了,不妨把并发数翻一倍再跑一次。
05实战:电商订单接口四维验证完整案例
前面四个维度讲完了,但我知道,讲原理和真正落地之间隔着一条河。所以这一章我把一个完整的真实案例从头到尾走一遍——脱敏之后的,但流程、问题、修复方式都是原样的。
5.1 背景:一个”看起来很简单”的下单接口
项目是一个中型电商平台的订单域改造。这次迭代的需求听起来很简单:下单时支持使用优惠券,并且要预占库存。
开发排期 5 天,测试排期 2 天。需求评审的时候大家都觉得没啥难度,就是在原有下单接口上加个 coupon_id 参数嘛。
结果这条链路我们用四维框架跑下来,找出 4 个问题,其中 2 个是会造成资损的。如果按老办法只测状态码,我可以很确定地说:这 4 个问题,一个都发现不了。
图片引自微信公众号,扫码关注阅读原文5.2 接口文档(三个核心接口)
先把测试对象说清楚。这次涉及三个接口:
接口一:创建订单
POST /api/v1/orders
// ========== 请求 ==========Headers:Authorization: Bearer <token>Idempotency-Key: <客户端生成的唯一键,可选但强烈建议>Content-Type: application/jsonBody:{"user_id": "U100000001", // 必填,用户ID"address_id": "A20001", // 必填,收货地址ID"coupon_id": "CP20240801001", // 选填,本次迭代新增"items": [{"sku_id": "SKU0000001", "quantity": 2}]}// ========== 成功响应 =========={"code": 0,"message": "","data": {"order_id": "OD20240801XK92MT3B01", // 20位"status": "pending","original_amount": 199.80, // 优惠前金额"discount_amount": 20.00, // 优惠券抵扣"total_amount": 179.80, // 实付金额"created_at": "2024-08-01T14:23:05+08:00","items": [{"sku_id": "SKU0000001", "quantity": 2, "price": 99.90}]}}// ========== 业务错误码 ==========// 4004 库存不足 4005 优惠券不可用 4006 地址无效// 4009 幂等键冲突(已有处理中的同键请求)
接口二:支付回调
POST /api/v1/payments/callback
Body:{"order_id": "OD20240801XK92MT3B01","trade_no": "TRADE202408011423001", // 支付渠道流水号"pay_amount": "179.80", // 字符串传,防精度丢失"channel": "alipay"}Response:{"code": 0,"data": {"order_id": "OD20240801XK92MT3B01","status": "paid","paid_at": "2024-08-01T14:25:11+08:00"}}// 注意:支付渠道会重复推送回调(这是行业惯例,不是 bug)// 所以这个接口天然要求幂等 —— 这是维度四的重点靶子
接口三:订单详情
GET /api/v1/orders/{order_id}
Response:{"code": 0,"data": {"order_id": "OD20240801XK92MT3B01","status": "paid","total_amount": 179.80,"coupon_id": "CP20240801001","paid_at": "2024-08-01T14:25:11+08:00","items": [ ... ]}}
5.3 用例设计:把四个维度拆成校验点
拿到文档,我做的头一件事不是写代码,是画一张校验点矩阵。把每个接口在四个维度上分别要验什么,列清楚。这张表画完,用例怎么写就一目了然了。
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
💡 为什么要画这张矩阵 我强制要求团队在动手写代码前先画这张表,有两个原因。 一是它能暴露需求盲区。画到”优惠券并发”那一行时,我们才意识到需求文档里压根没说”同一张券被并发使用要怎么处理”。这个问题在评审阶段就抛给了产品和开发,比测出来再改成本低得多。 二是它能作为提测门禁。P0 校验点全绿才算通过,谁也别想拿”接口返回 200 了”来糊弄。 |
5.4 代码实现
下面是这个案例的核心测试代码。为了看起来清爽,我把 Schema 和工具函数的 import 都省略了(就是前面几章那些)。
test_order_e2e.py — 电商订单接口四维验证
# -*- coding: utf-8 -*-"""电商订单接口 · 四维验证完整用例集"""import uuidimport pytestimport requestsfrom decimal import Decimalfrom concurrent.futures import ThreadPoolExecutorfrom utils.assertions import assert_schema, assert_biz_codefrom utils.db_helper import query_one, query_all, wait_untilfrom schemas.order_schema import ORDER_CREATE_SCHEMA# ============================================================# 用例1:下单主流程 —— 一次覆盖维度一 + 维度二(8 个校验点)# ============================================================def test_create_order_with_coupon(api_session, base_url, ready_sku, ready_coupon):user_id = "U100000001"inv_before = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(ready_sku,),)resp = api_session.post(f"{base_url}/api/v1/orders", json={"user_id": user_id,"address_id": "A20001","coupon_id": ready_coupon,"items": [{"sku_id": ready_sku, "quantity": 2}],})# ---------- 维度一:结构 ----------body = assert_schema(resp, ORDER_CREATE_SCHEMA)d = body["data"]order_id = d["order_id"]# 金额勾稽:优惠前 - 优惠 = 实付。这一条抓到过 Bug#1assert (Decimal(str(d["original_amount"])) - Decimal(str(d["discount_amount"]))== Decimal(str(d["total_amount"]))), (f"[维度一·结构] 金额勾稽不上:"f"{d['original_amount']} - {d['discount_amount']} != {d['total_amount']}")# ---------- 维度二:数据 ----------order = wait_until("SELECT order_id, status, original_amount, discount_amount, "" total_amount, coupon_id, is_deleted ""FROM orders WHERE order_id=%s", (order_id,), timeout=5,)assert order, f"[维度二·数据] 接口成功但 orders 表无记录:{order_id}"assert order["status"] == "pending"assert Decimal(str(order["total_amount"])) == Decimal(str(d["total_amount"])), ("[维度二·数据] 库中实付金额与接口返回不一致")assert order["coupon_id"] == ready_coupon, "[维度二·数据] 订单没有记录所用优惠券"# 明细行金额求和,必须等于优惠前金额(这一条抓到过 Bug#2)items = query_all("SELECT sku_id, quantity, price FROM order_items WHERE order_id=%s",(order_id,),)assert len(items) == 1items_sum = sum(Decimal(str(i["price"])) * i["quantity"] for i in items)assert items_sum == Decimal(str(order["original_amount"])), (f"[维度二·数据] 明细金额求和 {items_sum} != 主表优惠前金额 {order['original_amount']}")# 库存:可售减少、预占增加inv_after = query_one("SELECT available_qty, locked_qty FROM inventory WHERE sku_id=%s",(ready_sku,),)assert inv_after["available_qty"] == inv_before["available_qty"] - 2assert inv_after["locked_qty"] == inv_before["locked_qty"] + 2# 优惠券:状态置为已使用,并回填订单号(这一条抓到过 Bug#3)cp = query_one("SELECT status, used_order_id FROM user_coupons ""WHERE coupon_id=%s AND user_id=%s", (ready_coupon, user_id),)assert cp["status"] == "used", (f"[维度二·数据] 优惠券状态应为 used,实际 {cp['status']}——券可能被重复使用")assert cp["used_order_id"] == order_id, "[维度二·数据] 券没有绑定到本次订单"# ============================================================# 用例2:支付回调幂等 —— 维度三 + 维度四# ============================================================def test_payment_callback_idempotent(api_session, base_url, pending_order):"""支付渠道会重复推回调,这是行业常态,必须幂等。"""order_id, amount = pending_ordertrade_no = f"TRADE{uuid.uuid4().hex[:14].upper()}"payload = {"order_id": order_id, "trade_no": trade_no,"pay_amount": str(amount), "channel": "alipay",}# 连续推 5 次,模拟渠道重复回调bodies = [api_session.post(f"{base_url}/api/v1/payments/callback",json=payload).json() for _ in range(5)]# 维度四:5 次响应必须完全一致for i, b in enumerate(bodies[1:], start=2):assert b == bodies[0], f"[维度四·幂等] 第 {i} 次回调响应与首次不同"# 维度二:支付流水只能有 1 条(这一条抓到过 Bug#4)pays = query_all("SELECT id, amount FROM payments WHERE order_id=%s", (order_id,))assert len(pays) == 1, (f"[维度四·幂等] 5 次回调产生了 {len(pays)} 条支付流水,财务对账要炸")# 维度三:状态正确流转,且伴随字段齐全order = wait_until("SELECT status, paid_at FROM orders WHERE order_id=%s AND status='paid'",(order_id,), timeout=8,)assert order and order["paid_at"], "[维度三·链路] 支付后状态或支付时间缺失"# 维度三:详情接口反查,金额一路不变d = assert_biz_code(api_session.get(f"{base_url}/api/v1/orders/{order_id}"))assert Decimal(str(d["total_amount"])) == amount# ============================================================# 用例3:同一张优惠券并发下单 —— 维度四# ============================================================def test_coupon_no_double_use(base_url, token, ready_sku, ready_coupon):"""一张券并发下 5 单,只允许 1 单用券成功。"""url = f"{base_url}/api/v1/orders"headers = {"Authorization": f"Bearer {token}"}def fire(i):r = requests.post(url, headers=headers, timeout=10, json={"user_id": "U100000001", "address_id": "A20001","coupon_id": ready_coupon,"items": [{"sku_id": ready_sku, "quantity": 1}],})return r.json()with ThreadPoolExecutor(max_workers=5) as pool:bodies = list(pool.map(fire, range(5)))ok = [b for b in bodies if b.get("code") == 0]assert len(ok) == 1, (f"[维度四·并发] 一张券被 {len(ok)} 笔订单同时用掉了,直接资损")# 库里也确认一遍:这张券只被一个订单绑定rows = query_all("SELECT order_id FROM orders WHERE coupon_id=%s AND is_deleted=0",(ready_coupon,),)assert len(rows) == 1, f"[维度四·并发] 券 {ready_coupon} 关联了 {len(rows)} 笔订单"
5.5 跑出来的 4 个问题
这套用例头一次跑,绿了 2 条,红了 4 条。逐个说说。
|
Bug #1 · 金额勾稽对不上(资损级) 现象:金额勾稽断言失败。接口返回 排查:开发一开始说”不可能,我算过的”。我们把请求响应原样发过去,他自己跑了一遍才发现——优惠券是”满199减20″,但代码里算折扣时用了 影响:用户少优惠 10 块,平台看着占便宜,实际是客诉和投诉。而且如果封顶逻辑反过来算错,就是平台亏钱。 这个 bug 只有维度一能抓到——因为接口返回的是 200,code 是 0,三个金额字段都在,类型都对。只有加上”三个数之间必须勾稽”这条断言,它才会现形。 |
|
Bug #2 · 明细单价用了优惠后价格 现象:明细金额求和 179.80 ≠ 主表优惠前金额 199.80。 排查:开发把优惠金额平摊到了每个明细的 影响:这个问题在下单时不明显,但退款时会炸。部分退款按明细单价退,用户退一件商品,退到的钱比他实际付的少。下游的商品销售报表也会算错。 抓到它的是维度二的”明细求和 = 主表金额”这条勾稽断言。如果只查 orders 表不查 order_items,这个问题会一直躺到有人申请部分退款。 |
|
Bug #3 · 优惠券被并发用了 3 次(资损级) 现象:并发用例里,同一张券 5 个并发请求,成功了 3 单。 排查:代码逻辑是标准的 check-then-act:先 影响:一张 20 元的券被用 3 次,直接亏 40 块。听起来不多,但这是可以被脚本批量刷的——薅羊毛团伙就靠这个吃饭。 值得说的是,这个 bug 在功能测试阶段完全测不出来。手工点、Postman 点、单线程用例跑,都是正常的。只有并发用例能把它逼出来。 |
|
Bug #4 · 重复回调产生多条支付流水 现象:同一个 排查:接口对订单状态做了判断(已 paid 就不再改状态),所以订单表看着是对的,状态没被改乱。但写流水那一步没有做去重,每次回调都 INSERT 一条。 影响:财务对账时,一笔订单对应 5 条流水,金额加起来是实付的 5 倍。对账系统会直接告警,然后财务同事来找测试。 这个 bug 特别有代表性:它的”主流程”是对的(订单状态正确),错的是副作用。如果用例只断言 |
我把这 4 个 bug 摆在一起,你会发现一个规律:
它们 4 个的 HTTP 状态码全都是 200,业务码全都是 0。
Bug#1 靠字段间的勾稽关系抓到,Bug#2 靠跨表数据比对抓到,Bug#3 靠并发抓到,Bug#4 靠”副作用数据”的数量断言抓到。
没有一个是靠”检查返回值是不是 200″发现的。
5.6 修复验证:不只是”改完再跑一遍”
开发修完了,怎么验?这一步很多人做得太粗——重新跑一遍原用例,绿了就关单。我的做法多两步。
|
第 1 步:确认修复方式,而不只是确认现象消失 Bug#3 开发的头一版修复是”加了个 Redis 分布式锁”。我追问了一句:锁的 key 是什么?答: 没设超时的分布式锁,一旦持锁的进程崩了,这张券就永远锁死了。所以修复方案本身也得评审。后来改成了数据库乐观锁: |
|
第 2 步:加固用例,把修复点沉淀成长期防线 光跑原用例不够,我针对每个 bug 补了更严的断言。比如 Bug#3 修复后,并发数从 5 提到 20,并且加了一条”失败的 19 个请求,业务码必须都是 4005(优惠券不可用),不能是 500″。 这条断言当场又抓到一个小问题:乐观锁竞争失败时,后端抛的是未捕获异常,返回了 500 而不是 4005。用户看到的是”系统繁忙”,而不是”券已被使用”。体验差别很大。 |
|
第 3 步:回归影响面,而不只是回归 bug 本身 Bug#2 改的是明细金额口径。这个改动会影响退款、报表、对账三条下游链路。所以回归时我们额外跑了退款链路的用例,果然发现退款金额的计算逻辑也要跟着改——不然改完下单,退款又错了。 |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这次迭代整体延期了 1.5 天。听起来是坏事,但你换个算法:4 个 bug 如果漏到线上,Bug#1 和 Bug#3 都是资损,加上紧急发版、客服解释、财务对账、数据修复的成本,绝对不止 1.5 天。
而且这套用例是可以复用的。下个迭代改订单逻辑,直接跑一遍,10 分钟出结果。
06把四维校验封装成团队的基础设施
6.1 conftest.py:让新人抄一行就能用
四维校验如果散落在每个人的用例文件里,一个月之后就会变成四个版本。统一放进 conftest.py,才能真正变成团队资产。
conftest.py — 四维校验框架的统一供给
# -*- coding: utf-8 -*-import osimport timeimport uuidimport pytestimport requestsimport pymysqlfrom utils.db_helper import DB_CONF, query_one# ---------- 基础配置 ----------def base_url():# 环境地址从环境变量读,方便在 CI 里切 dev/test/stagingreturn os.getenv("API_BASE_URL", "http://api-test.internal")# ---------- Token 自动续期(解决"跑着跑着全挂") ----------class TokenManager:"""Token 不硬编码,且在快过期时自动续。很多团队的用例集跑到一半突然全红,十有八九是 Token 到期了。"""def __init__(self, base_url):self.base_url = base_urlself._token = Noneself._expire_at = 0def get(self) -> str:# 剩余有效期不足 5 分钟就提前换新的if self._token and time.time() < self._expire_at - 300:return self._tokenr = requests.post(f"{self.base_url}/api/v1/auth/login", json={"username": os.environ["QA_USER"],"password": os.environ["QA_PASS"],}, timeout=10)r.raise_for_status()data = r.json()["data"]self._token = data["access_token"]self._expire_at = time.time() + data.get("expires_in", 7200)return self._tokendef token_mgr(base_url):return TokenManager(base_url)def token(token_mgr):return token_mgr.get()def api_session(token):"""带认证头的 session,连接复用,比每次新建 requests 快很多。"""s = requests.Session()s.headers.update({"Authorization": f"Bearer {token}","Content-Type": "application/json",# 打标记,方便后端在日志里区分自动化流量"X-Request-Source": "qa-automation",})s.request = _with_timeout(s.request, default=15)yield ss.close()def _with_timeout(func, default=15):"""给所有请求兜一个默认超时,防止某个接口卡死拖垮整批用例。"""def wrapper(method, url, **kw):kw.setdefault("timeout", default)return func(method, url, **kw)return wrapper# ---------- 数据库写操作(造数据 / 清理用) ----------def db_write():"""提供一个可执行写 SQL 的函数。注意:这个 fixture 用的是有写权限的账号,和查询用的只读账号分开。造数据、改状态、重置库存走这里,普通断言查询走 query_one。"""conn = pymysql.connect(**{**DB_CONF, "user": os.environ["DB_RW_USER"],"password": os.environ["DB_RW_PASS"]})def _exec(sql: str, args: tuple = ()):with conn.cursor() as cur:cur.execute(sql, args)conn.commit()yield _execconn.close()# ---------- 测试数据 fixture:天然隔离,不靠清理逻辑 ----------def ready_sku(db_write):"""每个用例造一个独立 SKU,避免并行执行时互相抢库存。这里用 yield 而不是 return,是为了保证 teardown 一定会执行——即使用例中途断言失败,yield 之后的清理代码照样跑。"""sku_id = f"SKU{uuid.uuid4().hex[:7].upper()}"db_write("INSERT INTO inventory (sku_id, available_qty, locked_qty, price) ""VALUES (%s, 100, 0, 99.90)", (sku_id,))yield sku_id# 清理用软删除,不做物理 DELETE —— WHERE 写错一次就回不来了try:db_write("UPDATE inventory SET is_deleted=1 WHERE sku_id=%s", (sku_id,))except Exception as e:# 清理失败不能影响用例本身的结论,打日志就好print(f"[teardown] 清理 SKU {sku_id} 失败: {e}")def ready_coupon(db_write):"""造一张满199减20的未使用优惠券。"""cid = f"CP{uuid.uuid4().hex[:11].upper()}"db_write("INSERT INTO user_coupons (coupon_id, user_id, threshold, discount, status) ""VALUES (%s, 'U100000001', 199.00, 20.00, 'unused')", (cid,))yield cidtry:db_write("UPDATE user_coupons SET is_deleted=1 WHERE coupon_id=%s", (cid,))except Exception as e:print(f"[teardown] 清理券 {cid} 失败: {e}")# ---------- 自定义 marker,方便按维度筛选执行 ----------def pytest_configure(config):config.addinivalue_line("markers", "schema: 维度一 结构校验")config.addinivalue_line("markers", "db: 维度二 数据一致性")config.addinivalue_line("markers", "chain: 维度三 业务链路")config.addinivalue_line("markers", "concurrency: 维度四 并发幂等")
有了这套东西,CI 里就能按维度分开跑:
CI 里的分层执行策略
# 每次提交:只跑快的(结构 + 数据),2 分钟出结果pytest -m "schema or db" -n 4 --tb=short# 每日构建:加上链路,10 分钟pytest -m "schema or db or chain" -n 4# 发版前 / 每周:全量,并发用例单独串行跑,避免互相干扰pytest -m "not concurrency" -n 4pytest -m concurrency # 并发用例不要加 -n,会自己打自己
|
💡 并发用例别用 pytest-xdist 并行 这是我踩过的一个蠢坑:并发用例本身就在开 20 个线程,你再用 并发用例一定要单独串行执行,而且尽量安排在没人用环境的时段。 |
6.2 用例编写模板:七步走
框架有了,还得有规范,不然每个人写出来的用例结构五花八门。我们组的规范是”七步走“:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
还有个小规范但很有用:每条断言的失败信息,前面加维度标签。
就是前面代码里那些 [维度一·结构]、[维度二·数据]。CI 报告出来,扫一眼标签就知道问题出在哪一层,不用点进去看堆栈。开发看到 [维度四·并发] 也会立刻意识到这是竞态问题,而不是”你环境有问题吧”。
6.3 测试数据用 YAML 管理
测试数据硬编码在 Python 里,改一个金额要动代码、要走 code review、要重新提交。用 YAML 分离出来,业务测试同学自己就能维护。
test_data/order_cases.yaml — 下单场景数据
- name: "正常下单_单件商品_无优惠券"payload:quantity: 1coupon: nullexpect:code: 0status: "pending"discount: "0.00"- name: "正常下单_满减券_刚好达到门槛"payload:quantity: 2coupon: "THRESHOLD_199_MINUS_20"expect:code: 0discount: "20.00"- name: "异常_满减券_差一分钱不到门槛"payload:quantity: 1coupon: "THRESHOLD_199_MINUS_20"expect:code: 4005message_contains: "未达到使用门槛"- name: "边界_数量上限99件"payload:quantity: 99coupon: nullexpect:code: 0- name: "异常_数量超上限100件"payload:quantity: 100coupon: nullexpect:code: 4002message_contains: "单次购买数量"- name: "异常_数量为0"payload:quantity: 0coupon: nullexpect:code: 4002
import yamlimport pytestfrom pathlib import PathDATA = yaml.safe_load((Path(__file__).parent / "test_data/order_cases.yaml").read_text("utf-8"))def test_order_scenarios(api_session, base_url, ready_sku, case):"""一份 YAML 驱动 6 个场景,加场景不用改代码。"""payload = {"user_id": "U100000001", "address_id": "A20001","items": [{"sku_id": ready_sku, "quantity": case["payload"]["quantity"]}],}if case["payload"].get("coupon"):payload["coupon_id"] = resolve_coupon(case["payload"]["coupon"])resp = api_session.post(f"{base_url}/api/v1/orders", json=payload)body = resp.json()assert body["code"] == case["expect"]["code"], (f"场景【{case['name']}】业务码不符:"f"期望 {case['expect']['code']},实际 {body['code']},"f"提示:{body.get('message')}")if "message_contains" in case["expect"]:assert case["expect"]["message_contains"] in body.get("message", "")
图片引自微信公众号,扫码关注阅读原文07避坑指南:6 个我亲自踩过的坑
坑 1:清理代码没跑,脏数据污染下一条用例
用例中途断言失败,后面的清理代码直接被跳过,脏数据留在库里。下一条用例跑起来发现库存不对、订单状态不对,一片飘红,但根因在上一条用例。
解法有两条:
其一,清理逻辑必须写在 fixture 的 yield 之后,而不是用例函数的末尾。pytest 保证 yield 后的代码在用例失败时照样执行。
其二,清理代码本身要用 try-except 包住。清理失败是运维问题,不该把用例结论带偏——你总不希望看到”功能是对的,但因为清理失败所以报红”。
对比:错误写法 vs 正确写法
# ❌ 错误:断言失败后,清理代码永远执行不到def test_bad(api_session):sku = create_sku()resp = api_session.post(url, json=payload)assert resp.json()["code"] == 0 # ← 这里挂了cleanup(sku) # ← 永远跑不到# ✅ 正确:清理放 fixture teardown,且做容错def sku(db_write):s = create_sku()yield stry:# 用软删除而不是物理 DELETE,WHERE 写错还有救db_write("UPDATE inventory SET is_deleted=1 WHERE sku_id=%s", (s,))except Exception as e:print(f"[teardown] 清理失败但不影响用例结论: {e}")
坑 2:并发数太小,等于没测
开 2-3 个线程就叫并发测试?我见过有人的”并发用例”用 for 循环发 3 次请求——那是循环,不是并发。
并发度不够,竞态窗口打不开,用例永远是绿的,给人一种虚假的安全感。普通接口至少 10-20,秒杀场景 50 起步。
还有个细节容易忽略:并发度不只看线程数,还要看请求的实际发出时间是否集中。如果接口响应要 1 秒,你开 10 个线程,实际上就是每秒 10 个请求打进去,竞争窗口很宽松。可以在每个线程里记录 time.time(),跑完打印一下发出时间的方差,如果分散在几秒内,那并发是假的。
坑 3:Token 硬编码,跑到一半全挂
这个坑几乎每个团队都栽过。Token 直接写在配置文件里,两小时后过期,整批用例 401。更糟的是有人看到全红,头一个反应是”环境挂了”,跑去找运维,折腾半小时才发现是 Token 的事。
解法就是前面 conftest 里那个 TokenManager:从环境变量读账号密码,自动登录,剩余有效期不足 5 分钟就提前续。用例代码完全感知不到这一层。
如果接口支持 OAuth2 的 refresh_token,那就更省事,直接用 refresh 换新的,连重新登录都不用。
坑 4:所有用例共用一个测试账号
全组共用 test001 这个账号,单线程跑没问题,一开并行就出事:用例 A 把这个账号的订单全取消了,用例 B 正在查那笔订单,直接查不到。
解法是让数据天然隔离,别指望清理逻辑:
-
SKU、优惠券、订单这类可造的数据,每个用例用 UUID 造独立的一份 -
用户账号如果不能动态造,就按用例文件分配固定账号池,避免跨文件抢 -
用例命名和数据前缀对齐,出问题时一眼能看出是哪条用例留下的脏数据
另外必须强调一句:测试环境和生产环境的数据库要彻底隔离。我知道有团队为了省事在生产库跑只读查询,甚至跑过写操作。这事一旦出问题,触发的可能是真实的短信通知、真实的支付回调、真实的资金流动。别冒这个险。
坑 5:断言太宽,跟没断言一样
assert resp.json()["code"] == 0 这一行,是我见过被滥用得较多的断言。
接口返回 code=0,但 message 里写着”部分商品已下架”,data 是个空对象——这个断言照样绿。
断言要分层:
-
成功场景:业务码 + Schema 结构 + 关键字段有值 + 字段间勾稽关系 -
失败场景:业务码要精确(4004 就是 4004,不能接受 500)+ message 内容要合理 + 不能有副作用数据产生
但也别走另一个极端。我见过有人给每个字段写一条 assert,一个用例 40 行断言,失败时报告里全是噪音。核心业务字段(订单号、金额、状态)精确断言,辅助字段交给 Schema 整体兜住,这个比例比较舒服。
坑 6:把”接口测试”和”测接口”搞混了
这个坑比较隐蔽,但我觉得是危害较大的一个。
很多同学写接口测试,写着写着就变成了”把接口文档翻译成代码”——文档说返回 order_id,我就断言有 order_id;文档说 code 为 0,我就断言 code 为 0。
这不叫测试,这叫复述。接口文档本身可能就是错的,你照着它写用例,只是在验证”实现和文档一致”,而不是在验证”系统行为正确”。
四维框架里,维度二(查库)、维度三(链路)、维度四(并发)之所以有价值,正是因为它们跳出了接口文档的框——它们验证的是业务事实,而不是文档描述。
前面实战案例里的 Bug#1(金额勾稽),接口文档里压根没写”三个金额字段之间要满足什么关系”。是我们自己从业务常识推出来的。这种断言,才是测试的价值所在。
08行动清单:今天下午就能动手的 5 件事
讲这么多,不如你现在打开 IDE 改一行代码。下面 5 条按难度排过序,从投入小见效快的开始。
- 给一个核心接口写 Schema(半小时)
从你们团队接口文档里挑一个涉及钱或库存的接口,用 jsonschema写一份响应结构定义,塞进现有用例。写一次,后面所有相关用例都能复用。做完你大概率会发现:某个字段的类型和文档写的不一样。 - 给一条写入类用例加上查库断言(20 分钟)
找一条现有的下单/提交/保存类用例,在请求后面加一行 query_one,验证数据真的落库了。顺手把关联表也查一下。这一步的投入产出比在我看来是全篇里较高的。 - 串一条完整链路用例(半天)
下单 → 支付 → 查询详情,三个接口串起来,验证订单号和金额全程一致。这条用例能发现单接口测试永远发现不了的问题,跑一次你就知道值不值。 - 跑一次 20 并发的下单测试(1 小时)
先用 SQL 把库存精确设成 5,再用 ThreadPoolExecutor开 20 个线程同时下单,断言成功数恰好是 5。如果测出超卖,恭喜,你刚刚拦下了一个本来会在大促当天爆炸的 bug。 - 把四维校验搬进 conftest.py(1 天)
结构断言、业务码断言、数据库工具、Token 管理,全部收进 conftest.py。从这天起,新用例一律按四维写,老用例每次改到的时候顺手升级。
我的建议是按顺序做,别同时铺开。
前两条是地基,投入小、见效快,做完团队能立刻看到”哦原来真能测出问题”,后面推起来阻力就小很多。第 3 条链路测试相对重,等前两条稳定了再上。第 4 条并发测试建议挑核心接口做,不用全覆盖。
还有一个心态问题得说清楚:别想着一步到位。
接口测试的四维覆盖是个渐进过程,不可能一周之内把几百条老用例全改一遍。我们团队当年是花了两个多月才把核心链路铺完的,中间还有过反复。正确的节奏是:新写的用例一律按四维走,老用例等到有需求改到它时顺手升级。半年之后回头看,覆盖率自然就上来了。
写在末尾的一点想法
测试这个岗位,价值不在于”证明软件没问题”——那是不可能的。价值在于尽可能早地把问题捞出来。
四维框架做的事,就是把发现问题的时间点,从”上线之后”提前到”提测阶段”,从”客户投诉”提前到”CI 报红”。越早发现,修复成本越低,团队晚上睡得越踏实。
接口返回 200 从来都不是终点。它只是你开始验证的起点。
