返回自动化工程模块一条自动化用例要验证完整的下单链路
工程分层:变化被隔离,用例保持可读 调用链:越靠上越接近业务语言 测试数据的一生必须闭环
断言金字塔:从快速响应检查走向真实业务结果
同一段测试逻辑,替换数据覆盖数量边界 同一笔下单请求要经过身份、权限和幂等三道门 响应、数据库和异步事件必须讲述同一个结果 Fixture 作用域由数据是否会变化决定 CI 中的接口测试需要快速、可重复、能诊断
Automation / Tutorial 12
接口自动化测试教程
从一条商城下单请求开始,搭建能读懂、能定位、能持续运行的接口自动化测试。
10 个章节9 组图解与表格pytest + requests / httpx
01
先把下单接口读成一份测试契约
请求只是起点你要自动验证的业务
用户携带登录令牌,提交商品、数量、地址和优惠券。系统需要校验用户身份与对象归属,计算金额,创建一笔待支付订单并扣减库存;网络重试不能生成重复订单。
01
测试用例
准备用户与订单数据
02
下单 API
认证并执行业务规则
03
数据与事件
订单、库存、优惠券
04
分层断言
确认结果和副作用
把接口文档转换成可检查的契约
| 部分 | 下单接口约定 | 需要验证什么 |
|---|---|---|
| 请求方法 | POST /api/orders | 创建资源使用 POST,不应被缓存为普通查询 |
| 认证 | Authorization: Bearer <token> | 缺失、过期、伪造和越权令牌都要验证 |
| 幂等 | Idempotency-Key | 相同业务请求重试时只能生成一笔订单 |
| 请求体 | sku_id、quantity、address_id、coupon_id | 类型、必填、边界和对象归属都要覆盖 |
| 成功响应 | 200 + order_id + amount + status | 字段值还要与服务端真实数据一致 |
| 失败响应 | 4xx/5xx + code + message | 失败时不能留下订单、库存或优惠券脏数据 |
下单接口返回 200,说明请求处理成功。但测试不能只看状态码,还要继续核对订单金额、库存扣减、用户权限以及重复提交是否只生成一笔订单。
02
先搭好能长期维护的工程骨架
按职责分层01 / 用例层
tests
业务条件与预期
02 / 接口层
clients
请求动作与协议细节
03 / 支撑层
helpers
断言、查询和等待
04 / 资源层
fixtures
环境、账号与数据
推荐项目结构
api-tests/
├─ pytest.ini # 标记、超时与报告配置
├─ requirements.txt # pytest、requests 或 httpx
├─ clients/
│ ├─ base_client.py # URL、请求、日志、超时
│ └─ order_client.py # 下单接口动作
├─ data/
│ └─ order_cases.py # 参数化数据
├─ helpers/
│ ├─ assertions.py # 可读的业务断言
│ └─ db.py # 只读数据核对
├─ tests/
│ └─ test_create_order.py # 用例只表达业务意图
└─ conftest.py # fixture 与清理策略测试文件负责什么
- 表达业务条件、操作和预期。
- 组合 fixture 与参数数据。
- 不直接拼接 URL 或编写数据库连接。
公共模块负责什么
- Client 统一请求和超时。
- Helper 统一断言与数据查询。
- Fixture 准备账号、数据并在结束后清理。
目录不是越多越专业。只有出现真实复用需求时才拆模块,但 URL、认证、超时和日志从第一天就应该集中管理。
03
封装请求,让用例只表达业务
隐藏技术噪声用例create_order(payload)
业务 Client路径、方法、幂等键
基础 Client域名、认证、超时、日志
HTTP 库requests / httpx
统一基础请求
# clients/base_client.py
import requests
class BaseClient:
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
})
def request(self, method: str, path: str, **kwargs):
# 所有请求必须有超时,避免 CI 一直等待
kwargs.setdefault("timeout", 10)
return self.session.request(
method, f"{self.base_url}{path}", **kwargs
)把下单动作封装成业务方法
# clients/order_client.py
from .base_client import BaseClient
class OrderClient(BaseClient):
def create_order(self, payload: dict, idempotency_key: str):
return self.request(
"POST",
"/api/orders",
json=payload,
headers={"Idempotency-Key": idempotency_key},
)
# 如果项目采用异步调用,可将 requests.Session 替换为
# httpx.AsyncClient,并使用 await client.post(...)。requests 还是 httpx
同步项目可以先使用 requests,API 简单、资料丰富;需要验证异步接口、并发请求或 ASGI 应用时,可以使用 httpx。无论选择哪个库,测试用例都不应该依赖它们的底层细节。
04
让每条用例拥有可识别的测试数据
可创建也可清理01
准备
创建专属用户与商品
02
标记
写入唯一 case_id
03
执行
发起下单与异常请求
04
核对
查询订单和库存
05
清理
只删除本轮数据
一组可执行的下单基线数据
| 对象 | 测试数据 | 用途 |
|---|---|---|
| 用户 | 普通会员 user_a | 拥有地址 address_a,不拥有 address_b |
| 商品 | sku_1001 | 单价 50 元,库存 5 件,可正常销售 |
| 优惠券 | coupon_20 | 满 100 减 20,归属于 user_a,未过期 |
| 清理策略 | 按 case_id 查询并删除 | 只清理本次测试创建的数据 |
使用工厂生成默认有效请求
# conftest.py 中的测试数据工厂
import uuid
def build_order_payload(**overrides):
payload = {
"sku_id": "sku_1001",
"quantity": 2,
"address_id": "address_a",
"coupon_id": "coupon_20",
"case_id": f"auto-{uuid.uuid4().hex[:10]}",
}
payload.update(overrides)
return payload不要让所有用例共用一张随时可能被修改的优惠券。每条用例都要能识别自己创建的数据,并明确哪些数据可以清理、哪些只能读取。
05
用分层断言判断订单是否真的正确
从响应追到副作用副作用层消息是否只发一次
数据层订单、库存、优惠券
业务层金额、状态、错误码
结构层字段存在且类型正确
协议层状态码、响应头、耗时
下单成功后逐层检查
| 层级 | 检查对象 | 下单示例 |
|---|---|---|
| 协议层 | 状态码、Content-Type、响应时间 | 200;application/json;小于约定阈值 |
| 结构层 | 字段、类型、必填结构 | order_id 为非空字符串,amount 为数字 |
| 业务层 | 金额、状态、错误码 | 100 - 20 = 80;状态为 PENDING_PAYMENT |
| 数据层 | 订单、库存、优惠券 | 一笔订单;库存减 2;优惠券被占用一次 |
| 副作用层 | 消息、日志、重复请求 | 只发送一次订单创建事件,不重复扣库存 |
一条下单用例的分层断言
def test_create_order(order_client, order_payload):
response = order_client.create_order(
order_payload, idempotency_key=order_payload["case_id"]
)
# 1. 协议层
assert response.status_code == 200, response.text
assert response.headers["Content-Type"].startswith("application/json")
body = response.json()
# 2. 结构层
assert isinstance(body["order_id"], str)
assert body["order_id"]
# 3. 业务层
assert body["status"] == "PENDING_PAYMENT"
assert body["amount"] == 80
# 数据层断言放在独立 helper 中,便于复用与诊断断言失败时要让人一眼看出“期望什么、实际是什么、是哪一层失败”。不要把整段响应与巨大 JSON 快照一次性比较。
06
用参数化覆盖边界,不复制测试代码
数据变化,逻辑不变0拒绝最小值前
1成功最小值
3成功有效类代表
5成功最大值
6拒绝最大值后
商品数量边界参数化
import pytest
@pytest.mark.parametrize(
"quantity, expected_status, expected_code",
[
(0, 400, "INVALID_QUANTITY"),
(1, 200, None),
(5, 200, None),
(6, 400, "INVALID_QUANTITY"),
],
ids=["below-min", "min", "max", "above-max"],
)
def test_order_quantity(
order_client, build_payload, quantity,
expected_status, expected_code,
):
payload = build_payload(quantity=quantity)
response = order_client.create_order(payload, payload["case_id"])
assert response.status_code == expected_status
if expected_code:
assert response.json()["code"] == expected_code参数化前先问一句
这些数据是否执行同一个业务动作并验证同一种规则?如果前置条件、流程和预期结构差异很大,就应该拆成独立用例,而不是塞进一张难以阅读的大表。
07
重点验证鉴权、越权和重复提交
交易链路的 P0 风险身份认证
令牌是否真实有效
对象授权
地址与优惠券是否属于当前用户
幂等控制
重试是否复用同一业务结果
鉴权与授权
- 没有令牌、令牌过期和签名错误时拒绝请求。
- user_a 不能使用 user_b 的地址、优惠券或订单。
- 错误响应不能泄露内部堆栈、密钥或其他用户信息。
幂等与重试
- 相同幂等键和相同请求只能产生一个业务结果。
- 相同幂等键但请求内容不同应明确拒绝。
- 首次请求超时后重试,不重复扣库存或占券。
同时检查响应与真实写入次数
def test_same_key_creates_only_one_order(
order_client, build_payload, order_repository,
):
payload = build_payload(quantity=2)
key = payload["case_id"]
first = order_client.create_order(payload, key)
second = order_client.create_order(payload, key)
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["order_id"] == first.json()["order_id"]
assert order_repository.count_by_case_id(key) == 1
assert order_repository.stock_change(key) == -208
从接口响应追到真实业务数据
核对最终事实01
接口响应
order_id · 80 元
02
订单表
一笔待支付订单
03
库存记录
5 → 3
04
优惠券
可用 → 已占用
05
订单事件
只发送一次
使用参数化 SQL 做只读核对
# helpers/db.py:测试账号只授予只读权限
class OrderRepository:
def __init__(self, connection):
self.connection = connection
def find_by_id(self, order_id: str):
with self.connection.cursor() as cursor:
cursor.execute(
"""SELECT order_id, user_id, amount, status
FROM orders WHERE order_id = %s""",
(order_id,),
)
return cursor.fetchone()
def assert_order_saved(repository, body, expected_user_id):
row = repository.find_by_id(body["order_id"])
assert row is not None
assert row["user_id"] == expected_user_id
assert row["amount"] == body["amount"]
assert row["status"] == body["status"]不能直连数据库时怎么办
优先调用只读查询接口、后台管理查询或事件检索接口核对结果。测试目标仍然是确认订单、库存和优惠券状态一致,而不是为了使用 SQL 强行获得生产数据库权限。
数据库断言要查询稳定的业务字段,不要依赖自增主键顺序、更新时间精度或内部临时字段。涉及异步消息时,应在明确超时时间内轮询最终状态。
09
用 Fixture 管理环境,用报告保留证据
准备与清理由框架接管@pytest.fixture(scope="session")
配置、客户端
整次测试会话复用
@pytest.fixture(scope="module")
模块专属账号
同一文件复用
@pytest.fixture(scope="function")
订单、优惠券状态
每条用例重新准备
按作用域准备客户端和用例数据
# conftest.py
import pytest
from clients.order_client import OrderClient
@pytest.fixture(scope="session")
def order_client(settings, user_token):
return OrderClient(settings.base_url, user_token)
@pytest.fixture
def build_payload(request):
created_case_ids = []
def _build(**overrides):
payload = make_order_payload(**overrides)
created_case_ids.append(payload["case_id"])
return payload
yield _build
# 清理动作必须按 case_id 精确定位,不能全表清空
cleanup_test_orders(created_case_ids)Fixture 使用原则
- session 级保存稳定的客户端和配置。
- function 级隔离会被修改的订单数据。
- yield 后执行精确清理,即使用例失败也不跳过。
- 用例不应依赖执行顺序。
报告至少保留
- 用例名、参数和运行环境。
- 失败断言、响应摘要和业务 ID。
- 开始时间、耗时和重试次数。
- 敏感令牌、手机号等信息必须脱敏。
10
接入 CI,并让失败能够快速定位
自动运行不是终点01
代码提交
触发拉取请求
02
环境检查
确认 API 可用
03
冒烟测试
运行 P0 用例
04
报告归档
始终保存证据
05
发布门禁
失败则阻断
在拉取请求中运行冒烟接口测试
# .github/workflows/api-tests.yml(示意)
name: api-tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest -m smoke --junitxml=reports/junit.xml
env:
BASE_URL: ${{ secrets.TEST_BASE_URL }}
TEST_TOKEN: ${{ secrets.TEST_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: api-test-report
path: reports/看到失败时按证据判断
| 现象 | 先检查 | 可能归类 |
|---|---|---|
| 连接失败或 502 | 环境健康、DNS、网关和依赖服务 | 环境或部署问题 |
| 401 集中出现 | 令牌有效期、时钟、密钥和账号状态 | 配置或鉴权变更 |
| 状态码正确但金额错误 | 请求数据、规则版本和金额明细 | 业务缺陷 |
| 偶发重复订单 | 幂等键、重试日志和写入次数 | 并发或幂等缺陷 |
| 只有一条数据失败 | 数据归属、库存和历史残留 | 测试数据问题 |
可以直接运行的核心下单用例
| 优先级 | 用例标题 | 关键预期 |
|---|---|---|
| P0 | 当库存等于购买数量时,创建订单成功 | 200;只生成一笔订单;库存变为 0 |
| P0 | 当使用相同幂等键重复提交时,只生成一笔订单 | 两次返回同一业务结果;只扣一次库存 |
| P0 | 当使用其他用户的地址时,订单创建失败 | 403 或明确业务错误;无数据写入 |
| P1 | 当购买数量为 0 时,接口拒绝请求 | 400;返回 quantity 错误;库存不变 |
| P1 | 当订单金额正好为 100 元时,满减券生效 | 实付 80 元;优惠券占用状态正确 |
| P1 | 当令牌过期时,订单创建失败 | 401;不创建订单;不暴露内部信息 |
练习:把教程代码变成一个可运行项目
- 根据真实下单接口补全 BaseClient 和 OrderClient。
- 准备一个普通用户、两个不同归属的地址和一张满减券。
- 实现成功、边界、越权、过期令牌和幂等重试用例。
- 为成功与失败场景增加订单和库存数据核对。
- 生成 JUnit 或 Allure 报告,并确认敏感字段已经脱敏。
- 配置 smoke 与 regression 标记,让拉取请求只运行 P0 冒烟集。
- 故意改错一条金额断言,根据报告在 5 分钟内定位失败层级。
工程可维护
- 请求集中封装
- 数据可以识别和清理
- 用例互不依赖
- 配置不写死在代码中
结果可信
- 协议和结构已检查
- 业务规则已检查
- 关键数据已核对
- 失败副作用已检查
流水线可用
- 冒烟集足够快
- 失败证据完整
- 机密信息已脱敏
- 不稳定用例有责任人
当接口用例可以稳定运行后,继续学习 Mock 与测试桩,控制暂时不可用或难以稳定复现的外部依赖。
继续学习 Mock 与测试桩