六周接口自动化:让用例真正"能拦 bug"的四件事

测试 ·

去年做过一个接口自动化项目,从零搭到接进 CI,前后约六周。最后的数字是这样的:

指标 数值
覆盖业务模块 14 个(App 端 8 + Web 端 10)
测试文件 47 个
用例函数 325 个
参数化展开后实际用例 567 条
反向(异常)用例函数占比 约 51%
全量执行耗时 约 6 分钟

这篇不打算讲“怎么用 pytest”——那种文章满地都是。我想写的是六周下来真正改变了我做法的几个判断,其中有几条跟我一开始的想法是反的。

先说最核心的那条:一个通过率 100% 的用例集,很可能是负债而不是资产。

因为“全绿”有两种可能:系统真的没问题,或者你的用例根本测不出问题。而后者比前者常见得多。下面四件事,都是在往“能测出问题”这个方向拧。

一、每个用例自给自足

自动化最大的敌人是 flake 和脏数据。用例今天过明天挂,跑三次两次红,查半天发现是上一轮残留的数据把这一轮污染了——这种事出现两次,团队就不再信任这套用例了,而一套没人信的自动化等于没有。

我的原则是:每个用例自己创建需要的数据,跑完自动清理,不依赖执行顺序、不依赖环境预置数据。

pytest 的 fixture + yield 天然适合这件事:

@pytest.fixture
def team(cfg, auth_session):
    # --- yield 之前:前置准备 ---
    resp = auth_session.post(f"{cfg.BASE_URL}/group/create",
                             json={"groupName": "auto-test"})
    team_id = resp.json()["data"]["groupInfo"]["groupID"]

    yield team_id                    # --- 把数据交给用例 ---

    # --- yield 之后:后置清理(用例成败都会执行)---
    auth_session.post(f"{cfg.BASE_URL}/group/delete",
                      json={"groupID": team_id})

yield 把 fixture 切成两段,比 return 多了“用完回来收拾”的能力。关键在于后半段是无条件执行的——哪怕用例断言失败抛了异常,清理照样跑。这是“可重复执行”的前提。

用例这边只声明依赖:

def test_create_team(auth_session, team):
    resp = auth_session.post(f"{cfg.BASE_URL}/group/info", json={"groupID": team})
    assert resp.json()["data"]["groupName"] == "auto-test"
    # 结束后 team fixture 自动删团队

数据准备和清理对用例完全不可见,用例里只剩业务断言。

但别把 scope 开太大

fixture 有 function / class / module / session 几种作用域,很容易犯的错是“为了快,全提到 session 级”:

# ❌ 反面教材
@pytest.fixture(scope="session")
def shared_team(auth_session):
    ...

def test_update_team_name(shared_team): ...   # 改了团队名
def test_query_team_info(shared_team): ...    # 期待原名 → 挂

两条用例单独跑都过,一起跑就挂,而且挂哪条取决于执行顺序。这是最难查的一类问题。

我后来定的规矩是一句话:慢且只读的用 session 级,会被用例改动的一律 function 级。

登录是典型的“慢且可共享”——几百条用例没必要登几百次,提到 session 级登一次全程复用。而任何会被改动的业务数据,老老实实每个用例现造。

只读数据提上去的收益是实打实的:权限模块有 4 条纯查询用例,共用一个 session 级的只读团队之后,这个模块从 23 秒降到 11.5 秒。但前提是那 4 条确实一个字都不改它。

二、断言“业务真的发生了”,而不是“接口通了”

这是我修正得最狠的一个习惯。

先说一个坑:很多后端接口出错也返回 HTTP 200。 我这个项目就是,HTTP 状态码永远是 200(除非服务挂了或网关出错),真正的成败在 body 里。所以断言必须分两层:

assert resp.status_code == 200            # HTTP 层
assert resp.json().get("code") == 200     # 外层包装码:参数错 400 / RPC 错 500
data = resp.json()["data"]
assert data.get("status") == 0            # 业务码:0 成功 / -3 不在团队 / -140 无权限

只断 status_code == 200 的用例,基本等于没断言。

顺带一个细节,这个我栽过:不要写 data.get("status", 0) == 0。 那个默认值 0 会在响应结构畸形、压根没有 status 字段的时候悄悄让用例变绿。要么 data["status"](缺字段直接 KeyError,红得清楚),要么 data.get("status")(缺字段返回 None,也 != 0)。给断言设默认值,等于给自己发了一张免死金牌。

