第五单元——第11课:自动测试“拒绝后绝不能写入”

第十课验证了防重复保存。这节验证人工审批的三个关键行为:

场景 数据库里应该有几条记录
等待确认 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)

重要操作的测试,要检查实际产生了什么,不只检查程序说了什么。

本课验证的是最小测试工具与保存函数的配合,还不是原书店助手的完整端到端测试;模型选工具、真实用户身份、库存变化等,需要另外覆盖。

相关推荐
老徐聊GEO1 小时前
把“安徽“写进题面之后:多平台AI推荐名单的区域扰动测量(2026)
前端·人工智能·算法·geo优化
人工智能培训1 小时前
具身智能:让人工智能拥有物理世界感知能力
大数据·人工智能·学习·算法·机器学习
科技小花1 小时前
AI算力需求井喷,中立云如何承接大模型与具身智能需求
人工智能
小朱爱编程1231 小时前
AI点了两次发布,我多了两篇事故
数据库·人工智能·大模型·软件工程
ratel️1 小时前
小墨析 · Office Agent 日常办公不烧心
python·langchain·word·excel·fastapi·js·ppt
网易易盾1 小时前
AI Agent 的安全边界在哪里?四条边界与上线前后的防护落地
人工智能·安全
愤怒火龙果1 小时前
AIGC 安全学习笔记01 深伪检测・文本隐写・LoRA
人工智能·aigc·ai安全
AI浩1 小时前
Xiaomi-Lens-0 技术报告
人工智能·计算机视觉·1024程序员节
Rocky Ding*1 小时前
【三年面试五年模拟】2026-10-20_淘天AI应用研发一面面经全解析
论文阅读·人工智能·深度学习·机器学习·面试·aigc·ai-native