"""_006_streamlit 的测试用例
python -X utf8 -m _006_streamlit.test_app # 离线(默认)
python -X utf8 -m _006_streamlit.test_app --live # 打真实后端
测的是 api_client.py —— 前端里唯一有逻辑的部分。`app.py` 全是 st.* 调用,
import 它就等于渲染一遍页面,没法当函数测;把网络这层拆出来正是为了能测。
离线模式把 `requests.post` / `requests.get` 换成假函数,所以不需要后端在跑。
`--live` 需要 `python -X utf8 -m _005_fastapi.main` 已经起好。
和 _005_fastapi/test_api.py 同一套框架:_check 计数,失败退出码非 0。
"""
import argparse
import sys
import requests
from _006_streamlit import api_client
from _006_streamlit.api_client import (
BackendError,
ask_backend,
ask_backend_stream,
check_health,
)
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 FakeResponse:
"""够用的 requests.Response 替身:只有 status_code / json() / text"""
def __init__(self, status_code=200, payload=None, text=None):
self.status_code = status_code
self._payload = payload
# text 显式传 "" 时也要尊重,所以用 is None 判断而不是 or
self.text = text if text is not None else str(payload)
def json(self):
if self._payload is None:
raise ValueError("不是 JSON")
return self._payload
class FakePost:
"""替掉 requests.post。返回固定响应或抛指定异常,并记录收到的参数"""
def __init__(self, response=None, error=None):
self.response = response
self.error = error
self.calls = []
def __call__(self, url, json=None, timeout=None):
self.calls.append({"url": url, "json": json, "timeout": timeout})
if self.error is not None:
raise self.error
return self.response
def _patch_post(fake) -> None:
"""把 api_client 用的 requests.post 换掉。
api_client 里是 `requests.post(...)`(模块属性访问),所以改 requests 模块上的
这个名字就能生效。每个用例调用一次,最后一个用例跑完不用还原 —— 进程随即结束。
"""
requests.post = fake
class FakeStreamResponse(FakeResponse):
"""流式响应的替身:iter_lines 逐行吐出给定内容,并支持 with。
真的流式响应是 `with requests.post(..., stream=True) as r:` 用的,
所以必须实现上下文管理协议,否则 api_client 那句 with 就报 AttributeError。
closed 记下有没有被正确关闭 —— 连接泄漏在测试里看不出症状,得显式断言。
"""
def __init__(self, lines=None, status_code=200, payload=None, text=None):
super().__init__(status_code=status_code, payload=payload, text=text)
self.lines = lines or []
self.closed = False
def __enter__(self):
return self
def __exit__(self, *exc_info):
self.closed = True
return False
def iter_lines(self, decode_unicode=False):
for line in self.lines:
yield line
class FakeStreamPost:
"""替掉 requests.post 的流式版本:额外记录 stream 参数"""
def __init__(self, response=None, error=None):
self.response = response
self.error = error
self.calls = []
def __call__(self, url, json=None, timeout=None, stream=None):
self.calls.append(
{"url": url, "json": json, "timeout": timeout, "stream": stream}
)
if self.error is not None:
raise self.error
return self.response
def _stream_lines(*messages) -> list:
"""把消息字典转成后端那种"一行一个 JSON"的形状"""
import json as _json
return [_json.dumps(m, ensure_ascii=False) for m in messages]
# ======================
# 用例 1:正常返回
# ======================
def test_ask_ok() -> None:
print("\n[1] ask_backend 正常返回")
answer = "人参具有大补元气、补脾益肺等功效。"
fake = FakePost(FakeResponse(200, {"input": "人参有什么功效?", "output": answer}))
_patch_post(fake)
result = ask_backend("人参有什么功效?", user_id="u-1", base_url="http://127.0.0.1:8000")
_check(result == answer, "取到 output 里的回答", repr(result))
call = fake.calls[0]
_check(call["url"] == "http://127.0.0.1:8000/process", "URL 拼接正确", call["url"])
_check(
call["json"] == {"input": "人参有什么功效?", "user_id": "u-1"},
"请求体是 input + user_id",
str(call["json"]),
)
_check(call["timeout"] == api_client.DEFAULT_TIMEOUT, "默认超时 120 秒", str(call["timeout"]))
# ======================
# 用例 2:base_url 末尾斜杠
# ======================
def test_base_url_trailing_slash() -> None:
print("\n[2] base_url 末尾带斜杠")
fake = FakePost(FakeResponse(200, {"output": "回答"}))
_patch_post(fake)
ask_backend("问题", user_id="u", base_url="http://127.0.0.1:8000/")
# 不处理的话会拼出 //process
_check(
fake.calls[0]["url"] == "http://127.0.0.1:8000/process",
"不会拼出双斜杠",
fake.calls[0]["url"],
)
# ======================
# 用例 3:网络异常
# ======================
def test_network_errors() -> None:
print("\n[3] 网络异常都收敛成 BackendError")
cases = [
(requests.ConnectionError("拒绝连接"), "连不上", "ConnectionError"),
(requests.Timeout("超时"), "超时", "Timeout"),
(requests.RequestException("其他错误"), "失败", "RequestException"),
]
for error, keyword, label in cases:
_patch_post(FakePost(error=error))
try:
ask_backend("问题", user_id="u")
_check(False, f"{label} 抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check(keyword in str(exc), f"{label} -> 含'{keyword}'的中文提示", str(exc))
except Exception as exc:
_check(False, f"{label} 抛出 BackendError", f"抛的是 {type(exc).__name__}")
# ConnectionError 的提示里要给出启动命令,用户照着做就行
_patch_post(FakePost(error=requests.ConnectionError("拒绝连接")))
try:
ask_backend("问题", user_id="u")
except BackendError as exc:
_check("_005_fastapi.main" in str(exc), "连不上时提示启动命令", str(exc))
# ======================
# 用例 4:HTTP 错误码
# ======================
def test_http_errors() -> None:
print("\n[4] 后端返回错误码")
# 500:后端仍然给 {"input", "output"},把它的文案透出来
_patch_post(FakePost(FakeResponse(500, {"input": "", "output": api_client.ERROR_OUTPUT})))
try:
ask_backend("问题", user_id="u")
_check(False, "500 抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check(str(exc) == api_client.ERROR_OUTPUT, "500 透出后端的错误文案", str(exc))
# 422:入参校验没过,把 detail 带出来,比只看到 "422" 有用
_patch_post(FakePost(FakeResponse(422, {"detail": [{"msg": "不能为空"}]}, text='{"detail":"不能为空"}')))
try:
ask_backend("", user_id="u")
_check(False, "422 抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check("参数不合法" in str(exc), "422 提示参数不合法", str(exc))
# 500 但响应体不是 JSON(比如反向代理返的 HTML 错误页)
_patch_post(FakePost(FakeResponse(502, None, text="<html>Bad Gateway</html>")))
try:
ask_backend("问题", user_id="u")
_check(False, "502 抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check("502" in str(exc), "非 JSON 错误响应也有提示", str(exc))
# ======================
# 用例 5:响应体异常
# ======================
def test_bad_body() -> None:
print("\n[5] 200 但响应体不对")
cases = [
(FakeResponse(200, None, text="not json"), "不是 JSON", "不是 JSON"),
(FakeResponse(200, {"input": "x"}), "空回答", "没有 output 键"),
(FakeResponse(200, {"output": None}), "空回答", "output 为 None"),
(FakeResponse(200, {"output": " "}), "空回答", "output 全空白"),
]
for response, keyword, label in cases:
_patch_post(FakePost(response))
try:
ask_backend("问题", user_id="u")
_check(False, f"{label} 抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check(keyword in str(exc), f"{label} -> 含'{keyword}'的提示", str(exc))
# ======================
# 用例 6:健康检查
# ======================
def test_check_health() -> None:
print("\n[6] check_health")
class FakeGet:
def __init__(self, response=None, error=None):
self.response = response
self.error = error
self.calls = []
def __call__(self, url, timeout=None):
self.calls.append({"url": url, "timeout": timeout})
if self.error is not None:
raise self.error
return self.response
cases = [
(FakeGet(FakeResponse(200, {"status": "ok", "message": "正常"})), True, "后端正常 -> True"),
(FakeGet(FakeResponse(200, {"status": "degraded"})), False, "status 不是 ok -> False"),
(FakeGet(FakeResponse(500, {})), False, "500 -> False"),
(FakeGet(FakeResponse(200, None, text="not json")), False, "非 JSON -> False"),
(FakeGet(error=requests.ConnectionError("拒绝连接")), False, "连不上 -> False"),
(FakeGet(error=requests.Timeout("超时")), False, "超时 -> False"),
]
for fake, expected, label in cases:
requests.get = fake
_check(check_health("http://127.0.0.1:8000") is expected, label)
# 健康检查是给状态灯用的,超时必须短,不能拿 120 秒去卡页面
_check(fake.calls[-1]["timeout"] == 5, "健康检查超时是 5 秒", str(fake.calls[-1]["timeout"]))
# ======================
# 用例 7:流式接口正常返回
# ======================
def test_stream_ok() -> None:
"""逐条 yield thinking + final,且请求带上了 stream=True"""
print("\n[7] ask_backend_stream 正常返回")
messages = [
{"type": "thinking", "node": "语义转写节点", "data": {"input_semantic_trans": "人参有什么功效?"}},
{"type": "thinking", "node": "中医意图识别节点", "data": {"is_zhongyi_intent": True}},
{"type": "final", "input": "人参有什么功效?", "output": "人参能大补元气。"},
]
response = FakeStreamResponse(lines=_stream_lines(*messages))
fake = FakeStreamPost(response)
_patch_post(fake)
got = list(
ask_backend_stream("人参有什么功效?", user_id="u-1", base_url="http://127.0.0.1:8001")
)
_check(len(got) == 3, "收到 3 条消息", f"{len(got)} 条")
_check(got == messages, "消息原样透出,没被改写")
_check(got[-1]["type"] == "final", "最后一条是 final", got[-1]["type"])
call = fake.calls[0]
_check(call["url"] == "http://127.0.0.1:8001/process_stream", "URL 拼接正确", call["url"])
# 少了 stream=True,requests 会把响应体读完才返回,流式就退化成一次性接口
_check(call["stream"] is True, "带上了 stream=True", repr(call["stream"]))
_check(
call["json"] == {"input": "人参有什么功效?", "user_id": "u-1"},
"请求体符合接口契约",
repr(call["json"]),
)
_check(response.closed, "响应被正确关闭(with 退出)")
# ======================
# 用例 8:流式是真的边收边出
# ======================
def test_stream_is_lazy() -> None:
"""必须是生成器、按需产出,不能先攒完再返回 —— 否则页面还是要等满 30 秒"""
print("\n[8] ask_backend_stream 惰性产出")
consumed = []
class CountingResponse(FakeStreamResponse):
def iter_lines(self, decode_unicode=False):
for line in self.lines:
consumed.append(line)
yield line
messages = [
{"type": "thinking", "node": "A", "data": {"x": 1}},
{"type": "thinking", "node": "B", "data": {"y": 2}},
{"type": "final", "input": "q", "output": "done"},
]
_patch_post(FakeStreamPost(CountingResponse(lines=_stream_lines(*messages))))
generator = ask_backend_stream("q", user_id="u")
# 只调用还没迭代:一行都不该被读
_check(consumed == [], "调用后未迭代时不发起读取", f"已读 {len(consumed)} 行")
first = next(generator)
_check(first["node"] == "A", "取第一条就能拿到第一个节点", repr(first))
_check(len(consumed) == 1, "取一条只读了一行", f"读了 {len(consumed)} 行")
rest = list(generator)
_check(len(rest) == 2, "剩下两条能继续取完", f"{len(rest)} 条")
# ======================
# 用例 9:流式的异常与脏数据
# ======================
def test_stream_errors() -> None:
"""连不上/超时/422 抛 BackendError;error 消息和坏行不抛,照原样处理"""
print("\n[9] ask_backend_stream 异常处理")
# 没能开始流的三种情况,都该收敛成 BackendError
cases = [
(FakeStreamPost(error=requests.ConnectionError()), "连不上", "连不上后端"),
(FakeStreamPost(error=requests.Timeout()), "超时", "超时"),
(
FakeStreamPost(FakeStreamResponse(status_code=422, payload={"detail": "x"})),
"422",
"参数不合法",
),
(
FakeStreamPost(
FakeStreamResponse(status_code=500, payload={"output": api_client.ERROR_OUTPUT})
),
"500",
api_client.ERROR_OUTPUT,
),
]
for fake, label, keyword in cases:
_patch_post(fake)
try:
list(ask_backend_stream("q", user_id="u"))
_check(False, f"{label} 抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check(keyword in str(exc), f"{label} -> 含'{keyword}'的提示", str(exc))
except Exception as exc:
_check(False, f"{label} 抛出 BackendError", f"抛的是 {type(exc).__name__}")
# 后端图跑挂了:HTTP 是 200,error 是流里正常的一行,不该抛异常
_patch_post(
FakeStreamPost(
FakeStreamResponse(
lines=_stream_lines(
{"type": "error", "input": "q", "output": api_client.ERROR_OUTPUT}
)
)
)
)
got = list(ask_backend_stream("q", user_id="u"))
_check(len(got) == 1 and got[0]["type"] == "error", "error 消息照原样 yield", repr(got))
# 空行要跳过,坏行不能让整轮问答报废
_patch_post(
FakeStreamPost(
FakeStreamResponse(
lines=[
"",
" ",
"{坏掉的 json",
*_stream_lines({"type": "final", "input": "q", "output": "答案"}),
]
)
)
)
got = list(ask_backend_stream("q", user_id="u"))
_check(len(got) == 1, "空行和坏行被跳过", f"{len(got)} 条")
_check(got[0]["output"] == "答案", "坏行之后的消息照样收到", repr(got[0]))
# 一条消息都没有:不是正常结束,得报出来而不是静默返回空
_patch_post(FakeStreamPost(FakeStreamResponse(lines=[])))
try:
list(ask_backend_stream("q", user_id="u"))
_check(False, "空流抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check("没有返回任何消息" in str(exc), "空流 -> 提示没有消息", str(exc))
# ======================
# 用例 10:app.py 能正常导入
# ======================
def test_app_importable() -> None:
"""页面代码本身跑不了断言,但至少确认它没有语法错误、导入名都对得上。
直接 import app 会执行所有 st.* 调用(脱离 streamlit runtime 会报错),
所以用 compile + 检查引用的名字确实存在于 api_client 里。
"""
print("\n[10] 页面静态检查")
import ast
from pathlib import Path
# app.py 和 pages/ 下的每一页都过一遍,新增页面不用改这里
here = Path(__file__).parent
pages = [here / "app.py", *sorted((here / "pages").glob("*.py"))]
_check(len(pages) >= 2, f"找到 {len(pages)} 个页面文件", str([p.name for p in pages]))
for page in pages:
source = page.read_text(encoding="utf-8")
try:
tree = ast.parse(source)
_check(True, f"{page.name} 语法正确")
except SyntaxError as exc:
_check(False, f"{page.name} 语法正确", str(exc))
continue
# 从 api_client 导入的名字必须真的存在,否则运行时才会炸
imported = [
alias.name
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module and "api_client" in node.module
for alias in node.names
]
_check(bool(imported), f"{page.name} 确实从 api_client 导入了东西", str(imported))
missing = [name for name in imported if not hasattr(api_client, name)]
_check(not missing, f"{page.name} 导入的名字都存在", f"缺少 {missing}")
# ======================
# --live:打真实后端
# ======================
def run_live(base_url: str) -> None:
"""对着已启动的 _005_ 后端走一遍真实调用。
注意这里不 _patch_post —— 用的是真的 requests。
"""
print(f"\n=== live 模式:{base_url} ===")
print("\n[live-1] check_health")
if not check_health(base_url):
_check(False, "后端可用", f"连不上 {base_url},先跑 python -X utf8 -m _005_fastapi.main")
return
_check(True, "后端可用")
for i, question in enumerate(["人参有什么功效?", "帮我写一段 Python 快排代码"], start=2):
print(f"\n[live-{i}] ask_backend {question!r}")
try:
answer = ask_backend(question, user_id="streamlit-live-test", base_url=base_url)
except BackendError as exc:
_check(False, "拿到回答", str(exc))
continue
_check(bool(answer.strip()), "回答非空", repr(answer))
_check(answer != api_client.ERROR_OUTPUT, "不是错误文案", repr(answer))
print(f" 回答: {answer[:120]}")
print("\n[live-stream] ask_backend_stream '四君子汤由哪些药材组成?'")
try:
got = list(
ask_backend_stream(
"四君子汤由哪些药材组成?",
user_id="streamlit-live-test",
base_url=base_url,
)
)
except BackendError as exc:
_check(False, "流式调用成功", str(exc))
got = []
if got:
thinking = [m for m in got if m.get("type") == "thinking"]
finals = [m for m in got if m.get("type") == "final"]
# 查图谱的问题要走完整条链,节点数明显多于闲聊
_check(len(thinking) >= 3, f"收到多条 thinking({len(thinking)} 条)", str(len(thinking)))
_check(len(finals) == 1, "有且只有一条 final", f"{len(finals)} 条")
_check(got[-1].get("type") == "final", "final 是最后一条", str(got[-1].get("type")))
if finals:
answer = finals[0].get("output") or ""
_check(bool(answer.strip()), "final 的 output 非空", repr(answer))
_check(answer != api_client.ERROR_OUTPUT, "不是错误文案", repr(answer))
print(f" 节点: {[m.get('node') for m in thinking]}")
print(f" 回答: {answer[:120]}")
print("\n[live-last] 空问题应被后端挡回")
try:
ask_backend("", user_id="u", base_url=base_url)
_check(False, "空问题抛出 BackendError", "没有抛异常")
except BackendError as exc:
_check("参数不合法" in str(exc), "空问题 -> 参数不合法", str(exc))
OFFLINE_TESTS = [
test_ask_ok,
test_base_url_trailing_slash,
test_network_errors,
test_http_errors,
test_bad_body,
test_check_health,
test_stream_ok,
test_stream_is_lazy,
test_stream_errors,
test_app_importable,
]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="_006_streamlit 前端测试")
parser.add_argument("--live", action="store_true", help="对已启动的后端发真实请求")
parser.add_argument(
"--base-url", default=api_client.DEFAULT_BASE_URL, help="live 模式的后端地址"
)
args = parser.parse_args()
if args.live:
run_live(args.base_url.rstrip("/"))
else:
print("=== 离线模式:假造 requests,不需要后端在跑 ===")
for test in OFFLINE_TESTS:
test()
print(f"\n{'=' * 70}")
print(f"通过 {passed} 条,失败 {failed} 条")
sys.exit(1 if failed else 0)