接口测试
接口测试
一、工程案例:给一组订单接口做自动化并接进流水线
场景:订单服务有 6 个接口,回归全靠人点 Postman。上周改了一处序列化配置,列表里的 total 从数字变成字符串,前端分页当场崩,直到用户投诉才发现。目标是把这组接口做成一套 pytest 用例,git push 后 90 秒内给出结论,失败就挡住合并。
接口清单(都在 /api 下,认证走 Authorization: Bearer <token>):
| 方法 | 路径 | 说明 | 成功 | 主要失败 |
|---|---|---|---|---|
| POST | /api/orders | 创建订单 | 201 | 400 参数错、409 重复提交 |
| GET | /api/orders/{id} | 订单详情 | 200 | 401 未登录、403 非本人、404 不存在 |
| GET | /api/orders | 我的订单列表 | 200 | 401 |
| POST | /api/orders/{id}/cancel | 取消订单 | 200 | 403、409 状态不允许 |
| GET | /api/orders/{id}/items | 订单明细 | 200 | 403、404 |
| POST | /api/orders/{id}/pay | 支付回调(内部) | 200 | 400 签名错、409 已支付 |
响应约定:成功 {"code":"OK","data":{...}},失败 {"code":"ORDER_NOT_FOUND","message":"..."},HTTP 状态码与业务码同步。quantity 约定为 1–99 的整数,越界一律 400。这条约定是后面所有断言的地基——没有它,测试只能断言「不是 500」。
1.1 目录与依赖
python3 -m venv .venv && source .venv/bin/activate
cat > requirements-test.txt <<'EOF'
pytest
requests
pytest-html
EOF
pip install -r requirements-test.txt
mkdir -p tests/api依赖不锁死版本,装官方当前稳定版即可;需要复现历史结果时再用 pip freeze > requirements.lock。
requirements-test.txt 放仓库根目录,下面只列测试代码:
tests/
├── conftest.py # 登录、夹具、清理
├── api/client.py # 统一 base_url / 超时 / 日志
├── api/orders.py # 订单接口封装
├── test_order_create.py
├── test_order_query.py
├── test_order_cancel.py
└── test_order_security.py三层分工:client.py 只管发请求,orders.py 只管拼业务参数,测试文件只写「给定什么、期望什么」。路径一改,只动 orders.py 一处。
1.2 客户端:把地址、超时、日志收在一处
# tests/api/client.py
import os, logging, requests
log = logging.getLogger("api")
class ApiClient:
def __init__(self, base_url: str | None = None):
self.base_url = (base_url or os.environ["API_BASE_URL"]).rstrip("/")
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def set_token(self, token: str) -> None:
self.session.headers["Authorization"] = f"Bearer {token}"
def request(self, method: str, path: str, **kw) -> requests.Response:
kw.setdefault("timeout", 10) # 不设超时,CI 会挂到 job 超时
resp = self.session.request(method, f"{self.base_url}{path}", **kw)
log.info("%s %s -> %s %s", method, path, resp.status_code, resp.text[:200])
return respAPI_BASE_URL 用下标读取而不是 .get(..., "http://localhost:8080"):缺配置时应该当场报错,而不是悄悄去压本机。
# tests/api/orders.py
def create(client, sku: str, quantity: int):
return client.request("POST", "/orders", json={"sku": sku, "quantity": quantity})接口少时这层可以省;一旦路径或字段改名,有这层就只改一处,用例不动。
1.3 认证与数据夹具
# tests/conftest.py
import os, pytest
from api.client import ApiClient
def _login(user_env: str, pass_env: str) -> ApiClient:
c = ApiClient()
r = c.request("POST", "/auth/login",
json={"username": os.environ[user_env], "password": os.environ[pass_env]})
assert r.status_code == 200, f"登录失败: {r.status_code} {r.text}"
c.set_token(r.json()["data"]["accessToken"])
return c
@pytest.fixture(scope="session")
def buyer() -> ApiClient:
return _login("BUYER_USER", "BUYER_PASS")
@pytest.fixture(scope="session")
def stranger() -> ApiClient: # 另一个账号,专用于越权
return _login("OTHER_USER", "OTHER_PASS")
@pytest.fixture
def anon() -> ApiClient: # 不带 token,测 401 用
return ApiClient()
@pytest.fixture
def order(buyer):
"""造一单,用完取消;取消失败也不影响后续用例"""
r = buyer.request("POST", "/orders", json={"sku": "SKU-001", "quantity": 1})
assert r.status_code == 201, r.text
body = r.json()["data"]
yield body
buyer.request("POST", f"/orders/{body['id']}/cancel")两个客户端共用一个类,但 token 放在各自的 Session 上。不要用同一个 session 临时改 header——session.headers 是可变的,用完不还原就会串号,多线程并发时更会互相踩。
1.4 主流程:创建、查询、取消
# tests/test_order_create.py
import pytest
from api.orders import create
def test_创建订单返回201和约定字段(buyer):
r = create(buyer, "SKU-001", 2)
assert r.status_code == 201, r.text
body = r.json()
assert body["code"] == "OK"
data = body["data"]
assert isinstance(data["id"], int)
assert data["status"] == "CREATED"
assert data["quantity"] == 2
assert data["amount"] > 0 # 金额由服务端算,不采纳客户端传值
buyer.request("POST", f"/orders/{data['id']}/cancel")
@pytest.mark.parametrize("payload, expected, why", [
({"sku": "SKU-001", "quantity": 1}, 201, "正常"),
({"sku": "SKU-001", "quantity": 0}, 400, "数量为 0"),
({"sku": "SKU-001", "quantity": -1}, 400, "数量为负"),
({"sku": "SKU-001", "quantity": 99}, 201, "上限内"),
({"sku": "SKU-001", "quantity": 100}, 400, "超过上限"),
({"sku": "SKU-001"}, 400, "缺数量"),
({"sku": "", "quantity": 1}, 400, "空 sku"),
({"sku": "SKU-001", "quantity": "abc"}, 400, "类型错"),
({"sku": "SKU-001", "quantity": 1, "amount": 0.01}, 201, "金额字段被忽略"),
])
def test_创建订单参数校验(buyer, payload, expected, why):
r = buyer.request("POST", "/orders", json=payload)
assert r.status_code == expected, f"[{why}] 期望 {expected},实际 {r.status_code}:{r.text}"
if expected == 400:
assert r.json()["code"] != "OK", f"[{why}] 400 必须带业务错误码"
return
data = r.json()["data"]
assert isinstance(data["amount"], (int, float)), f"[{why}] 金额应为数字"
assert data["amount"] != payload.get("amount"), f"[{why}] 服务端不能采纳客户端金额"
buyer.request("POST", f"/orders/{data['id']}/cancel") # 201 分支自己清掉最后一条最容易被漏:客户端传 amount 时服务端必须无视它。这条挂了就是「越权改价」,损失比一个分页 bug 大得多。
# tests/test_order_query.py
def test_列表分页字段类型正确(buyer, order):
r = buyer.request("GET", "/orders", params={"page": 1, "size": 10})
assert r.status_code == 200
data = r.json()["data"]
assert isinstance(data["total"], int) # 就是这条拦住上周的事故
assert isinstance(data["items"], list)
assert isinstance(data["hasNext"], bool)
assert data["page"] == 1 and data["size"] == 10 and len(data["items"]) <= 10
def test_详情能查到刚创建的单(buyer, order):
r = buyer.request("GET", f"/orders/{order['id']}")
assert r.status_code == 200 and r.json()["data"]["id"] == order["id"]
def test_查询不存在的订单返回404(buyer):
r = buyer.request("GET", "/orders/999999999")
assert r.status_code == 404 and r.json()["code"] == "ORDER_NOT_FOUND"# tests/test_order_cancel.py
def test_取消后状态变为CANCELLED(buyer, order):
assert buyer.request("POST", f"/orders/{order['id']}/cancel").status_code == 200
again = buyer.request("GET", f"/orders/{order['id']}")
assert again.json()["data"]["status"] == "CANCELLED" # 再查一次才算落库
def test_重复取消返回409(buyer, order):
buyer.request("POST", f"/orders/{order['id']}/cancel")
r = buyer.request("POST", f"/orders/{order['id']}/cancel")
assert r.status_code == 409 and r.json()["code"] == "ORDER_STATUS_CONFLICT"1.5 越权与幂等:两条最值钱的用例
# tests/test_order_security.py
import uuid
def test_未登录返回401(anon):
assert anon.request("GET", "/orders").status_code == 401
def test_他人订单一律403且不泄露内容(stranger, order):
for method, path in [("GET", f"/orders/{order['id']}"),
("GET", f"/orders/{order['id']}/items"),
("POST", f"/orders/{order['id']}/cancel")]:
r = stranger.request(method, path)
assert r.status_code == 403, f"{method} {path} 应为 403,实际 {r.status_code}:{r.text}"
assert "SKU-001" not in r.text, f"{method} {path} 泄露了订单内容"越权用例有三个容易写错的地方:
| 写法 | 问题 |
|---|---|
| 用不存在的 id 测越权 | 返回 404,看起来「拦住了」,其实归属校验没执行 |
| 只断言状态码 | 403 的响应体里可能带着订单详情 |
| 只测 GET | 取消、支付、删除才是真正造成损失的入口 |
幂等用 Idempotency-Key:
def test_同一幂等键重复提交只创建一单(buyer):
key = f"it-{uuid.uuid4()}"
payload = {"sku": "SKU-002", "quantity": 1}
first = buyer.request("POST", "/orders", json=payload, headers={"Idempotency-Key": key})
second = buyer.request("POST", "/orders", json=payload, headers={"Idempotency-Key": key})
assert first.status_code == 201 and second.status_code in (200, 201)
order_id = first.json()["data"]["id"]
assert second.json()["data"]["id"] == order_id
items = buyer.request("GET", "/orders", params={"page": 1, "size": 100}).json()["data"]["items"]
active = [o for o in items if o["sku"] == "SKU-002" and o["status"] != "CANCELLED"]
assert len(active) == 1, f"同一幂等键落了 {len(active)} 单"
buyer.request("POST", f"/orders/{order_id}/cancel") # 让下次运行仍从 1 开始数1.6 接进流水线
# .github/workflows/order-api.yml
name: Order API
on: [push, pull_request]
jobs:
api:
runs-on: ubuntu-latest
timeout-minutes: 15
env: # 所有步骤共用:冒烟也要登录
API_BASE_URL: http://localhost:8080/api
BUYER_USER: ${{ secrets.BUYER_USER }}
BUYER_PASS: ${{ secrets.BUYER_PASS }}
OTHER_USER: ${{ secrets.OTHER_USER }}
OTHER_PASS: ${{ secrets.OTHER_PASS }}
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: orders }
ports: ["5432:5432"]
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=5s --health-timeout=3s --health-retries=10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: '21' }
- run: ./gradlew bootJar --no-daemon # 构建命令与产物路径按项目实际替换
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: requirements-test.txt
- run: pip install -r requirements-test.txt
- name: 启动应用并等就绪
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/orders
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: postgres
run: |
nohup java -jar build/libs/order-service.jar > app.log 2>&1 &
for i in $(seq 1 60); do curl -sf http://localhost:8080/actuator/health && break; sleep 2; done
curl -sf http://localhost:8080/actuator/health || { tail -50 app.log; exit 1; }
- name: 冒烟(先证明脚本能连通)
run: pytest -q tests/test_order_query.py::test_查询不存在的订单返回404
- name: 全量接口测试
run: pytest -q --html=report.html --self-contained-html
- uses: actions/upload-artifact@v4
if: always()
with: { name: api-report, path: report.html }应用启动时要自己跑迁移(Flyway/Liquibase)或预置 schema,否则第一条写用例就 500;环境变量名按框架实际调整。来自 fork 的 PR 拿不到 secrets,这类流水线要么只对同仓库分支跑,要么改用一次性公共账号;示例里的 Action 与运行时版本以官方当前稳定版为准。
流水线里只有两个关键点:等健康检查真的通过再跑测试,以及让失败以非 0 退出码传出去。pytest 有失败用例默认返回 1;如果你写了 pytest || true,门禁就彻底失效了。
1.7 验收:11 项全过才算交付
| # | 检查项 | 命令或检查 | 通过标准 |
|---|---|---|---|
| 1 | 地址来自环境 | grep -rn "localhost:8080" tests/ | 无输出 |
| 2 | 依赖齐全 | pip install -r requirements-test.txt | 无报错 |
| 3 | 应用可用 | curl -sf http://localhost:8080/actuator/health | grep -q '"status":"UP"' | 退出码 0 |
| 4 | 冒烟通过 | pytest -q -k 不存在 | 1 passed |
| 5 | 全量通过 | pytest -q | 0 failed |
| 6 | 用例互相独立 | pytest -q 连跑两次 | 两次结果一致 |
| 7 | 失败能定位 | 故意改错一条断言后跑 | 报错含实际响应体 |
| 8 | 活跃数据不增长 | psql -c "select count(*) from orders where sku like 'SKU-%' and status <> 'CANCELLED'" | 不随运行次数增长 |
| 9 | 越权覆盖 | pytest -q tests/test_order_security.py | 全过 |
| 10 | 报告可查 | test -s report.html | 文件存在且非空 |
| 11 | 门禁有效 | 注入一条必失败用例后跑 pytest -q; echo $? | 返回 1 |
第 8 项容易被忽略:夹具只创建不清理,跑一周后测试库堆了几十万条数据,分页用例开始变慢甚至超时——测试自己也会制造性能问题。订单通常只取消不删除,所以“取消”只保证活跃数据不增长;表总量仍会涨,最终得靠 3.6 里的前缀 + 定期清理兜住。
二、高频问答:会卡住你的地方,也是面试最常问的
2.1 返回 200 就认为通过
现场
assert r.status_code == 200
body = r.json()
assert body["code"] == "OK" # 业务码
assert body["data"]["id"] == order_id # 关键字段用错误参数发一次请求,看状态码是否仍然是 200,若是则业务码必须进断言。
要点
- 返回 200 不等于业务成功,很多系统所有响应都是 200,业务失败藏在 body 的
code里。 - 完整断言要包含业务码和关键字段,否则永远测不出失败。
- 核实方式:用错误参数发一次请求,状态码若仍是 200,说明所有用例都要先断言业务码。
追问
- 为什么 200 不可信?→ 业务框架统一收口返回 200,错误只在 body。
- 断言要覆盖哪几层?→ 传输、协议、业务、数据四层,写操作至少要到数据层。
- 那 500 怎么处理?→ 任何用例都不该期望 500,但要以实际响应体为判据而非状态码。
2.2 断言整个响应体相等
现场
# ✘ 把整个 data 全等比较,加一个字段就红
assert r.json()["data"] == {"id": 1001, "status": "CREATED"}
# ✔ 只断言契约字段
data = r.json()["data"]
assert data["status"] == "CREATED" and isinstance(data["id"], int)连续跑两次,如果 id 或时间不同就红,说明断言写得太死。
要点
- 整个响应体全等比较是把「快照」当「契约」,新增字段、时间戳、自增 id 都会让比较失败。
- 只断言业务承诺过的字段,且优先断言类型与取值集合,而不是具体值。
- 核实方式:连续跑两次,若 id 或时间不同就红,就是断言写死的证据。
追问
- 自增 id 怎么断言?→ 只断言是 int、大于 0,不写死具体值。
- 契约变化算 bug 吗?→ 是,字段改名或类型变化属于接口漂移,要单独用例盯。
- 列表字段要全断言吗?→ 只断言长度上限和元素的关键字段,不必逐字段。
2.3 用例之间有顺序依赖
现场
# ✘ 上一条用例把 id 留在模块变量里
ORDER_ID = None
def test_创建(buyer):
global ORDER_ID
ORDER_ID = buyer.request("POST", "/orders", json={"sku": "SKU-001", "quantity": 1}).json()["data"]["id"]
def test_详情(buyer):
r = buyer.request("GET", f"/orders/{ORDER_ID}") # 单跑时 ORDER_ID 是 None逐条单跑,每条都应独立通过;再随机顺序跑一遍,结果应完全一致。
要点
- 把「业务流程」当成「执行顺序」会埋雷:后一条复用前一条的 id,单跑必 404,并行更乱。
- 数据准备必须是每条用例自己的事,用夹具(
order)在用例内造数据并清理。 - 核实方式:逐条单跑全过,再用
--random-order或手动调换文件顺序,结果一致才算解耦。
追问
- 为什么并行会乱?→ 多用例同时跑,模块变量被互相覆盖。
- 顺序依赖和共享状态是一回事吗?→ 不是,顺序依赖是数据靠前条造,共享状态是变量被踩。
- 怎么快速发现?→ 单跑 + 随机顺序两次结果一致。
2.4 固定业务字段撞唯一索引
现场
import uuid
payload = {"sku": "SKU-001", "quantity": 1,
"clientOrderNo": f"it-{uuid.uuid4().hex[:8]}"} # 带随机后缀同一条用例连跑两次,第二次失败且错误码指向唯一约束,就是它。
要点
- 第二次跑报 409 多半不是接口 bug,而是每次提交同一个
clientOrderNo,被唯一索引拦下。 - 测试数据要当测试数据用,要么唯一化,要么用完就删,不能当稳定的业务数据。
- 核实方式:同一条用例连跑两次,第二次 409 且错误码是
DUPLICATE而非 500。
追问
- 为什么是 409 不是 500?→ 唯一约束冲突是业务可预期冲突,框架按冲突返回。
- 用完就删成本高怎么办?→ 用前缀 + 定期清理,或独立租户隔离。
- 唯一索引能当幂等吗?→ 不能,客户端仍拿 409,要服务端翻译成首次结果。
2.5 token 写死在脚本里
现场
# ✘ 过期即全红
HEADERS = {"Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9...."}
# ✔ 会话级登录夹具,见 1.3用一条过期 token 发请求,看是否 401。
要点
- 把「一次调试成功」当成「长期可用」会翻车:token 有有效期,跑得久或隔天就 401。
- 正确做法是
scope="session"的登录夹具,失败信息里明确写「登录失败」。 - 核实方式:用一条过期 token 发请求,若返回
UNAUTHORIZED而非业务错误,就是写死的证据。
追问
- 会话级夹具也会失效吗?→ 套件跑得比有效期长会中途失效,要在 401 上重登一次。
- 为什么不放全局 header?→ session 是可变对象,临时改 header 会串号。
- token 过期时间能调长吗?→ 不能,那只是把问题往后推。
2.6 地址硬编码成 localhost
现场
curl -sv "$API_BASE_URL/orders" -H "Authorization: Bearer $TOKEN" 2>&1 | head -20在流水线里打印 API_BASE_URL 并 curl 一次。
要点
- CI 里应用在别的容器或机器,
localhost指向 runner 自己,所以本地好、CI 全拒绝。 - 地址必须来自环境变量,且配置缺失时应报错而不是回退到 localhost。
- 核实方式:流水线里打印
API_BASE_URL并 curl,连接被拒且本机端口无服务就是硬编码。
追问
- 为什么缺配置要报错?→ 回退 localhost 会让压测、联调、CI 静默打错目标。
- 环境变量从哪来?→ CI 用 secrets 注入,本地用
.env,不进仓库。 - 多个环境怎么办?→ 一个
API_BASE_URL覆盖,不写死任何地址。
2.7 请求没有超时,整条流水线卡死
现场
session.request(method, url, timeout=(3.05, 10)) # 连接 3s、读取 10spytest --durations=10 看哪条最慢;若耗时等于 job 超时时间,就是没超时保护。
要点
- 把「慢」当成「死」会误判:某个接口不回包,
requests默认无限等待,job 挂到平台超时。 - 超时值要小于 CI 的
timeout-minutes,让测试自己报错,而不是被平台杀掉。 - 核实方式:
--durations=10里某条耗时等于 job 超时,就是没有超时保护。
追问
- 连接超时和读取超时是同一个吗?→ 不是,要分别设,连接 3s、读取更长。
- 超时设太短会误报吗?→ 会,要略大于正常 P99。
- 为什么不能靠平台超时?→ 平台超时拿不到失败现场,还浪费配额。
2.8 越权用例用了不存在的 id
现场
# ✘ 永远 404,测不出任何东西
stranger.request("GET", "/orders/999999999")
# ✔ 用夹具造出的真实 id
r = stranger.request("GET", f"/orders/{order['id']}")
assert r.status_code == 403准备两个账号,A 造一单,B 用真实 id 访问。
要点
- 返回 404 不能证明拦住了——404 是「资源不存在」,归属校验可能压根没跑。
- 只有真实存在且属于他人的资源,才能验证归属校验。
- 核实方式:换一个真实存在、属于别人的 id,看返回 403 还是 200。
追问
- 为什么不能直接构造 id?→ 不存在的 id 走的是查无此数据的分支,不是鉴权分支。
- 只测 GET 够吗?→ 不够,取消、支付、删除才是真正造成损失的入口。
- 403 的响应体要断言吗?→ 要,可能带着订单详情。
2.9 CI 里用 sleep 等应用启动
现场
for i in $(seq 1 60); do curl -sf http://localhost:8080/actuator/health && break; sleep 2; done
curl -sf http://localhost:8080/actuator/health || { tail -50 app.log; exit 1; }连续跑 10 次流水线,统计启动耗时分布。
要点
- 把「等待」当成「就绪」不可靠:
sleep 30机器慢时不够,快时白等,失败还没有日志。 - 就绪的判断标准只能是健康检查返回 200,不能是时间过去了多久。
- 核实方式:连续跑 10 次,统计启动耗时分布,超时说明 sleep 不够或健康检查没生效。
追问
- 为什么失败没日志?→ sleep 超时后直接进测试,应用日志还没刷出来。
- 健康检查不通怎么办?→ 失败分支要
tail app.log再exit 1,把现场带出来。 - 启动真的要 60 秒?→ 那是上限,实际轮询到就停,不会干等满。
2.10 签名接口把时间戳和 nonce 写死
现场
import hashlib, hmac, json, time, uuid
body = json.dumps(payload, separators=(",", ":"))
ts, nonce = str(int(time.time())), uuid.uuid4().hex
msg = f"{ts}\n{nonce}\n{body}" # 拼接顺序按接口文档
sign = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()把 ts 改成 10 分钟前再发一次,应被拒。
要点
- 服务端校验
ts在几分钟窗口内、nonce未被用过,写死的值第一次就过期或重放。 - 签名参数必须每次请求现算,并发请求的
nonce还要各自唯一。 - 核实方式:把
ts改成 10 分钟前再发,应被拒(SIGN_EXPIRED/NONCE_REPLAY)。
追问
- CI 机器时钟偏移会怎样?→ 同样会让窗口校验失败,要校时。
- nonce 为什么不能复用?→ 服务端记已用 nonce,重放直接拒。
- 算法细节要自己实现吗?→ 以对接文档为准,只保证参数动态生成。
三、核心知识:从零开始要建立的东西
3.1 接口测试的定义与边界
接口测试是绕过界面,直接向服务端发请求,并检查响应。输入是方法、URL、Header、Body,输出是状态码、响应头、响应体。它验证「这个业务动作在服务端做对了吗」,不验证按钮位置、颜色、动画。
| 层次 | 输入 | 能发现 | 不能发现 |
|---|---|---|---|
| 单元测试 | 函数参数 | 算法与分支 | 通常不覆盖序列化、路由、鉴权 |
| 接口测试 | HTTP 请求 | 契约、校验、权限、事务 | 页面渲染、交互 |
| 端到端测试 | 浏览器操作 | 用户流程能否走通 | 具体是哪一层错 |
边界要清楚:接口测试不能替代单元测试(定位粒度太粗),也不能替代端到端测试(不经过前端)。它的价值在投入产出比——比 UI 稳定,比单元真实。另一条边界是替身:mock 越多,测试越像单元测试,连「数据真的落库了吗」都测不到,所以只该 mock 支付、短信这类外部不可控依赖。
面试:常问「接口测试和单元测试、端到端测试的区别是什么?」答:接口测试绕过界面直接发请求验响应,比 UI 稳定、比单元真实,但定位粒度粗、不经过前端。
3.2 一次请求的完整生命周期
每个环节都能成为一条用例:路由错了是 404,认证漏了是 200 而不是 401,授权漏了是别人的数据,校验漏了是脏数据入库,序列化错了是字段名或类型变化。排查失败时按这个顺序从后往前推,比盯着响应体猜快得多。
3.3 断言的四层结构
| 层 | 断言什么 | 漏了会怎样 |
|---|---|---|
| 传输层 | 无异常、耗时在阈值内 | 把超时当成业务失败 |
| 协议层 | 状态码、Content-Type | 500 被当成 200 处理 |
| 业务层 | code、关键字段、字段类型 | 返回了别人的数据也发现不了 |
| 数据层 | 再查一次或查库确认 | 接口说成功,其实没落库 |
四层不必每条用例都写满,但写操作至少要覆盖到数据层。只断言返回值,等于相信了被测系统对自己的描述。
面试:常问「接口测试断言要分几层?为什么写操作要查库?」答:传输、协议、业务、数据四层,写操作至少到数据层,否则接口说成功其实没落库。
3.4 状态码的语义与常见误用
| 码 | 语义 | 接口测试里的期望 |
|---|---|---|
| 200 | 成功 | 查询、更新成功 |
| 201 | 已创建 | 创建成功,通常带 Location |
| 204 | 成功但无内容 | 删除成功,响应体应为空 |
| 400 | 请求本身有问题 | 参数缺失、类型错、格式错 |
| 401 | 未认证 | 缺 token、token 过期、签名错 |
| 403 | 已认证但无权限 | 越权、角色不足 |
| 404 | 资源不存在 | id 不存在;也可用于隐藏资源 |
| 409 | 与当前状态冲突 | 重复创建、状态机不允许 |
| 422 | 语义校验失败 | 格式对但业务规则不满足 |
| 429 | 限流 | 触发频率限制,带 Retry-After |
| 500 | 服务端异常 | 任何用例都不该期望 500 |
两个边界:401 与 403 不能混用(前者是「你是谁」,后者是「你能做什么」);404 与 403 的取舍是安全性与可调试性的权衡——404 不泄露资源是否存在,403 更好排查,团队要统一并在测试里固化。
面试:常问「401 和 403 有什么区别?404 和 403 怎么选?」答:401 是未认证、403 是已认证无权限;404 不泄露资源存在,403 更好排查,要团队统一。
3.5 认证态在测试中的表达
| 方式 | 怎么带 | 测试注意 |
|---|---|---|
| Bearer Token | Authorization: Bearer xxx | 有有效期,跨会话要重新登 |
| Cookie-Session | 客户端保存 Cookie | 要验证登录前后 sessionId 变化 |
| API Key / 签名 | Header 里带 key 与签名 | 时间戳、nonce 会被校验,需动态生成 |
登录放夹具、token 放各自 Session是唯一稳妥的写法。共享一个可变 header 的 session,用例之间会串号;同一进程里多线程并发时更会互相覆盖。夹具里登录失败要直接 assert 失败,不能带着空 token 继续跑——否则后面 30 条用例会给出 30 个 401,掩盖真正的问题。
3.6 测试数据的生命周期
| 策略 | 做法 | 适用 |
|---|---|---|
| 每条自造自清 | 夹具创建,yield 后删除或作废 | 默认选择,最稳 |
| 前缀 + 定期清理 | 数据带 it_ 前缀,定时任务清 | 无法删除的场景(如已支付订单) |
| 独立租户 | 每次跑在独立租户下 | 多租户系统,隔离最彻底 |
| 快照回滚 | 事务包裹,结束回滚 | 只适合纯数据库层测试 |
判断标准只有一条:同一条用例连跑两次,结果必须相同。做不到就说明数据有残留或依赖了外部状态。
3.7 参数化与数据驱动
@pytest.mark.parametrize("quantity, expected", [
(1, 201), (99, 201), (0, 400), (-1, 400), (100, 400),
])
def test_数量边界(buyer, quantity, expected):
r = buyer.request("POST", "/orders", json={"sku": "SKU-001", "quantity": quantity})
assert r.status_code == expected参数化把「一个分支一条函数」压成一张表,失败时 pytest 会给出 [0-400] 这样的用例 ID,一眼看出哪个边界挂了。边界值取「刚好通过 / 刚好不通过」两侧:上限 99 通过、100 不通过,两个都要有。201 的分支同样要清理,否则 3.6 的自造自清就落空了。
数据量大时把表放到 YAML/CSV,但不要为了「数据驱动」把三条用例拆成三个文件——可读性比形式重要。
3.8 夹具的作用域与共享边界
| 作用域 | 生命周期 | 适合 |
|---|---|---|
function | 每条用例 | 业务数据(订单、任务) |
class | 每个类 | 一组相关用例共享的上下文 |
module | 每个文件 | 模块级只读配置 |
session | 整个测试会话 | 登录客户端、数据库连接 |
作用域越大越省时间,也越容易互相污染。只读的东西往上提,会变的东西留在 function。有团队把「当前用户」做成 session 级并中途改 header,同一个进程里并发跑时 30% 的用例串号——这类问题排查成本极高。
3.9 幂等与并发的验证方式
幂等是「同样的请求重复发,结果只生效一次」。三种实现与对应测法:
| 实现 | 测法 | 期望 |
|---|---|---|
幂等键 Idempotency-Key | 同键发两次 | 两次返回同一资源 id |
| 唯一索引 | 同参数并发发 N 次 | 只落 1 条;其余 409(是冲突,不是幂等重放) |
| 状态机 | 对已完成订单再取消 | 409,且状态不变 |
唯一索引只保证「不重复」,客户端拿到的仍是 409;要让它表现成幂等,还得由服务端或网关把冲突翻译成第一次的结果。
并发要用真并发,不是 for 循环:
# 10 个相同请求同时到达
seq 10 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "$API_BASE_URL/coupons/1001/claim" -H "Authorization: Bearer $TOKEN" \
| sort | uniq -cfor 循环是串行的,第一个成功、后面全 409,看起来「幂等没问题」,其实并发窗口根本没打开。
3.10 契约测试的定位
契约测试验证「前端以为的响应」和「后端实际给的响应」是否一致,只验状态码、字段名、类型、必填性和枚举,不验业务逻辑。
| 接口测试 | 契约测试 | |
|---|---|---|
| 关注 | 行为对不对 | 两端约定一致吗 |
| 谁维护 | 测试/开发单方 | 前后端共同 |
| 典型产出 | 用例与报告 | 契约文件 + 验证结果 |
| 能发现 | 业务 bug、校验 bug | 字段改名、类型变化 |
落地有两种:轻量做法是在接口测试里加一条「契约断言」,把前端依赖的字段名写死;重量做法是用 Pact 之类工具生成契约文件,在提供方流水线里验证。小团队用前者就够了,成本低、见效快。
3.11 报告与门禁
一条合格的失败信息要包含四件事:请求、期望、实际、上下文。
assert r.status_code == 201, f"创建订单失败 status={r.status_code} body={r.text}"
assert data["status"] == "CREATED", f"初始状态错误,实际 {data}"| 设计点 | 做法 | 反例 |
|---|---|---|
| 触发 | push 与 PR 都跑 | 只在发布前跑 |
| 依赖 | service 容器 + 健康检查 | 连共享测试库 |
| 顺序 | 冒烟 → 全量 | 直接全量,挂了分不清是环境还是代码 |
| 超时 | job 15 分钟,请求 10 秒 | 无超时,挂一小时 |
| 产物 | HTML 报告 + 应用日志 | 只留一行 failed |
| 阻断 | 非 0 退出码 + 分支保护 | || true |
门禁的意义是让问题在合并前暴露。如果一条用例经常无缘无故失败,正确做法是修它或删它,而不是加 retry、continue-on-error——那只是把红灯涂成绿色。
3.12 覆盖率的正确口径
接口测试不适合用行覆盖率衡量(客户端脚本统计不到服务端代码)。要看覆盖率,得在服务进程里挂 JaCoCo、coverage 之类的采集器,成本不低,而且测试流量只走到部分分支,数字容易误导。可用口径有三个:
| 口径 | 怎么算 | 用途 |
|---|---|---|
| 接口覆盖 | 已测接口数 / 总接口数 | 最容易汇报,也最粗 |
| 分支覆盖 | 每个接口的正常 + 主要异常分支 | 实用,能发现漏测 |
| 契约覆盖 | 前端依赖字段是否都被断言 | 防联调事故 |
比起追求百分比,更值得问的是:每个写接口有没有「成功后重新查询」的用例?每个带 id 的接口有没有越权用例?每个状态机有没有非法流转用例? 这三类问题漏一个,就是线上事故。
面试:常问「接口测试覆盖率怎么看才算够?」答:不看行覆盖率,看写接口有无重查用例、带 id 接口有无越权用例、状态机有无非法流转用例。
四、冷门知识汇总
这些场景不常遇到,遇到时先按关键词查文档,再动断言:
| 知识点 | 为什么冷门 | 用到时的关键词 |
|---|---|---|
| GraphQL 单端点测试 | 所有请求都是 POST /graphql,多数实现仍返回 200,错误藏在 errors 数组 | query / variables / errors[0].extensions.code |
| gRPC 测试 | HTTP/2 + protobuf,不能像 REST 那样直接 curl,需要专用客户端 | grpcurl -plaintext -d / .proto |
| WebSocket 与 SSE | 长连接没有「一次请求一次响应」的结构,断言要按消息序列做 | onmessage / Last-Event-ID |
| 游标分页 | offset 深分页时 LIMIT 100000,20 越来越慢,响应契约也换成 cursor | cursor / nextCursor / hasNext |
| 时区断言 | 容器常是 UTC,响应带偏移的 ISO 8601,直接做字符串相等容易错 | createdAt / Instant / Asia/Shanghai |
| 大响应与流式下载 | 一次性读进内存会把测试机打爆 | stream=True / iter_content |
| 文件上传边界 | 空文件、超大文件、同名覆盖、文件名含路径 | files= / filename= |
| 重定向与 3xx | 接口不该 302 到登录页,客户端默认行为还会吞掉它 | allow_redirects=False / Location |
| CORS 预检 | 预检由浏览器自动发起,普通接口测试不会触发 | Origin / Access-Control-Request-Method |
| 限流与退避 | 429 不是 bug,但脚本必须识别并停止 | Retry-After / X-RateLimit-Remaining |
| 条件请求与 304 | 带上 If-None-Match 后服务端可能回 304 且没有响应体,断言 200 会误判 | ETag / If-None-Match / Cache-Control: no-cache |
| 压缩与分块传输 | requests 默认自动解压 gzip,但手写 Accept-Encoding 或按原始字节读时拿到的是压缩内容 | Accept-Encoding / Content-Encoding / chunked |
| 文档与实现漂移 | 手写用例只覆盖想得到的字段,openapi.json 与真实返回的差异常被漏掉 | openapi.json / schemathesis / 契约漂移 |