光断状态码还不够,要验业务效果

# ❌ 通过了 ≠ 真创建了
resp = auth_session.post(URL_CREATE, json={"groupName": "x"})
assert resp.json()["data"]["status"] == 0
# ✓ 状态 + 响应字段 + 业务规则 + 真实生效
data = get_data(auth_session.post(URL_CREATE, json={"groupName": name}))
assert data["status"] == 0
info = data["groupInfo"]
assert info["groupName"] == name
assert info["role"] == 2, "创建者应为 Owner"
assert info["memberNum"] == 1

# 再查一次列表,确认真的落库了
groups = {g["groupID"]: g for g in get_data(list_resp)["groups"]}
assert info["groupID"] in groups

我现在写 CRUD 一律走这个闭环:创建后查一致 → 列表校验包含 → 更新校验生效 → 删除后双重校验(列表不含 + 详情查不到)。只验“接口通了”会漏掉“返回数据错了”这一整类 bug,而后者才是线上真正会出事的。

二元对比要验方向

这条很隐蔽。测两个角色权限不同时,很自然会这么写:

# ❌ Owner 和 Member 的权限互换了,这条照样通过
assert ac_admin["manageTeam"] != ac_member["manageTeam"]

只验“不同”,那么“两边权限完全颠倒”这个相当严重的 bug 也能顺利通过。必须验方向:

# ✓
assert ac_admin["manageTeam"] == "Allow"
assert ac_member["manageTeam"] == "Deny"

同类的还有反向断言——不只断言“应该有什么”,还要断言“不应该有什么”。比如接口有 V1/V2 两套响应格式,测 V1 时除了断言 V1 字段齐全,还要断言 V2 独有字段必须缺席,否则服务端把两种格式混着返回的时候,V1 用例会蒙混过关。

三、反向用例的分流口诀

反向用例是这个项目里占比过半的部分(约 51% 的用例函数是异常向的),也是最能拦 bug 的部分。但反向用例怎么组织,我一开始是乱的——有的堆在一个大函数里,有的到处串联,写得又慢又难维护。

后来收敛成一句口诀:

参数能直接构造的,不串联,用参数化批量铺;状态要操作出来的,才串联。

参数异常——空值、超长、类型错、不存在的 ID——这些都能直接在请求体里构造出来,那就一行 parametrize 铺一片:

MISSING_REQUIRED = [
    (URL_ROLE_QUERY,  "role/query 缺 groupID",     {}),
    (URL_ROLE_ASSIGN, "role/assign 缺 bindUserIDs", {"groupID": "g", "roleID": 1}),
    (URL_ROLE_ASSIGN, "role/assign roleID=0",       {"groupID": "g", "bindUserIDs": ["u"], "roleID": 0}),
]

@pytest.mark.parametrize("url, name, body", MISSING_REQUIRED,
                         ids=[t[1] for t in MISSING_REQUIRED])
def test_missing_required(cfg, auth_session, url, name, body):
    resp = auth_session.post(f"{cfg.BASE_URL}{url}", json=body)
    assert resp.json().get("code") == 400, f"[{name}] {resp.json()}"

参数化和 for 循环铺数据的区别值得说一句:for 循环是一条用例跑多组,挂一组就停,报告里也只算一条;parametrize 每组是独立用例,独立计数、独立报告、互不阻塞。这才是参数化的价值。整个项目有 65 处参数化点,把 325 个函数放大成了 567 条实际用例——异常和边界的覆盖密度基本都是从这儿来的。

状态异常——删除后再查、越权访问、有成员的团队不能删——这些没法靠构造参数造出来,必须串联前置操作把系统推到那个状态,才能测。这类就老实写成串联用例。

分流之后,反向用例的编写效率和可读性都上了一个台阶。

一个隐蔽的假阳性

这是我在这个项目里发现的、最值得写下来的一个坑。

我写了一条用例,验证“管理员不能被移除”这条业务规则。用例是这样的:让一个普通成员去调删除接口删 Owner,断言返回 -140(拒绝)。跑通了,绿的。

