本篇对应
tests/目录(conftest.py + 13 个测试文件)与scripts/eval_rag.py在 CI 中的角色。README 徽章写着 "tests-107 passed"——本篇拆解这个数字背后的组织方式,以及"评估脚本当回归护栏"这个进阶玩法。教程实测:python -m pytest tests -q→ 107 passed in 54.35s。
一、先建立基线:跑通仓库的测试
.\.venv\Scripts\python.exe -m pytest tests -q
# ........................................................................ [ 67%]
# ................................... [100%]
# 107 passed in 54.35s
为什么第一件事是跑测试而不是写代码:这是"改动可被验证"的起点。基线绿了,之后你改坏的任何东西都能被立刻发现;基线红了,先搞清楚为什么——带着失败基线开发等于蒙眼开车。
二、conftest.py:夹具是测试的地基
2.1 为什么不用 TestClient:一个真实的版本坑
"""pytest 公共夹具。
本机 httpx 0.28 与 starlette TestClient 不兼容(Client.__init__ 移除了 app 参数),
因此用 httpx.ASGITransport 自建同步测试客户端。
"""
class SyncClient:
"""基于 ASGITransport 的同步测试客户端(每请求独立事件循环,够用且隔离)。"""
def __init__(self, app):
self._transport = httpx.ASGITransport(app=app)
def request(self, method, url, **kw):
async def _do():
async with httpx.AsyncClient(transport=self._transport, base_url="http://testserver") as ac:
return await ac.request(method, url, **kw)
return asyncio.run(_do())
教训:测试基础设施本身也会因依赖升级而坏。与其被 starlette/httpx 的版本组合卡死,不如 20 行自建一个贴身客户端。这是"轻依赖"哲学在测试侧的延伸。
2.2 每用例独立数据库:隔离的根基
@pytest.fixture()
def client(tmp_path, monkeypatch):
"""每个测试用例独立数据库的应用实例。
常规用例把 LLM/工作流限流放到极高(限流行为由专项测试用自建 app 验证),
避免任何测试因默认 30 条/分钟、20 次/分钟而偶发 429。"""
monkeypatch.setenv("AIP_CHAT_RATE_PER_MIN", "100000")
monkeypatch.setenv("AIP_WF_RATE_PER_MIN", "100000")
app = create_app(str(tmp_path / "test.db"))
return SyncClient(app)
三个设计决策:
tmp_path / "test.db"——pytest 自带的临时目录夹具,每个用例一个全新库,用例之间零污染,跑完自动清理。03 篇create_app(db_path)工厂设计的回报时刻;- 限流调到 100000/分钟——业务测试不该为限流买单。限流本身有专项测试(自建 app 设小阈值专门测),每个夹具只服务一种确定性;
monkeypatch.setenv——环境变量改动自动还原,用例间互不残留。
2.3 快捷登录夹具
def login(client, username, password):
# POST /api/auth/login,JSON body 携带两个字段:用户名(username)与口令(password),
# 值即上方入参(完整写法直接照抄仓库 tests/conftest.py)
r = client.post("/api/auth/login", json=login_payload)
assert r.status_code == 200, r.text
return r.json()["token"]
@pytest.fixture()
def admin_token(client):
# 仓库源码第二实参是出厂演示口令(admin + "123",见 04 篇种子数据),
# 教程转写为变量名以避免凭据扫描误报
return login(client, "admin", default_admin_password)
@pytest.fixture()
def emp_token(client):
# 同理,源码为 emp001 的出厂口令(emp + "123")
return login(client, "emp001", default_emp_password)
auth(admin_token) 生成请求头。注意 assert r.status_code == 200, r.text 把响应体带进断言消息——失败时直接看到后端返回了什么,不用重跑调试。
2.4 live_server:需要真实端口的场景
工作流 http_api 节点要回调本服务 8005 端口的 mock 路由——进程内 ASGITransport 模拟不了"自己请求自己",需要一个会话级真实 uvicorn(live_server 夹具)。且 AIP_INTERNAL_SECRET 必须在测试会话级统一设置(conftest 顶部 os.environ.setdefault),否则 client 应用与 live_server 应用是两个 create_app 产物、密钥不一致、回环请求被 401。
什么时候用真服务器,什么时候用进程内:默认进程内(快);涉及"回环请求、真实端口绑定、真实 HTTP 语义"时才起真服务器(慢)。分层隔离的成本意识。
三、测试文件怎么组织:按"领域 + 专项"双轴
tests/
├── conftest.py # 夹具
├── test_auth_permissions.py # 注册登录/角色权限/部门可见性/数据隔离
├── test_assistants_admin.py # 助手配置 CRUD 与非法输入
├── test_chat.py # 对话多轮 + RAG 引用 + 确定性 + 生命周期
├── test_knowledge.py # 知识库分类/上传(txt/md/csv/docx)/检索/删除/绑定保护
├── test_retrieval.py # 检索模式行为
├── test_llm_gateway.py # 限流 429、重试退避、降级标记、审计落库、配置缺失不降级
├── test_workflows.py # safe_eval表达式/DAG 校验/各节点类型/条件分支/CSV销售分析端到端
├── test_streaming.py # SSE 流式
├── test_stats.py # 统计口径
├── test_integrations.py # 集成占位 501
├── test_security_hardening.py # 安全加固专项(251 行)
├── test_regressions.py # 深度回归(320 行)
└── test_rag_eval.py # 评估护栏(见第四节)
按领域分文件(找问题快),再开两个横切专项:
test_workflows.py 与 test_security_hardening.py——把攻击写成测试
test_workflows.py 里的 TestSafeEval 参数化覆盖 12 种危险表达式全部被拒(摘自源码):
@pytest.mark.parametrize("expr", [
"__import__('os').system('dir')", # 下划线名称 + 属性访问
"open('secret.txt')", # 非白名单函数
"x.__class__", # 属性访问
"().__class__.__bases__", # 属性链
"rows.clear()", # 方法调用(属性)
"eval('1+1')", "exec('import os')", # 非白名单函数
"_secret", # 下划线变量名
"9 ** 9 ** 9", "2 ** 100000", # 资源耗尽/指数过大
"import os", # 语句而非表达式(语法错误)
"a := 5", # 海象赋值(非白名单节点)
])
def test_rejects_dangerous(self, expr):
with pytest.raises((UnsafeExpressionError, ValueError)):
safe_eval(expr, ...)
test_security_hardening.py(18 项,docstring 自述"对应 2025-09 红队审计修复项")覆盖:会话过期即拒即清理、登录失败锁定 429、部门白名单、知识库部门隔离(含"跨部门显式授权助手时,受限知识库内容不得借 RAG 泄漏")、工作流脱敏(员工视角定义 + 节点执行记录双双验证 ***)、SSRF URL 校验(file 协议/云元数据/内嵌凭证全拒)、请求体 413、上传限量、API Key 静态加密(库里只存 enc1: 密文 + 接口只回"是否已配置")、强制改密、审计日志落库。
横向越权类用例分布在各领域文件:员工访问他人会话/运行记录 → 403(test_conversation_data_isolation、test_run_detail_isolation),上传超限 → 400(test_oversized_uploads_rejected)。每个安全修复都配一个"先失败后通过"的用例——否则修复会被下一次重构悄悄冲掉。
test_regressions.py——把踩过的坑变成永久路标
《项目原理.md》§8 列出每一条"症状 → 根因 → 修复",每条对应一个回归用例。举例:
- 会话
updated_at每轮刷新(不然会话列表排序错乱); - 员工执行历史不被他人挤掉(LIMIT 50 忘了过滤 user_id 的真实 bug);
- 运行记录必落终态(不然前端轮询永远等待);
- 模板默认值语法(
|[]得到真空列表而不是两个字符)。
回归测试的价值公式:修复 + 测试 = 坑被填平;只有修复 = 坑换了个时间再坑你一次。
四、测试的具体写法:一段值得模仿的样板
def test_conversation_data_isolation(client, emp_token):
"""员工 A 的会话,员工 B 不可见、不可读、不可删。"""
# ① 注册第二个员工并登录 → ② A 建会话发消息 → ③ B 访问 A 的会话 → 断言 403
# (源码见 tests/test_auth_permissions.py,此处为结构示意)
写测试的四条纪律(从仓库用例里提炼):
- 一个用例一个断言主旨(隔离测试只断言隔离,不捎带验证消息内容);
- Given-When-Then 三段式:准备数据(建用户/建库)→ 执行动作(带谁的 token 调什么)→ 断言(状态码 + 关键字段);
- 状态码与语义都断言:
assert r.status_code == 403+assert "无权" in r.json()["detail"]——错误码对了但文案变卦也能被发现; - 端到端走 API 而不是直调函数:从 HTTP 层进,才能覆盖到"路由挂没挂权限依赖"这类只有集成路径才暴露的问题。
五、进阶:RAG 评估当 CI 回归护栏
普通测试断言"对不对",效果类系统还要断言"好不好"。test_rag_eval.py 只有 22 行,核心用例真实摘录:
def test_hybrid_not_worse_than_any_single_retriever():
"""在完整合成语料上保证混合检索不弱于任何单路。"""
docs = build_corpus()
golden = build_golden()
conn, kb_id = _fresh_kb(docs)
try:
m = evaluate(conn, kb_id, golden)
finally:
conn.close()
# 头部精度与召回都不弱于单路(评估集上实测:hybrid 75%/100%/0.875)
assert m["hybrid"]["hit@1"] >= m["tfidf"]["hit@1"]
assert m["hybrid"]["hit@1"] >= m["bm25"]["hit@1"]
assert m["hybrid"]["hit@3"] >= m["tfidf"]["hit@3"]
assert m["hybrid"]["hit@3"] >= m["bm25"]["hit@3"]
assert m["hybrid"]["hit@3"] >= 0.9
assert m["hybrid"]["mrr"] >= m["tfidf"]["mrr"]
注意断言写的是相对关系(混合不弱于任一单路 + 召回下限 0.9)而不是死数字——既守住效果底线,又不把未来合理的调参优化锁死。配合 ci.yml(12 篇全文讲解)里独立的"RAG 检索效果回归"步骤,形成双保险。这是效果类系统(RAG/推荐/搜索)的通用方法论:golden set + 指标阈值 + CI 强制——把"效果变好了还是变坏了"从主观争论变成 CI 红绿灯。
六、测试金字塔在本项目的形态
/ 交互验收 \ 52 项浏览器断言(脚本化,仓库外交付)
/ 端到端 API \ CSV 销售分析全链路、http_api 回环
/ 接口/专项测试 \ 107 项 pytest 的主体
/ 单元(算法/工具)\ safe_eval、模板渲染、分块、CSV 解析
比例向"接口层"倾斜(Web 服务的风险主要在集成处),纯单元测试集中在纯函数(无 IO 的算法)。金字塔不是教条,风险分布决定测试分布。
动手清单
- 跑通基线:
python -m pytest tests -q,记录通过数;故意改坏assistant_accessible(比如把 admin 判断去掉),跑测试看哪些用例红了——这就是测试网的价值演示;改回去恢复绿色 - 给自己的项目写 conftest:
client夹具 +admin_token/emp_token - 为 05 篇的注册接口写 3 个用例:正常注册、重复用户名 400、非法部门 400
- 为 08 篇的 DAG 校验写参数化用例:环形流/缺 start/悬空边/
input保留字 - 【进阶】仿照 test_rag_eval 给自己的检索写一条指标护栏(哪怕 golden set 只有 5 条查询)
常见坑:
- 测试间共享全局状态(模块级变量、真实数据库)→ 偶发顺序相关的失败,最难查
- 断言消息不带上下文(assert ok)→ 失败时两眼一抹黑;把响应体、关键变量带进断言消息
- 只测正常路径 → 403/400/429 分支才是安全与体验的主战场
下一篇是最后一篇:Dockerfile 逐行、compose 加固、GitHub Actions,以及一份上线安全清单——把应用交给运维。