"""_005_fastapi 的测试用例
仓库里没有 pytest,测试沿用各模块 `if __name__ == "__main__"` 自测的约定,
用一个极小的断言 + 计数框架,跑完打印 通过/失败 汇总,失败时退出码非 0。
两种模式:
python -X utf8 -m _005_fastapi.test_api # 离线(默认)
python -X utf8 -m _005_fastapi.test_api --live # 打真实服务
**离线模式是主力**:它把 `main._graph` 换成一张假图,于是不碰 LLM、Neo4j、FAISS,
一秒内跑完,验证的是接口层自己的行为 —— 状态码、字段、校验、异常收敛。
真图跑一遍要 5-30 秒还要花钱,不适合当回归测试。
`--live` 是对着 `python -m _005_fastapi.main` 起好的服务打真实请求,验证的是端到端
真的通(图能加载、答案有内容)。它依赖外部服务,所以是可选的,不进默认流程。
"""
import argparse
import json
import sys
from fastapi.testclient import TestClient
from _005_fastapi import main
BASE_URL = "http://localhost:8000"
# 结果计数,_check 往里累加
passed = 0
failed = 0
def _check(condition: bool, label: str, detail: str = "") -> None:
"""一条断言 = 一行输出。不抛异常,让后面的用例继续跑完"""
global passed, failed
if condition:
passed += 1
print(f" [PASS] {label}")
else:
failed += 1
print(f" [FAIL] {label}" + (f" -> {detail}" if detail else ""))
# ======================
# 假图:替掉真图,测接口层自己
# ======================
class FakeGraph:
"""只实现 invoke,返回预先设定的 state 或抛异常。
真图的接口就这一个方法(`graph.invoke(dict) -> dict`),所以替换成本极低。
"""
def __init__(self, state=None, error=None):
self.state = state
self.error = error
self.calls = []
self.configs = []
def invoke(self, initial_state, config=None):
"""签名要跟真图对齐。
加了 checkpointer 之后 main.py 会带上 `config`(里面是 thread_id),
少这个参数会让每次调用都 TypeError,然后被 /process 的 except 收敛成 500 ——
测出来是"图挂了",看不出其实是假图的签名过时了。
"""
self.calls.append(initial_state)
self.configs.append(config)
if self.error is not None:
raise self.error
return self.state
def _client(fake_graph) -> TestClient:
"""装上假图,返回 TestClient。
直接给 `main._graph` 赋值即可绕过懒加载 —— `get_graph()` 见到非 None 就不再 import
真图。TestClient 会触发 lifespan,预热那一步同样命中缓存,不会去加载 FAISS。
"""
main._graph = fake_graph
return TestClient(main.app)
# ======================
# 用例 1:健康检查
# ======================
def test_health_check() -> None:
print("\n[1] GET / 健康检查")
with _client(FakeGraph(state={"output": "不该被调用"})) as client:
response = client.get("/")
_check(response.status_code == 200, "状态码 200", str(response.status_code))
body = response.json()
_check(body.get("status") == "ok", "status == ok", repr(body.get("status")))
_check(
body.get("message") == "中医知识图谱问答系统服务运行正常",
"message 与文档一致",
repr(body.get("message")),
)
_check(set(body) == {"status", "message"}, "字段只有 status/message", str(set(body)))
# ======================
# 用例 2:/process 正常返回
# ======================
def test_process_ok() -> None:
print("\n[2] POST /process 正常问答(图谱路)")
answer = "人参具有大补元气、复脉固脱、补脾益肺、生津养血、安神益智等功效。"
graph = FakeGraph(state={"input": "人参有什么功效?", "output": answer})
with _client(graph) as client:
response = client.post(
"/process", json={"input": "人参有什么功效?", "user_id": "user-123"}
)
_check(response.status_code == 200, "状态码 200", str(response.status_code))
body = response.json()
_check(body.get("input") == "人参有什么功效?", "input 原样回显", repr(body.get("input")))
_check(body.get("output") == answer, "output 取自 state['output']", repr(body.get("output")))
_check(set(body) == {"input", "output"}, "字段只有 input/output", str(set(body)))
# 只把 input 传进图,user_id 不进 state(AgentState 里没有这个字段)
_check(graph.calls == [{"input": "人参有什么功效?"}], "只把 input 传给图", str(graph.calls))
# ======================
# 用例 3:/process 兜底路(非中医提问)
# ======================
def test_process_direct_out() -> None:
print("\n[3] POST /process 兜底路(非中医提问)")
answer = "抱歉,我无法提供天气信息。我是一个中医知识图谱问答系统。"
# 兜底路的 state 里没有 neo4j_answer,但 output 一样被写满,接口层不用区分
graph = FakeGraph(state={"input": "今天天气怎么样?", "direct_out": answer, "output": answer})
with _client(graph) as client:
response = client.post(
"/process", json={"input": "今天天气怎么样?", "user_id": "user-456"}
)
_check(response.status_code == 200, "状态码 200", str(response.status_code))
_check(response.json().get("output") == answer, "兜底路答案也能取到", repr(response.json()))
# ======================
# 用例 4:入参校验
# ======================
def test_process_validation() -> None:
print("\n[4] POST /process 入参校验(FastAPI 返回 422)")
graph = FakeGraph(state={"output": "不该被调用"})
bad_requests = [
({"user_id": "user-123"}, "缺 input"),
({"input": "人参有什么功效?"}, "缺 user_id"),
({"input": "", "user_id": "user-123"}, "input 为空串"),
({"input": " ", "user_id": "user-123"}, "input 全空格"),
({"input": "人参有什么功效?", "user_id": ""}, "user_id 为空串"),
({}, "两个字段都缺"),
]
with _client(graph) as client:
for payload, label in bad_requests:
response = client.post("/process", json=payload)
_check(response.status_code == 422, f"{label} -> 422", str(response.status_code))
# 参数不合法就该被挡在图之前,一次大模型都不该调
_check(graph.calls == [], "非法请求没有触达图", str(graph.calls))
# ======================
# 用例 5:异常收敛成 500 + 固定文案
# ======================
def test_process_error() -> None:
print("\n[5] POST /process 图执行异常")
graph = FakeGraph(error=RuntimeError("Neo4j 连接失败"))
with _client(graph) as client:
response = client.post(
"/process", json={"input": "人参有什么功效?", "user_id": "user-123"}
)
_check(response.status_code == 500, "状态码 500", str(response.status_code))
body = response.json()
_check(body.get("input") == "", "错误响应 input 为空串", repr(body.get("input")))
_check(body.get("output") == main.ERROR_OUTPUT, "output 为固定错误文案", repr(body.get("output")))
# 内部异常信息不该漏给调用方
_check("Neo4j" not in response.text, "不泄漏内部异常细节", response.text)
# ======================
# 用例 6:图没写 output 时的兜底
# ======================
def test_process_empty_output() -> None:
print("\n[6] POST /process 图返回空 output")
for state, label in [
({"input": "x"}, "state 里没有 output 键"),
({"input": "x", "output": None}, "output 为 None"),
({"input": "x", "output": " "}, "output 全空白"),
]:
with _client(FakeGraph(state=state)) as client:
response = client.post("/process", json={"input": "x", "user_id": "u"})
_check(
response.status_code == 200
and response.json().get("output") == main.ERROR_OUTPUT,
f"{label} -> 200 + 错误文案",
response.text,
)
# ======================
# 用例 7:并发不被阻塞
# ======================
def test_process_concurrent() -> None:
"""图是同步阻塞的,接口必须把它丢进线程池,否则一个慢请求会卡住整个服务。
这里让假图 sleep,再用线程并发打两个请求:串行执行耗时会是 2 倍 sleep,
并发则接近 1 倍。用 1.5 倍做判据,够区分两种情况又不至于被机器抖动误判。
"""
print("\n[7] POST /process 并发(验证没有阻塞事件循环)")
import time
from concurrent.futures import ThreadPoolExecutor
delay = 0.5
class SlowGraph(FakeGraph):
# config 同样要接(见 FakeGraph.invoke)
def invoke(self, initial_state, config=None):
time.sleep(delay)
return {"output": "慢回答"}
with _client(SlowGraph()) as client:
def one(_):
return client.post("/process", json={"input": "人参", "user_id": "u"})
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(one, range(2)))
elapsed = time.perf_counter() - start
_check(all(r.status_code == 200 for r in responses), "两个请求都成功")
_check(elapsed < delay * 1.5, f"并发执行(耗时 {elapsed:.2f}s < {delay * 1.5}s)", f"{elapsed:.2f}s")
# ======================
# 用例 8:流式接口
# ======================
class FakeStreamGraph(FakeGraph):
"""假图的流式版本:按给定顺序吐出 {节点名: 状态}。
真图的 stream 是"每个节点执行完产出一条",而且因为我们的节点是改完整个 state
再返回,产出的是**全量状态**不是增量。这里照着这个形状造数据,才测得到
main._state_delta 做差那一步。
"""
def __init__(self, chunks=None, error=None):
super().__init__(state=None, error=error)
self.chunks = chunks or []
def stream(self, initial_state, config=None, stream_mode=None):
self.calls.append(initial_state)
self.configs.append(config)
if self.error is not None:
raise self.error
for chunk in self.chunks:
yield chunk
def _read_stream(response) -> list:
"""把换行分隔的响应体解析成消息列表"""
return [json.loads(line) for line in response.text.splitlines() if line.strip()]
def test_process_stream_ok() -> None:
"""逐节点产出 thinking,最后一条 final,且 data 是增量不是全量"""
print("\n[8] POST /process_stream 正常流式问答")
# 模拟三个节点依次执行,状态逐步累积(真图就是这个形状)
chunks = [
{"语义转写节点": {"input": "人参有什么功效?", "input_semantic_trans": "人参有什么功效?"}},
{
"中医意图识别节点": {
"input": "人参有什么功效?",
"input_semantic_trans": "人参有什么功效?",
"is_zhongyi_intent": True,
}
},
{
"图谱答案节点": {
"input": "人参有什么功效?",
"input_semantic_trans": "人参有什么功效?",
"is_zhongyi_intent": True,
"neo4j_answer": "人参能大补元气。",
"output": "人参能大补元气。",
}
},
]
with _client(FakeStreamGraph(chunks)) as client:
response = client.post(
"/process_stream", json={"input": "人参有什么功效?", "user_id": "user-123"}
)
_check(response.status_code == 200, "状态码 200", str(response.status_code))
messages = _read_stream(response)
thinking = [m for m in messages if m["type"] == "thinking"]
finals = [m for m in messages if m["type"] == "final"]
_check(len(finals) == 1, "有且只有一条 final", f"{len(finals)} 条")
_check(messages[-1]["type"] == "final", "final 是最后一条", messages[-1]["type"])
_check(len(thinking) >= 2, f"有多条 thinking({len(thinking)} 条)", str(len(thinking)))
# 关键:data 必须是这一步新增的键,不能把全量状态原样推出去
intent_msg = next((m for m in thinking if m["node"] == "中医意图识别节点"), None)
_check(intent_msg is not None, "能找到意图识别节点的消息")
if intent_msg:
_check(
intent_msg["data"] == {"is_zhongyi_intent": True},
"data 只含这一步的增量",
repr(intent_msg["data"]),
)
_check(
finals[0]["output"] == "人参能大补元气。", "final 取到 output", repr(finals[0]["output"])
)
_check(finals[0]["input"] == "人参有什么功效?", "final 回显 input", repr(finals[0]["input"]))
# thinking 里不该出现 output/history_messages 这类噪音键
noisy = [k for m in thinking for k in m["data"] if k in main.STREAM_SKIP_KEYS]
_check(not noisy, "thinking 不含被过滤的键", repr(noisy))
def test_process_stream_error() -> None:
"""图抛异常时,流以 error 消息收尾,而不是把堆栈漏出去或让请求挂住"""
print("\n[9] POST /process_stream 图执行异常")
with _client(FakeStreamGraph(error=RuntimeError("Neo4j 连接失败"))) as client:
response = client.post(
"/process_stream", json={"input": "人参", "user_id": "u"}
)
messages = _read_stream(response)
_check(response.status_code == 200, "状态码 200(头已发出,改不了)", str(response.status_code))
_check(len(messages) == 1, "只有一条消息", f"{len(messages)} 条")
_check(messages[-1]["type"] == "error", "最后一条是 error", messages[-1]["type"])
_check(
messages[-1]["output"] == main.ERROR_OUTPUT, "错误文案固定", repr(messages[-1]["output"])
)
_check("Neo4j" not in response.text, "堆栈/内部信息没有漏出去")
def test_process_stream_validation() -> None:
"""入参校验和 /process 共用同一个模型,行为要一致"""
print("\n[10] POST /process_stream 入参校验")
with _client(FakeStreamGraph([])) as client:
_check(
client.post("/process_stream", json={"input": "", "user_id": "u"}).status_code
== 422,
"空 input -> 422",
)
_check(
client.post("/process_stream", json={"input": "人参"}).status_code == 422,
"缺 user_id -> 422",
)
_check(
client.post("/process_stream", json={"input": " ", "user_id": "u"}).status_code
== 422,
"全空格 input -> 422",
)
def test_process_stream_thread_id() -> None:
"""user_id 要作为 thread_id 传给图,否则多轮记忆按用户隔离就失效了"""
print("\n[11] POST /process_stream 透传 user_id 作为 thread_id")
fake = FakeStreamGraph([{"节点": {"output": "答案"}}])
with _client(fake) as client:
client.post("/process_stream", json={"input": "人参", "user_id": "user-abc"})
_check(fake.calls == [{"input": "人参"}], "只把 input 传给图", repr(fake.calls))
thread_id = (fake.configs[0] or {}).get("configurable", {}).get("thread_id")
_check(thread_id == "user-abc", "thread_id == user_id", repr(thread_id))
# ======================
# --live:打真实服务
# ======================
LIVE_QUESTIONS = [
# 图谱路:药材属性
"人参有什么功效?",
# 图谱路:方剂 → 组成
"四君子汤由哪些药材组成?",
# 兜底路:非中医提问
"帮我写一段 Python 快排代码",
]
def run_live(base_url: str) -> None:
"""对着已启动的服务打真实请求。需要先 python -m _005_fastapi.main"""
import requests
print(f"\n=== live 模式:{base_url} ===")
print("\n[live-1] GET /")
try:
response = requests.get(f"{base_url}/", timeout=10)
except requests.RequestException as exc:
_check(False, "连接服务", f"{exc}(服务没启动?先跑 python -m _005_fastapi.main)")
return
_check(response.status_code == 200, "状态码 200", str(response.status_code))
_check(response.json().get("status") == "ok", "status == ok", response.text)
for i, question in enumerate(LIVE_QUESTIONS, start=2):
print(f"\n[live-{i}] POST /process {question!r}")
try:
# 文档建议 120 秒超时:单次问答 5-30 秒,复杂问题更久
response = requests.post(
f"{base_url}/process",
json={"input": question, "user_id": "test-user-001"},
timeout=120,
)
except requests.RequestException as exc:
_check(False, "请求成功", str(exc))
continue
_check(response.status_code == 200, "状态码 200", str(response.status_code))
body = response.json()
_check(body.get("input") == question, "input 回显", repr(body.get("input")))
answer = body.get("output") or ""
_check(bool(answer.strip()), "output 非空", repr(answer))
_check(answer != main.ERROR_OUTPUT, "不是错误文案", repr(answer))
print(f" 回答: {answer[:120]}")
print("\n[live-last] POST /process 入参校验")
response = requests.post(f"{base_url}/process", json={"input": ""}, timeout=10)
_check(response.status_code == 422, "空 input + 缺 user_id -> 422", str(response.status_code))
OFFLINE_TESTS = [
test_health_check,
test_process_ok,
test_process_direct_out,
test_process_validation,
test_process_error,
test_process_empty_output,
test_process_concurrent,
test_process_stream_ok,
test_process_stream_error,
test_process_stream_validation,
test_process_stream_thread_id,
]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="_005_fastapi 接口测试")
parser.add_argument(
"--live",
action="store_true",
help="对已启动的服务打真实请求(需要 LLM / Neo4j / FAISS 都可用)",
)
parser.add_argument("--base-url", default=BASE_URL, help=f"live 模式的服务地址,默认 {BASE_URL}")
args = parser.parse_args()
if args.live:
run_live(args.base_url.rstrip("/"))
else:
print("=== 离线模式:用假图替掉真图,不调用 LLM / Neo4j / FAISS ===")
for test in OFFLINE_TESTS:
test()
print(f"\n{'=' * 70}")
print(f"通过 {passed} 条,失败 {failed} 条")
sys.exit(1 if failed else 0)