但它是假阳性。因为普通成员根本没有“删人”的权限,请求在前置鉴权阶段就被拦了,压根没走到 handler 里那条“不能删 Owner”的业务规则。返回码恰好也是 -140,所以表面上看不出来——我以为我在测业务规则,实际上我在测权限拦截。

那条业务规则,从头到尾没有被覆盖过。

修法是先给这个成员赋予“删人”权限,绕开前置鉴权,让请求真正抵达业务规则,这时候再断言它被拦:

def test_cannot_remove_owner(...):
    """先给 member 赋 RemovePeople=Allow 绕开前置鉴权,
    这样请求才能到达 handler 内部的『目标是 Owner』检查,
    才是真正在测『Owner 不可删』这条业务规则。"""
    setup_custom_role(..., removePeople="Allow")
    resp = member_session.post(URL_MEMBER_DELETE, json={...})
    assert get_data(resp)["status"] == -140
    assert "owner" in (get_data(resp).get("msg") or "").lower()   # ← 关键

最后那行 msg 断言是解药:同一个 -140 可能来自好几条不同的拒绝路径,靠 msg 才能区分“到底是被谁拦的”。

这个坑的一般形式是:反向用例返回了你期望的错误码,但它是从另一条路径返回的。 错误码相同不代表走的是同一条逻辑。凡是写串联反向用例,都值得多问一句——我怎么确认它真的走到了我要测的那行代码?

四、写用例之前,先读服务端代码

这条听起来像废话,但它是我这个项目里 ROI 最高的习惯,没有之一。

接口自动化是黑盒测试,理论上不需要看实现。但不看实现,你的断言就只能靠猜——猜错误码、猜边界值、猜哪个参数是必填。而猜错的断言比没有断言更糟,因为它会给你虚假的安全感。

我固定读这几样:

读什么 决定用例的哪一块
路由定义 接口 URL、方法、对应的 handler
handler 函数 错误分支的顺序,反向用例该覆盖哪些点
请求结构体的 validate 标签 哪些字段必填、长度上限多少 → 边界值用例
错误码常量 断言里那个精确的数字(-3 / -9 / -140)
鉴权中间件 越权用例该断言哪个码

最后一条尤其重要。我这个项目里,不同接口走的鉴权链居然不一样:走公共前置检查套件的接口,一个“不在团队”的用户去调会返回 -140;而少数手写鉴权流的接口,同样的场景返回的是 -3。同一个越权场景,不同接口返回不同的码——这种事不看代码是绝对猜不到的,只能靠一次次跑挂了再改断言,那就成了“用例去适配实现”,本末倒置。

顺带说,读代码还有个副产品:你会看到一些不用测就知道有问题的地方。项目里风险最高的那个 bug(一个删除接口在不传任何过滤条件时会删掉整个团队的照片),就是先读代码觉得不对,才去构造请求验证的。这个留到系列第三篇讲。

关于文档:写“为什么”,不写“是什么”

最后补一条关于可维护性的。

用例的 docstring,我要求自己只写“为什么这么测”,不写“是什么”:

def test_role_query_v1_owner(cfg, auth_session, owner_team):
    """低版本 App(versionCode 低于 V2 门槛)→ 走 V1 兼容格式。
    admin 是 Owner,V1 格式下 roleID 应为 2。
    versionCode 用 pinned 值而不读 cfg,避免将来 cfg 升级
    把这条用例静悄悄推到 V2 分支还显示通过。"""

“调 POST 接口,断言 status=0”这种——看代码就知道,写了等于没写。真正需要写下来的是:这个断言的业务依据是什么(哪个常量、哪条规则)、为什么这么设计(为什么用固定值不读配置)、它和隔壁那条用例的区别在哪、哪些点故意没覆盖以及原因。

半年后回来改用例的人(大概率是你自己)需要的正是这些。

写在最后

回头看,这六周里技术上的东西——pytest 怎么用、fixture 怎么写——大概占了两成时间。剩下八成花在两件事上:搞清楚被测系统到底是怎么工作的,以及反复确认我的用例是不是真的在测我以为它在测的东西。

反向用例占到一半以上,不是刻意凑的比例,而是写着写着自然到了这个位置。因为正向用例的通过率再高,也只证明主路径没挂——而线上真正出事的地方,几乎全在异常、边界和权限上。

下一篇会写这个项目里技术上最有意思的一块:怎么让加密对 300 多条用例完全透明。