为了系统练习AI项目的接口自动化测试,找了一个真实的前后端分离项目 Chat2Excel 当作被测对象,基于 pytest + requests + Allure 搭了一套完整框架,覆盖用户 / 文件 / AI 三个服务,直连 MySQL 做数据一致性校验,并针对越权、鉴权、SQL 注入做了安全测试。
一、Chat2Excel系统
Chat2Excel 是一个"对话式 Excel 数据分析"平台:用户上传 Excel,后端用 POI 解析,把每个 sheet 建成一张 MySQL 表,然后用户用自然语言提问,AI 生成 SQL 查询并流式返回结果。
架构是 Spring Cloud 微服务:网关 + 用户服务 + 文件服务 + AI 服务,中间件用了 MySQL、Redis、Nacos、Nginx,前端 Vue3。
这个系统有三个特点,直接决定了测试方案的难点:
1)接口不是"一问一答"。 AI 对话接口返回的是 text/event-stream(SSE),是持续推送的事件流,不是一次性 JSON。如果按普通接口那样 requests.post() 拿 resp.json(),要么拿不到东西,要么一直阻塞。
2)接口成功 ≠ 业务成功。 后端有一个 GlobalExceptionHandler 统一兜异常,业务出错时返回的是:
python
HTTP 200
{"code": 400, "message": "只能处理excel文件", "data": null}
也就是说 HTTP 状态码永远是 200 ,只有 body 里的 code 才代表业务结果。断言只看 status_code == 200,等于什么都没测。
3)真正的结果在数据库里。 上传一个 Excel,接口只返回一个 fileId 和几KB的元信息。但后端实际做了四件事:文件传 OSS、files 表插记录、每个 sheet 建一张 MySQL 表、再写 file_table_mappings 和 field_mappings 两张映射表。只看接口返回,根本不知道数据到底有没有正确落库。
二、框架设计:三层分离
我理解的"框架"和"脚本"的区别只有一个:用例里应不应该出现 URL。
- 脚本:
requests.post("http://xxx/api/v1/files/upload/single", files=..., headers={"Authorization": ...}) - 框架:
file_api.upload(path)
所以整个框架分成三层:
python
用例层(testcases) 只写业务断言:上传后数据库里应该有什么
↓
接口层(api) 每个接口一个方法,URL 和参数拼装收敛在这里
↓
工具层(common) HTTP 封装 / SSE 解析 / 数据库 / Redis / 断言
目录结构:
python
autotest/
├── config/env.yaml 唯一需要改的配置:地址、账号、数据库
├── conftest.py 全局 fixture:登录、上传文件、连库
├── common/ 工具层
│ ├── http_client.py HTTP 封装(自动带 token、打日志、文件上传、SSE)
│ ├── sse_client.py SSE 事件流解析器
│ ├── db_client.py MySQL 直连(数据一致性断言用)
│ ├── redis_client.py Redis(读验证码、校验 token)
│ ├── assert_util.py 断言封装
│ └── logger.py 统一日志
├── api/ 接口层
│ ├── user_api.py / file_api.py / ai_api.py / llm_api.py
├── testcases/ 用例层
│ ├── test_00_smoke.py 冒烟
│ ├── test_01_user.py 用户服务
│ ├── test_02_file.py 文件服务 + 数据库一致性
│ ├── test_03_ai.py AI 服务(SSE)
│ ├── test_04_security.py 安全测试
│ └── test_05_defects.py 缺陷复现(xfail)
└── run.py 一键运行 + 出报告
还有一条原则:配置与代码分离 。环境地址、数据库连接、测试账号全部集中在 config/env.yaml,换环境只改这一个文件,代码一行不动。
python
base_url: "http://<your-server>:8080/api/v1"
user: { username: "autotest_main", password: "AutoTest@123" }
user_b: { username: "autotest_second", password: "AutoTest@123" } # 越权测试专用
mysql: { host: "<your-server>", user: "<db-user>", password: "<db-pass>" }
测试用例总数 52 条:冒烟 5、用户 10、文件 15、AI 8、安全 8、缺陷 6。
三、核心设计一:断言必须看 body.code
因为上面说的 GlobalExceptionHandler,业务失败也是 HTTP 200,所以我把断言分成了三类,覆盖两种"拒绝"方式:
python
def assert_success(resp, expect_message=None):
"""断言业务成功:body.code == 200,返回 data"""
body = _safe_json(resp)
assert body is not None, f"响应不是合法 JSON: {resp.text[:300]}"
assert body.get("code") == 200, (
f"期望业务码 200,实际 code={body.get('code')},"
f"message={body.get('message')},HTTP={resp.status_code}"
)
return body.get("data")
def assert_business_code(resp, expect_code, message_contains=None):
"""断言业务返回码,例如参数错误期望 400"""
def assert_rejected(resp, allow_status=(401, 403)):
"""
断言请求被"拒绝"。
兼容两种拒绝方式:
1. 网关拦截:HTTP 401/403 + 纯文本"令牌无效"
2. 业务层拒绝:HTTP 200 但 body.code != 200
"""
body = _safe_json(resp)
if resp.status_code in allow_status:
return True
if body is not None and body.get("code") != 200:
return True
raise AssertionError(f"期望请求被拒绝,但它成功了!HTTP={resp.status_code}")
assert_rejected 这个封装解决的是一类很常见的坑:同样是"未授权",网关拦和业务层拦返回的结构完全不同。不封装的话,每个安全用例都得写一遍 if-else。
经验:断言报错信息一定要带上"期望值 + 实际值 + 完整响应",否则失败时只能靠加 print 重新跑一遍。
四、核心设计二:一个 client = 一个独立用户会话
自动化测试里最容易翻车的是用例互相污染。比如"登出后 token 失效"这条用例,如果直接用主账号跑,主账号的 token 就被销毁了,后面所有用例全部失败。
我的解法:ApiClient 内部维护自己的 token,一个实例 = 一个用户会话。
python
class ApiClient:
def __init__(self, base_url, timeout=30, name="api"):
self.session = requests.Session()
self.token = None
def _headers(self, extra=None):
headers = {}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return {**headers, **(extra or {})}
然后在 conftest.py 里按账号开出不同的 client fixture:
python
@pytest.fixture(scope="session")
def main_client(cfg):
"""主账号,已登录。session 级:只登录一次,后面所有用例共用 token"""
client = ApiClient(cfg.base_url, name="main")
UserApi(client).login(cfg.user["username"], cfg.user["password"])
return client
@pytest.fixture(scope="session")
def client_b(cfg):
"""第二账号,专用于越权测试"""
@pytest.fixture
def anonymous_client(cfg):
"""未登录客户端,专用于鉴权测试"""
@pytest.fixture
def user_api(main_client):
return UserApi(main_client)
对应的,env.yaml 里准备了 4 个专用账号:主账号、越权账号、登出账号、改密账号,互不干扰。
改密用例还会用 finally 把密码改回去,保证用例可重复执行:
python
try:
resp = api.change_password("totally-wrong-old-password", NEW, NEW)
...
finally:
api.change_password(NEW, cfg.user_pwd["password"], cfg.user_pwd["password"])
fixture 作用域的选择也是有讲究的:
session级 ------ 配置、登录 token、数据库连接(只做一次,省时间)function级 ------ 有副作用的资源,比如"上传一个文件"
五、核心设计三:SSE 流式接口怎么自动化
问题
AI 对话接口 POST /ai/chat/stream 返回 text/event-stream。后端推送的格式长这样:
python
event: progress
data: {"stage":"INIT","progress":0}
event: progress
data: {"stage":"QUERY_SQL","progress":60}
event: complete
data: {"completed":true,"result":{"requestId":123,...}}
用 requests 默认行为是等响应体全部接收完才返回,而 SSE 是持续推送、连接长时间不关闭的 ------ 直接调用会一直卡住。
解法
第一步,发请求时用 stream=True,并且显式声明 Accept:
python
def stream_post(self, path, json_body, timeout=120):
headers = self._headers({"Accept": "text/event-stream"})
return self.session.post(url, headers=headers, json=json_body,
stream=True, timeout=timeout)
第二步,写一个 SSE 解析器,把文本流解析成事件列表:
python
def parse_sse(resp, max_seconds=None) -> List[Tuple[str, dict]]:
"""解析 SSE 响应,返回 [(事件名, data字典), ...]"""
events = []
current_event = "message"
start = time.time()
for raw_line in resp.iter_lines(decode_unicode=True):
# 防止后端异常不关连接导致用例卡死
if max_seconds and (time.time() - start) > max_seconds:
break
line = raw_line.strip()
if line == "":
current_event = "message" # 空行 = 一个事件结束
continue
if line.startswith("event:"):
current_event = line[len("event:"):].strip()
continue
if line.startswith("data:"):
payload = line[len("data:"):].strip()
events.append((current_event, _try_json(payload)))
continue
return events
第三步,用例只断言流程和结构:
python
@pytest.mark.ai
def test_chat_stream_query_flow(uploaded_file, ai_api):
resp = ai_api.chat_stream(uploaded_file["fileId"], "这个文件一共有多少条数据?")
# 1) 先确认这确实是流式接口
assert "text/event-stream" in resp.headers.get("Content-Type", "")
events = parse_sse(resp, max_seconds=110)
names = event_names(events)
# 2) 必须有 INIT 初始化事件(证明后端链路启动)
assert find_stage(events, "INIT") is not None, "缺少 INIT 初始化事件"
# 3) 必须以 complete 收尾,且带结构化结果
complete = find_event(events, "complete")
assert complete is not None, f"未收到 complete 事件,实际事件: {names}"
assert complete.get("completed") is True
assert complete["result"].get("requestId")
一个关键的取舍:为什么不断言 AI 说的话
大模型输出天然有不确定性。同一句"这个文件有多少条数据",两次回答措辞可能完全不同。断言具体文本 = 制造随机失败。
所以我把 AI 用例的断言收敛成"结构契约":
- 事件的类型对不对(有 progress、有 complete)
- 事件的顺序对不对(INIT 必须在前、complete 必须在最后)
- 事件的结构对不对(complete 里必须有 result.requestId)
至于"AI 回答得对不对",那是模型评测的范畴,交给评测集或人工,不该由稳定性要求很高的回归用例来承担。这是业界对流式 LLM 接口做自动化的通用做法。
max_seconds 这个参数也值得一提:它防的是"后端异常导致连接永不关闭"------ 没有它,一条用例能挂到天荒地老。而后面会讲到,这个保护恰恰帮我发现了一个真实缺陷。
六、核心设计四:数据库一致性校验
只看接口返回,测不出真问题
上传 Excel 接口返回 {fileId: 123, fileName: "国家GDP.xlsx", fileSize: 10240, uploadStatus: 1}。断言它 code == 200、fileId > 0,看起来很美好。
但如果后端只写了 files 表、忘了建动态表 ,或者建表了但行数少了一半,接口返回完全一样 ------ 因为你查的是元信息,不是数据。
所以我把 pymysql 接进来,直接断言数据库:
python
@pytest.mark.file
def test_db_consistency_after_upload(uploaded_file, db, file_api):
"""上传 Excel 后,校验数据库三张表 + 动态表都正确落库"""
file_id = uploaded_file["fileId"]
# 1) files 表有记录
file_row = db.get_file(file_id)
assert file_row is not None, f"files 表中不存在 fileId={file_id} 的记录"
assert file_row["file_name"] == uploaded_file["fileName"]
# 2) 文件 <-> MySQL表 的映射
table_names = db.get_table_names_by_file(file_id)
assert table_names, "file_table_mappings 未记录表映射"
# 3) Excel 表头 <-> 字段 的映射
field_mappings = db.get_field_mappings(file_id)
assert field_mappings, "field_mappings 未记录字段映射"
# 4) 动态表存在,且【数据库行数 == 接口返回行数】
dynamic_table = table_names[0]
assert db.table_exists(dynamic_table)
db_count = db.count_table_rows(dynamic_table)
preview = assert_success(file_api.preview(file_id))
api_count = preview["paginationInfo"]["totalRecords"]
assert db_count == api_count, (
f"数据库表行数 {db_count} 与接口返回 {api_count} 不一致"
)
第 4 步是全篇我最喜欢的一行断言:数据库和接口,是两个独立的信息源,让它们互相验证。接口说 100 行、数据库说 100 行,才敢说数据是对的。
db_client 里顺便封装了一批业务化查询方法(get_file / get_table_names_by_file / count_table_rows / table_exists),用例里就不用裸写 SQL 了。
附带收获:接口间一致性
同样的思路可以用来测"接口之间对不对得上"。/files/excel/info/{id} 和 /files/excel/preview/{id} 读的是同一张表,总行数必须一致:
python
assert info["totalRows"] == preview["paginationInfo"]["totalRecords"], \
"excel_info 与 preview 返回的总行数不一致"
"一键复原":一个破坏性测试
平台有个"一键复原"接口,作用是用户把数据改坏后,用原始 Excel 覆盖数据库表还原。这个怎么测?
先破坏,再复原,比对行数:
python
def test_restore_file_data(uploaded_file, db, file_api):
table = db.get_table_names_by_file(uploaded_file["fileId"])[0]
original_count = db.count_table_rows(table)
# 破坏数据:直接清空
db.execute(f"DELETE FROM `{table}`")
assert db.count_table_rows(table) == 0
# 调复原接口
assert_success(file_api.restore(uploaded_file["fileId"]))
# 数据应被完整还原
assert db.count_table_rows(table) == original_count
这比"调一下接口看返回 200"强太多 ------ 后者测的是接口活着,前者测的是功能真的有用。
数据准备与清理
数据库校验的前提是有一个已上传的文件。这个前置动作我用 fixture 管理,用例结束后自动清理:
python
@pytest.fixture
def uploaded_file(main_client, cfg, tiny_excel_path):
"""上传一个 Excel,用例结束后自动删除,保证用例可重复执行"""
api = FileApi(main_client)
info = api.upload(tiny_excel_path).json()["data"]
yield {**info, "localPath": tiny_excel_path} # ← 交给用例
# 清理:删除文件(同时删除衍生表、映射记录、OSS 文件)
try:
api.delete([info["fileId"]])
except Exception as exc:
log.warning(f"清理文件失败(不影响用例结果): {exc}")
注意清理失败只打 warning 不抛异常 ------ 清理出错不该让用例结果变红。
七、顺带把 Redis 也用上了
除了 MySQL,框架还直连了 Redis,解决两个具体问题:
1)自动化没法收邮件,但需要验证码。
后端的验证码存在 Redis 里,key 就是 code:<6位验证码>。所以我可以直接扫出来用:
python
def get_latest_verification_code(self, pattern="code:*"):
keys = list(self.client.scan_iter(match=pattern, count=100))
if not keys:
return None
# 按 TTL 剩余最长 = 刚生成的那个
keys.sort(key=lambda k: self.client.ttl(k), reverse=True)
return keys[0].split("code:", 1)[-1]
这是测试环境的一个小技巧:绕过外部依赖(邮箱),从数据层拿到你需要的东西。
2)验证"登出"是不是真的生效了。
登出接口返回 200 不代表 token 真的失效了。用例不只看返回,还重新用旧 token 请求一次,确认被拒绝:
python
def test_logout_then_token_becomes_invalid(cfg):
api.login(cfg.user_logout["username"], cfg.user_logout["password"]) # 专用账号
token = client.token
assert_success(api.get_user_info()) # 登出前:能查到
assert_success(api.logout())
assert_rejected(api.get_user_info()) # 登出后:同一 token 被拒绝
八、安全测试:越权、鉴权、注入、绕过网关
接口自动化很适合做安全测试,因为这些都是纯接口层面的问题,跟 UI 无关。我测了四类(8 条用例,正常情况下应该全部通过):
1. 水平越权(IDOR)
用 B 账号上传一个文件,再用 A 账号的 token 去操作它:
python
def test_download_other_users_file_forbidden(uploaded_file_b, main_client):
"""下载别人的文件 -> 403"""
resp = FileApi(main_client).download(uploaded_file_b["fileId"])
assert resp.status_code == 403
def test_delete_other_users_file_has_no_effect(uploaded_file_b, file_api, db):
"""删除别人的文件 -> 文件必须仍然存在"""
file_api.delete([uploaded_file_b["fileId"]])
assert db.get_file(uploaded_file_b["fileId"]) is not None, "严重越权:A 删除了 B 的文件!"
注意最后一条:删完再用数据库确认它还在,而不是只看接口返回。越权漏洞最危险的地方就是"接口报错但数据已经被改了"。
2. 鉴权:伪造 JWT
这个项目的 JWT 密钥是硬编码的(源码里写死),理论上攻击者可以自己签发 token。于是我构造一个伪造的 JWT 试进去:
python
fake_payload = {"sub": "10000001", "username": "hacker", "exp": ...}
forged = pyjwt.encode(fake_payload, "<硬编码的密钥>", algorithm="HS256")
anonymous_client.set_token(forged)
assert_rejected(anonymous_client.get("/users/info"))
结果是被拒绝的 ------ 原因是这个项目的鉴权实际以 Redis 为准 (网关查 token:<token> 是否存在),伪造的 JWT 在 Redis 里查不到,所以依然进不来。这属于纵深防御生效:主防线有缺陷,但兜底防线挡住了。
3. SQL 注入
在文件名的模糊查询参数里塞注入串:
python
def test_sql_injection_in_file_name_filter(file_api):
resp = file_api.list_files(file_name="' OR '1'='1")
data = assert_success(resp)
# 注入成功会查出所有文件;参数化查询下它只是个普通字符串
assert data["total"] == 0
4. 绕过网关直连微服务
网关做了一层鉴权、微服务端口又做了一层 GatewayTokenFilter。我直接跳过网关,拿合法 token 去请求微服务的裸端口,期望被 403 拦掉:
python
url = f"{cfg.host}:{cfg.service_ports['file']}/files/list"
resp = requests.get(url, headers={"Authorization": f"Bearer {main_client.token}"})
assert resp.status_code == 403
assert "禁止访问" in resp.text
如果服务端口没对外暴露,用例会 pytest.skip() 而不是失败 ------ 环境不具备就跳过,不要制造假红。
九、缺陷管理:为什么用 xfail
跑出来 5 个确认的缺陷,我把它们写成了**断言"正确行为"**的用例,然后用 xfail 标记:
python
@pytest.mark.defect
@pytest.mark.xfail(
reason="已知缺陷D1:/files/excel/info 未校验文件归属,任意登录用户可查看他人文件信息",
strict=False,
)
def test_defect_excel_info_idor(uploaded_file_b, file_api):
"""
正常逻辑:excel_info 和 preview 一样,都应该校验文件归属。
实测现象:接口直接按 fileId 查库返回,未校验 user_id -> 越权读取成功。
"""
resp = file_api.excel_info(uploaded_file_b["fileId"])
body = resp.json()
assert body.get("code") != 200, f"越权成功:用户A 读取到了用户B 的文件信息"
这样做的好处很实在:
- 回归报告里显示 XFAIL(预期失败),而不是 FAILED ------ 留下缺陷证据,又不会让整个回归一片红
- 缺陷一旦被开发修复,用例自动变成 XPASS ------ 天然就是"修复验证"
- 缺陷的复现步骤、根因、修复建议,都固化在用例里了,不会随着时间遗忘
举个有代表性的:D1 水平越权
| 项 | 内容 |
|---|---|
| 接口 | GET /api/v1/files/excel/info/{fileId} |
| 预期 | 拒绝(文件不存在或用户无权限) |
| 实际 | 200,并把 B 的文件名、大小、行列数返回给 A |
| 根因 | 该方法用 filesMapper.selectById(fileId),只按 fileId 查,没校验 user_id 。而同模块的 previewExcel / restoreFileData / deleteFiles 用的都是 selectByUserIdAndFileId(userId, fileId) ------ 显然是漏改 |
| 修复建议 | 换成 selectByUserIdAndFileId,查不到就抛"文件不存在或用户无权限" |
| 风险 | 遍历 fileId 即可拿到全站用户的文件元信息 |
再看一个"一个字符"的缺陷
D5:下载不存在的文件时返回 500 而不是 404。根因是判断写错了变量:
python
FilesEntity filesEntity = filesMapper.selectById(fileId);
if (fileId == null) { // ← 应该是 filesEntity == null
throw new ...
}
// 继续执行到 filesEntity.getUserId() → NullPointerException
这类缺陷不写用例是发现不了的 ------ 手工点页面永远点不出"下载一个不存在的文件"。
十、工程化:让框架能真的用起来
一个只有用例的框架活不了多久,还得有:
1)pytest marker 分层
python
markers =
smoke: 冒烟用例,验证环境连通性,每次提交后快速执行
user / file / ai: 按服务划分
security: 安全测试(越权/鉴权/注入/上传校验)
defect: 已知缺陷复现(xfail)
slow: 慢用例(大文件、并发),默认跳过
AI 用例单独标记是刻意的 ------ 它依赖真实大模型、一条要跑十几秒到几十秒、结果还不确定,不该拖累日常回归:
python
python run.py -m "not ai" # 默认推荐:跑除 AI 外的全部
python run.py -m smoke # 改完代码先跑冒烟
python run.py -m security # 只跑安全
2)先冒烟,再回归
冒烟用例只有 5 条,但价值极高 ------ 它回答的是"环境通不通":
- 网关可达性(带无效 token 能收到拒绝,说明网关活着)
- 登录链路 + 查用户信息
- MySQL 可达
- Redis 可达
- 未登录请求受保护接口被拒绝
冒烟挂了就别看其他用例的红了 ------ 那大概率是环境问题,不是代码缺陷。
3)一键运行 + 双报告
run.py 把 pytest 命令和报告生成包成一条命令,同时产出两种报告:
- pytest-html:自包含 HTML,双击就能开,不需要额外环境
- Allure:更好看,适合演示(需要 allure 命令行 + Java)
python
if importlib.util.find_spec("pytest_html") is not None:
args += [f"--html={HTML_REPORT}", "--self-contained-html"]
if importlib.util.find_spec("allure_pytest") is not None:
args.append(f"--alluredir={ALLURE_DIR}")
用 find_spec 判断插件是否存在,没装就跳过而不是报错 ------ 降低别人的使用门槛。
4)连不上外部依赖就 skip,不要 fail
python
@pytest.fixture(scope="session")
def db(cfg):
client = DbClient(cfg.mysql)
try:
client.connect()
except Exception as exc:
pytest.skip(f"MySQL 连接失败,跳过数据一致性用例: {exc}")
yield client
client.close()
十一、踩过的坑
1)测试环境连不上,先别怀疑用例。 一开始登录一直 500,排查半天发现是网关白名单配置的问题:源码里白名单写的是 /users/auth,但实际请求路径是 /api/v1/users/auth,前缀对不上。先用 Postman 确认接口通不通,再怀疑用例。
2)Windows 控制台中文乱码。 PowerShell 里跑中文日志全是乱码,chcp 65001 切到 UTF-8 即可。
3)AI 用例的不确定性要从设计上隔离,而不是靠重试。 我的做法是三层:marker 隔离 + 只断言结构契约 + 超时保护(max_seconds)。
4)用例之间的污染往往来自"共享账号"。 登出、改密这类有副作用的用例,一定要用独立账号。
5)断言信息写详细,是给自己省时间。 每条断言都带上期望值、实际值和完整响应体,失败时一眼看出问题,不用重新跑一遍加 print。