第十课验证了防重复保存。这节验证人工审批的三个关键行为:
| 场景 | 数据库里应该有几条记录 |
|---|---|
| 等待确认 | 0 |
| 人工拒绝 | 0 |
| 人工同意 | 1 |
我们用一个最小测试工具,复用第九课的保存函数。不调用模型,也不需要 API Key。
1. 新建文件
C:\Users\Administrator\langchain-study\test_unit5_lesson11_approval.py
与第九课文件放在同一目录,写入:
import sqlite3
import unittest
from contextlib import closing
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from langchain.tools import tool, ToolRuntime
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode
from langgraph.types import interrupt, Command
import unit5_lesson9_idempotency as orders
@tool
def approval_demo(runtime: ToolRuntime) -> str:
"""测试专用:经人工确认后保存一条固定的模拟记录。"""
approved = interrupt({
"book_name": "三体",
"quantity": 2,
"total_yuan": "81.00",
})
if approved is not True:
return "已取消,没有保存记录。"
# 固定测试数据,不代表真实登录用户或真实订单
order_id, _ = orders.save_simulated_order(
user_id="A",
request_id=runtime.tool_call_id,
book_name="三体",
quantity=2,
total_yuan="81.00",
)
return f"模拟记录已保存,编号={order_id}"
class TestApproval(unittest.TestCase):
def setUp(self):
# 每个测试使用独立数据库
self.temp_dir = TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = Path(self.temp_dir.name) / "orders.sqlite"
patcher = patch.object(orders, "DB_PATH", self.db_path)
patcher.start()
self.addCleanup(patcher.stop)
# 每个测试也使用独立的图和内存检查点
builder = StateGraph(MessagesState)
builder.add_node("tools", ToolNode([approval_demo]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
self.graph = builder.compile(
checkpointer=InMemorySaver(),
)
self.config = {
"configurable": {
"thread_id": "approval-test",
}
}
def start_request(self):
"""手动构造工具请求,不调用大模型。"""
message = AIMessage(
content="",
tool_calls=[{
"name": "approval_demo",
"args": {},
"id": "test-call-1",
"type": "tool_call",
}],
)
return self.graph.invoke(
{"messages": [message]},
config=self.config,
)
def count_records(self):
"""读取测试数据库中的记录数量。"""
if not self.db_path.exists():
return 0
with closing(sqlite3.connect(self.db_path)) as conn:
return conn.execute(
"SELECT COUNT(*) FROM simulated_orders"
).fetchone()[0]
def test_waiting_does_not_write(self):
result = self.start_request()
self.assertTrue(result.get("__interrupt__"))
self.assertEqual(self.count_records(), 0)
def test_rejection_does_not_write(self):
paused = self.start_request()
self.assertTrue(paused.get("__interrupt__"))
result = self.graph.invoke(
Command(resume=False),
config=self.config,
)
self.assertFalse(result.get("__interrupt__"))
self.assertEqual(self.count_records(), 0)
self.assertIn("已取消", result["messages"][-1].content)
def test_approval_writes_one_record(self):
paused = self.start_request()
self.assertTrue(paused.get("__interrupt__"))
result = self.graph.invoke(
Command(resume=True),
config=self.config,
)
self.assertFalse(result.get("__interrupt__"))
self.assertEqual(self.count_records(), 1)
self.assertIn("已保存", result["messages"][-1].content)
if __name__ == "__main__":
unittest.main(verbosity=2)
2. 运行测试
python test_unit5_lesson11_approval.py
预期三个测试都显示 ok,最后:
Ran 3 tests in ...s
OK
测试只使用临时数据库,不会修改第九课的 simulated_orders.sqlite。
3. 为什么不需要调用模型?
因为我们手动构造了模型通常会返回的工具请求:
AIMessage(
content="",
tool_calls=[{
"name": "approval_demo",
"args": {},
"id": "test-call-1",
"type": "tool_call",
}],
)
这相当于告诉测试图:
假设模型已经提出调用
approval_demo,现在检查程序怎样处理。
这里的 args 为空,是因为测试工具没有需要模型填写的业务参数;runtime 由 ToolNode 注入。
我们测试的是审批和写入逻辑,不是模型是否选择了正确工具。
4. 自动测试怎样模拟人工选择?
人工同意:
Command(resume=True)
人工拒绝:
Command(resume=False)
它们会成为工具里这行代码的返回值:
approved = interrupt(...)
由于本例每次只有一个中断,可以直接传布尔值。恢复时使用与暂停时相同的 thread_id。官方说明
5. 再巩固一下 self
上节课代码中有 self.save()。这里是同样的写法:
self.start_request()
调用当前测试对象的 start_request 方法,启动一次请求。
self.count_records()
调用当前测试对象的 count_records 方法,读取记录数。
因此:
self.assertEqual(self.count_records(), 0)
可以读成:
先查询记录数量,再检查它是否等于0。
6. 为什么不能只检查"已取消"这句话?
只检查:
self.assertIn("已取消", result["messages"][-1].content)
还不够。程序可能已经写入数据库,却返回了"已取消"。
所以还必须检查实际结果:
self.assertEqual(self.count_records(), 0)
重要操作的测试,要检查实际产生了什么,不只检查程序说了什么。
本课验证的是最小测试工具与保存函数的配合,还不是原书店助手的完整端到端测试;模型选工具、真实用户身份、库存变化等,需要另外覆盖。
