接口测试文档手册 · 实战篇
Postman / Apifox / JMeter / pytest 工具实操 · 以老师带练的方式,把工具真正用起来
这篇的定位
前面的几篇解决的是"为什么测"和"测什么"。这一篇解决的是"拿什么测"和"怎么把动作做标准"。你可以把它理解成接口测试的工具实验课。
第1章:Postman 实战
1.1 Postman 最适合做什么
| 场景 | 是否适合 | 原因 |
|---|---|---|
| 单接口调试 | 非常适合 | 修改参数快,查看响应方便 |
| 接口集合串联 | 适合 | Collection 和 Folder 很好管理场景 |
| 接口自动化回归 | 中等适合 | 可以做,但维护复杂度高于代码方案 |
| 大规模并发压测 | 不适合 | 不是它的强项 |
1.2 老师带练:5 分钟调通一个登录接口
- 新建 Collection,按业务模块分组,例如
Auth、Order、Refund。 - 新建 Request,方法选
POST,URL 填登录地址。 - Headers 中设置
Content-Type: application/json。 - Body 选择
raw + JSON,填入用户名密码。 - 点击发送,观察状态码、响应体、耗时。
{
"username": "tester01",
"password": "Pass@123"
}1.3 变量怎么用,才像团队在工作
不要把域名、token、账号写死在每个请求里。正确做法是把可变化的内容抽成变量。
| 变量 | 建议位置 | 示例 |
|---|---|---|
| 环境域名 | Environment | {{base_url}} |
| token | Environment / Collection | {{access_token}} |
| 公共账号 | Environment | {{test_user}} |
| 场景内临时值 | Collection Variables | {{order_id}} |
1.4 Postman 断言怎么写
pm.test("HTTP 状态码是 200", function () {
pm.response.to.have.status(200);
});
pm.test("业务码是 0", function () {
const body = pm.response.json();
pm.expect(body.code).to.eql(0);
});
pm.test("返回 token", function () {
const body = pm.response.json();
pm.expect(body.data.token).to.be.a("string");
});1.5 前置请求和接口串联
Postman 很适合拿来讲"登录 → 下单 → 查询订单"这种链路。做法是上一条请求把关键字段写进变量,下一条请求再引用。
const body = pm.response.json();
pm.environment.set("access_token", body.data.token);
pm.environment.set("user_id", body.data.user_id);课堂练习
- 用 Postman 建一个
AuthCollection。 - 完成登录、获取用户详情、退出登录 3 个请求串联。
- 给每个请求至少写 2 条断言。
第2章:Apifox 实战
2.1 Apifox 和 Postman 的区别,老师通常怎么讲
| 维度 | Postman | Apifox |
|---|---|---|
| 调试体验 | 成熟稳定 | 也很顺手 |
| 文档一体化 | 偏弱 | 更强 |
| 团队协作 | 可以做 | 文档、调试、测试更一体 |
| 适合培训 | 适合讲调试逻辑 | 适合讲接口全生命周期 |
2.2 最适合拿 Apifox 讲什么
- 接口文档和接口调试的一体化。
- 从接口定义直接生成调试请求。
- 接口用例和接口自动化的联动。
2.3 一条典型课堂路径
- 先打开接口文档,读字段说明。
- 直接点"调试",生成请求。
- 替换参数为测试数据。
- 运行后保存为测试用例。
- 把正常场景、异常场景、边界场景组成测试集。
示例
为什么培训里很适合用 Apifox 因为学员可以明显看到:文档不是摆设,文档、调试、测试集、自动化是连在一起的。这种工具更容易帮助新人建立工程化意识。
2.4 Apifox 里要特别强调的习惯
- 接口目录按业务模块整理,不要所有请求都堆在根目录。
- 正常用例和异常用例分文件夹管理。
- 环境变量命名统一,例如
base_url、token_admin、token_user。 - 每条核心用例写清前置条件和预期。
第3章:JMeter 实战
3.1 JMeter 在这套课程里的定位
JMeter 在这里不是作为"正式性能测试工具"来讲,而是作为接口测试的"批量执行器 + 轻并发验证器"来讲。重点是让学员理解:为什么幂等、重复提交、限流这类问题,单点发送很难暴露,而在并发场景下一下子就会显形。
3.2 一次完整带练顺序
- 先用 1 个线程把接口调通。
- 再加 CSV 参数化,让多个账号参与。
- 然后把线程数调到 10 或 20,观察业务结果。
- 最后联查数据库,确认不是"接口看起来都成功了",而是真正只执行了该执行的次数。
3.3 JMeter 课堂演示案例:重复提交不重复建单
线程数:20
Ramp-Up:1s
Loop Count:1
请求:
POST /api/orders
Header: Idempotency-Key = SAME-ORDER-KEY
观察点:
1. 成功响应有多少条
2. 返回的 order_id 是否相同
3. 数据库订单表里有几条记录
4. 库存扣减了几次课堂上一定要提醒
JMeter 的线程数不是越大越好。对功能验证来说,10 到 50 的轻量并发往往已经足够把很多幂等、锁、重复提交问题测出来。
第4章:pytest + requests 实战
4.1 为什么最终还要回到代码
因为当接口测试要进入持续回归、CI、版本对比时,代码方案在可维护性上通常会比纯工具更强。尤其是当你需要造数、清数、查库、封装鉴权、做复杂断言时,代码会更稳。
4.2 一个最小项目结构
api-tests/
├── tests/
│ ├── test_auth.py
│ ├── test_order.py
│ └── test_refund.py
├── common/
│ ├── client.py
│ ├── auth.py
│ └── db.py
├── data/
│ └── users.yaml
└── pytest.ini4.3 课堂示例:登录后查询用户详情
def test_user_profile_after_login(client):
login_resp = client.post("/api/login", json={
"username": "tester01",
"password": "Pass@123"
})
assert login_resp.status_code == 200
token = login_resp.json()["data"]["token"]
profile_resp = client.get(
"/api/user/profile",
headers={"Authorization": f"Bearer {token}"}
)
assert profile_resp.status_code == 200
body = profile_resp.json()
assert body["code"] == 0
assert body["data"]["username"] == "tester01"4.4 代码方案最值得讲的三个点
- 公共 client 封装,避免每个测试都重复写 URL 和 header。
- fixture 管理登录态、测试数据、环境配置。
- 把"接口断言 + 数据库断言 + 日志定位"组合成可复用能力。
第5章:环境与变量模板
5.1 培训时最容易漏讲,但项目里最重要
很多接口测试不是不会写,而是环境乱、变量乱、账号乱、数据乱,最后谁都跑不稳。所以这章专门讲模板。
环境变量建议:
base_url=https://test.example.com
admin_user=admin01
admin_password=Pass@123
normal_user=tester01
normal_password=Pass@123
db_host=10.10.10.10
db_name=test_db第6章:断言与数据校验模板
| 断言类型 | 示例 |
|---|---|
| 协议断言 | 状态码、header、响应时间 |
| 业务断言 | 业务码、状态字段、金额字段 |
| 副作用断言 | 订单表、库存表、消息表、日志 |
| 安全断言 | 越权拒绝、敏感字段不返回 |
示例
一个完整断言口径
- 接口返回正确。
- 数据库状态正确。
- 关联副作用正确。
- 失败时错误信息清晰。
第7章:缺陷与报告模板
7.1 一条缺陷最好怎么写
标题:
[订单模块] 重复提交创建两张订单
前置条件:
用户已登录,SKU-001 库存充足
复现步骤:
1. 使用相同请求体连续调用 POST /api/orders 两次
2. 两次请求间隔 200ms
实际结果:
返回两个不同 order_id
预期结果:
只创建一张订单,重复请求返回同一结果
附加信息:
request_id、响应体、数据库截图、日志关键字7.2 课程收尾建议
如果你要把这一套真拿去培训,最推荐的顺序是:
- 先讲基础篇,建立概念和理论。
- 再讲通用篇,让学员知道怎么拆点和写用例。
- 然后讲这篇实战篇,现场带着学员调工具。
- 最后再讲 AI 专项篇,把大模型接口测试拉起来。
课堂练习
- 任选一种工具,完整完成一次登录、下单、查询订单的链路调试。
- 再把同一个场景分别用 Postman / Apifox / pytest 三种方式各实现一次。
- 如果有精力,再用 JMeter 对下单接口做一次 20 并发的幂等验证。
第8章:AI 接口自动化完整项目
8.1 项目目标
前面用 Postman 和 pytest 做的都是传统接口。这一章把同样的工程化思路搬到大模型接口上:用 pytest 写一个可持续回归的 AI 接口测试项目,覆盖 Chat、流式、结构化输出和工具调用四类场景。
8.2 项目结构
ai-api-tests/
├── tests/
│ ├── conftest.py # 公共 fixture
│ ├── test_chat_basic.py # 基础对话
│ ├── test_stream.py # 流式输出 + TTFT
│ ├── test_structured.py # 结构化输出
│ └── test_tool_calling.py # 工具调用
├── common/
│ ├── ai_client.py # 封装 OpenAI 客户端
│ ├── evaluator.py # 质量评估(关键词 / LLM Judge)
│ └── metrics.py # 延迟、Token 计数
├── golden_sets/
│ ├── chat_golden.json # Chat 基线数据集
│ └── tool_golden.json # 工具调用基线
├── reports/
├── .env # API Key(不入库)
├── pytest.ini
└── requirements.txt8.3 conftest.py — 公共 fixture
import os, pytest
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
@pytest.fixture(scope="session")
def ai_client():
return OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
@pytest.fixture(scope="session")
def model_name():
return os.getenv("TEST_MODEL", "gpt-4o-mini")8.4 基础对话测试
# tests/test_chat_basic.py
import time, json
def test_chat_returns_200(ai_client, model_name):
"""契约层:接口能通、字段齐全"""
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "你好"}],
max_tokens=50
)
assert resp.id is not None
assert resp.choices[0].message.content
assert resp.choices[0].finish_reason == "stop"
assert resp.usage.total_tokens > 0
def test_chat_relevance(ai_client, model_name):
"""质量层:回答是否相关"""
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "法国的首都是哪里?"}],
max_tokens=100
)
answer = resp.choices[0].message.content
assert "巴黎" in answer or "Paris" in answer
def test_chat_latency(ai_client, model_name):
"""性能层:端到端延迟"""
start = time.time()
ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "1+1等于几?"}],
max_tokens=20
)
latency = time.time() - start
assert latency < 10, f"延迟 {latency:.2f}s 超过阈值"8.5 流式输出 + TTFT 测试
# tests/test_stream.py
import time
def test_stream_ttft(ai_client, model_name):
"""首 Token 延迟(TTFT)"""
start = time.time()
ttft = None
chunks = []
stream = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "写一首关于春天的短诗"}],
max_tokens=200,
stream=True
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
if ttft is None:
ttft = time.time() - start
chunks.append(chunk.choices[0].delta.content)
full_text = "".join(chunks)
assert ttft is not None, "没有收到任何内容 chunk"
assert ttft < 3.0, f"TTFT {ttft:.2f}s 超过阈值"
assert len(full_text) > 10, "生成内容过短"
def test_stream_no_gap(ai_client, model_name):
"""验证流式传输无异常中断"""
chunk_times = []
stream = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "列举 5 个水果"}],
max_tokens=100,
stream=True
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
chunk_times.append(time.time())
if len(chunk_times) > 2:
gaps = [chunk_times[i+1] - chunk_times[i] for i in range(len(chunk_times)-1)]
max_gap = max(gaps)
assert max_gap < 5.0, f"流式传输最大间隔 {max_gap:.2f}s,疑似中断"8.6 结构化输出测试
# tests/test_structured.py
import json
def test_json_mode(ai_client, model_name):
"""JSON 模式输出验证"""
resp = ai_client.chat.completions.create(
model=model_name,
messages=[
{"role": "system", "content": "以 JSON 格式回答,包含 name 和 capital 字段。"},
{"role": "user", "content": "日本的信息"}
],
response_format={"type": "json_object"},
max_tokens=100
)
text = resp.choices[0].message.content
data = json.loads(text)
assert "name" in data, f"缺少 name 字段: {data}"
assert "capital" in data, f"缺少 capital 字段: {data}"
def test_json_stability(ai_client, model_name):
"""多次调用 JSON 结构一致性"""
keys_sets = []
for _ in range(3):
resp = ai_client.chat.completions.create(
model=model_name,
messages=[
{"role": "system", "content": "以 JSON 回答,字段: title, summary, tags(数组)"},
{"role": "user", "content": "介绍 Python 语言"}
],
response_format={"type": "json_object"},
max_tokens=200
)
data = json.loads(resp.choices[0].message.content)
keys_sets.append(set(data.keys()))
for ks in keys_sets[1:]:
assert ks == keys_sets[0], f"JSON 结构不一致: {keys_sets}"8.7 工具调用测试
# tests/test_tool_calling.py
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
}]
def test_tool_call_triggered(ai_client, model_name):
"""验证模型能正确触发工具调用"""
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "北京今天天气怎么样?"}],
tools=TOOLS,
tool_choice="auto"
)
msg = resp.choices[0].message
assert msg.tool_calls, "未触发工具调用"
call = msg.tool_calls[0]
assert call.function.name == "get_weather"
import json
args = json.loads(call.function.arguments)
assert "city" in args
assert "北京" in args["city"] or "Beijing" in args["city"]
def test_tool_not_triggered_irrelevant(ai_client, model_name):
"""无关问题不应触发工具"""
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "请解释什么是量子力学"}],
tools=TOOLS,
tool_choice="auto"
)
msg = resp.choices[0].message
assert not msg.tool_calls, "无关问题不应触发 get_weather"8.8 Golden Set 驱动的批量评测
# common/evaluator.py
import json
def load_golden_set(path):
with open(path) as f:
return json.load(f)
def keyword_check(answer, expected_keywords):
"""关键词命中检查"""
answer_lower = answer.lower()
hits = [kw for kw in expected_keywords if kw.lower() in answer_lower]
return len(hits) / len(expected_keywords) if expected_keywords else 1.0
# golden_sets/chat_golden.json 示例
# [
# {"query": "法国的首都是哪里?", "keywords": ["巴黎", "Paris"], "min_score": 0.5},
# {"query": "水的化学式是什么?", "keywords": ["H2O"], "min_score": 1.0},
# {"query": "请用一句话解释机器学习", "keywords": ["数据", "学习", "模型"], "min_score": 0.6}
# ]# tests/test_golden_set.py
import pytest
from common.evaluator import load_golden_set, keyword_check
GOLDEN = load_golden_set("golden_sets/chat_golden.json")
@pytest.mark.parametrize("case", GOLDEN, ids=[c["query"][:20] for c in GOLDEN])
def test_golden_case(ai_client, model_name, case):
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": case["query"]}],
max_tokens=200
)
answer = resp.choices[0].message.content
score = keyword_check(answer, case["keywords"])
assert score >= case["min_score"], (
f"质量不达标: score={score:.2f}, 期望>={case['min_score']}\n"
f"问题: {case['query']}\n回答: {answer}"
)课堂练习
- 把上面的项目结构搭建起来,确保
pytest tests/ -v能跑通。 - 自己写 3 条 Golden Set 测试数据,覆盖事实、推理、拒答场景。
- 给流式测试加一个"Token 吞吐量"断言(tokens / 秒 > 某阈值)。
第9章:Allure 报告与可视化
9.1 为什么需要 Allure
pytest 默认输出是终端文本,对自己够用,但拿去给团队看、给领导汇报就不够直观。Allure 能把测试结果变成一个有分类、有趋势、有截图的 Web 报告。
9.2 安装与配置
# 安装 pytest 插件
pip install allure-pytest
# macOS 安装 Allure CLI
brew install allure
# 运行测试并生成 Allure 数据
pytest tests/ --alluredir=reports/allure-results
# 生成并打开报告
allure serve reports/allure-results9.3 在测试代码中标记分类
import allure
@allure.epic("AI 接口测试")
@allure.feature("基础对话")
@allure.story("响应格式校验")
@allure.severity(allure.severity_level.CRITICAL)
def test_chat_returns_200(ai_client, model_name):
with allure.step("发送基础对话请求"):
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "你好"}],
max_tokens=50
)
with allure.step("验证响应字段"):
assert resp.id is not None
assert resp.choices[0].message.content
with allure.step("记录 Token 使用量"):
allure.attach(
str(resp.usage.model_dump()),
name="Token 使用量",
attachment_type=allure.attachment_type.TEXT
)9.4 AI 测试报告的分类建议
| Epic | Feature | 说明 |
|---|---|---|
| AI 接口测试 | 基础对话 | 契约层:状态码、字段、格式 |
| AI 接口测试 | 流式输出 | TTFT、chunk 完整性 |
| AI 接口测试 | 结构化输出 | JSON Schema 一致性 |
| AI 接口测试 | 工具调用 | 触发准确率、参数正确性 |
| AI 质量评测 | Golden Set | 关键词命中、LLM Judge 评分 |
| AI 质量评测 | 幻觉检测 | 事实性、一致性 |
| AI 质量评测 | 安全边界 | 注入防御、拒答率 |
第10章:CI/CD 集成
10.1 为什么 AI 测试更需要 CI
AI 接口的输出不确定,版本切换时容易出现质量退化。如果不自动跑回归,你很难发现"昨天还好的 Prompt 今天就不行了"。CI 的核心价值是把评测变成一个自动触发、有历史对比的常态化流程。
10.2 GitHub Actions 完整配置
# .github/workflows/ai-api-test.yml
name: AI 接口回归测试
on:
schedule:
- cron: '0 2 * * *' # 每天凌晨 2 点
push:
paths:
- 'tests/**'
- 'golden_sets/**'
workflow_dispatch: # 手动触发
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
TEST_MODEL: gpt-4o-mini
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: 安装依赖
run: pip install -r requirements.txt
- name: 运行测试
run: pytest tests/ -v --alluredir=reports/allure-results --tb=short
- name: 生成 Allure 报告
if: always()
uses: simple-elf/allure-report-action@v1.9
with:
allure_results: reports/allure-results
allure_history: reports/allure-history
- name: 发布报告到 GitHub Pages
if: always()
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: reports/allure-history10.3 质量门控:失败时阻断
# pytest.ini
[pytest]
addopts = -v --tb=short
markers =
critical: 关键测试,失败即阻断
quality: 质量评测,允许一定比例失败# 在 CI 中分层运行
# 第一步:关键测试必须全过
pytest tests/ -m critical --tb=short
# 第二步:质量测试允许部分失败
pytest tests/ -m quality --tb=short || echo "质量测试有失败,请查看报告"10.4 成本控制
CI 中跑 AI 测试的成本问题
每次 CI 都会消耗 Token 和 API 费用。建议: 1. 日常 CI 使用便宜的模型(如 gpt-4o-mini) 2. Golden Set 控制在 20-50 条以内 3. max_tokens 设置合理上限 4. 定期审查 CI 的月度 Token 消耗
第11章:契约测试入门
11.1 什么是契约测试
契约测试(Contract Testing)解决的是前后端或服务间的"接口格式约定"问题。传统做法是前后端各自测,上线时才发现字段名改了。契约测试把约定写成可验证的规范,双方各自验证自己是否遵守了约定。
11.2 AI 接口的契约层
大模型接口的契约测试不验证"AI 回答得对不对",只验证"接口格式是否符合约定":
| 契约检查项 | 示例 |
|---|---|
| 响应结构 | choices[0].message.content 必须是字符串 |
| 字段存在性 | usage.prompt_tokens 和 usage.completion_tokens 必须存在 |
| 状态码约定 | 正常 200,超限 429,鉴权失败 401 |
| 流式格式 | 每行以 data: 开头,最后一行 data: [DONE] |
| 错误格式 | 错误响应包含 error.message 和 error.type |
11.3 用 Pydantic 做 Schema 验证
from pydantic import BaseModel, field_validator
from typing import Optional
class Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
@field_validator("total_tokens")
@classmethod
def total_matches(cls, v, info):
data = info.data
expected = data.get("prompt_tokens", 0) + data.get("completion_tokens", 0)
assert v == expected, f"total_tokens({v}) != prompt({data['prompt_tokens']}) + completion({data['completion_tokens']})"
return v
class Message(BaseModel):
role: str
content: Optional[str] = None
class Choice(BaseModel):
index: int
message: Message
finish_reason: str
class ChatResponse(BaseModel):
id: str
object: str
model: str
choices: list[Choice]
usage: Usage# tests/test_contract.py
def test_response_matches_contract(ai_client, model_name):
resp = ai_client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": "hi"}],
max_tokens=10
)
validated = ChatResponse.model_validate(resp.model_dump())
assert validated.choices[0].finish_reason in ("stop", "length")11.4 契约测试 vs 质量测试
两层分离原则
契约测试保证"接口能用、格式对",失败意味着接口出了 breaking change,必须立即修复。质量测试保证"回答好不好",允许一定比例的波动。两层都需要,但不要混在一起。
第12章:密钥管理与安全实践
12.1 最常见的安全事故
API Key 被提交到 Git 仓库,被爬虫扫描后盗用,一晚上跑掉几千美元的 Token 费用。这不是段子,是真实发生过的事故。
12.2 正确的密钥管理方式
| 方式 | 适用场景 | 做法 |
|---|---|---|
| .env 文件 | 本地开发 | 写 .env ,加入 .gitignore ,用 python-dotenv 加载 |
| CI Secrets | GitHub Actions | 在 Settings → Secrets 中配置,通过 ${{ secrets.KEY }} 引用 |
| 环境变量 | 服务器部署 | 通过 export 或容器编排注入 |
| 密钥管理服务 | 企业环境 | AWS Secrets Manager / HashiCorp Vault 等 |
12.3 .gitignore 必须包含的内容
# API Keys & Secrets
.env
.env.*
!.env.example
# 测试报告
reports/
# IDE
.idea/
.vscode/
__pycache__/12.4 .env.example 作为模板
# .env.example — 提交到仓库,告诉协作者需要配哪些变量
OPENAI_API_KEY=sk-your-key-here
TEST_MODEL=gpt-4o-mini
TEST_BASE_URL=https://api.openai.com/v112.5 Key 轮换与权限控制
- 生产 Key 和测试 Key 分开,不要混用。
- 测试 Key 设置用量上限(Rate Limit / Budget)。
- 定期轮换 Key,建议每 90 天。
- CI 中的 Key 使用最小权限原则。
课堂练习
- 创建一个
.env.example,确认.env在.gitignore中。 - 在 GitHub 仓库中配置 Secrets,确保 CI 能读到
OPENAI_API_KEY。 - 搭建完整的 CI 流水线:推送代码 → 自动运行测试 → 生成 Allure 报告。
补充参考答案要点
- 实战篇的答案要体现真实接口调试过程:准备数据、构造请求、观察响应、核对副作用和记录缺陷。
- 如果是创建/更新/删除类接口,不能只断言响应体,还要检查数据库或下游状态是否真的变化。
- 出现失败时,答案里应能区分是鉴权问题、参数问题、环境问题还是业务规则问题。