返回自动化工程模块
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) == -2
08

从接口响应追到真实业务数据

核对最终事实
响应、数据库和异步事件必须讲述同一个结果
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 管理环境,用报告保留证据

准备与清理由框架接管
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,并让失败能够快速定位

自动运行不是终点
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;不创建订单;不暴露内部信息

练习:把教程代码变成一个可运行项目

  1. 根据真实下单接口补全 BaseClient 和 OrderClient。
  2. 准备一个普通用户、两个不同归属的地址和一张满减券。
  3. 实现成功、边界、越权、过期令牌和幂等重试用例。
  4. 为成功与失败场景增加订单和库存数据核对。
  5. 生成 JUnit 或 Allure 报告,并确认敏感字段已经脱敏。
  6. 配置 smoke 与 regression 标记,让拉取请求只运行 P0 冒烟集。
  7. 故意改错一条金额断言,根据报告在 5 分钟内定位失败层级。

工程可维护

  • 请求集中封装
  • 数据可以识别和清理
  • 用例互不依赖
  • 配置不写死在代码中

结果可信

  • 协议和结构已检查
  • 业务规则已检查
  • 关键数据已核对
  • 失败副作用已检查

流水线可用

  • 冒烟集足够快
  • 失败证据完整
  • 机密信息已脱敏
  • 不稳定用例有责任人

当接口用例可以稳定运行后,继续学习 Mock 与测试桩,控制暂时不可用或难以稳定复现的外部依赖。

继续学习 Mock 与测试桩