[LangGrpah] Unit testing
在写普通函数的时候,测试其实是一件很自然的事情:给输入,看输出,对不对,一跑就知道。
但在 langgraph 里,情况会复杂一些。因为这里测试的对象,已经不只是一个函数了,而是:
- 多个节点组成的流程
- 带状态流转
- 可能中断、可能恢复
- 还可能依赖 checkpoint
也就是说,在 langgraph 里,我们测试的,其实是流程在某种状态下的行为是否符合预期。
正因为如此,langgraph 官方在测试这一块,给出了几种不同层级的测试方式,用来覆盖不同的开发场景。
- 整图测试
- 单节点测试
- 部分执行测试
整图测试
这是最基础、也是最常见的一种测试方式。它解决的问题只有一个:这个 graph,从头跑到尾,能不能正常执行完?
基本思路
整体流程测试的做法非常简单:
- 每个测试用例里
- 重新创建 graph
- 重新创建一个 checkpointer
- 然后直接
invoke,跑完整个流程 - 最后断言最终 state
这里有一个非常重要的原则:不同的测试用例之间,不能共享 graph 实例,也不能共享 checkpointer。
原因也很好理解:
- graph 是有内部状态的
- checkpointer 里会保存执行记录
- 一旦共享,就会互相污染测试结果
所以在测试里,哪怕 graph 的结构完全一样,也要每个 test 都重新 new 一份。
import { test, expect } from "vitest";
import { StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
import { z } from "zod/v4";
// 图的状态
const State = z.object({
my_key: z.string(),
});
// 这是一个工厂函数,调用之后返回一个 graph 的实例
const createGraph = () => {
return new StateGraph(State)
.addNode("node1", () => {
return { my_key: "node1节点信息" };
})
.addNode("node2", () => {
return { my_key: "node2节点信息" };
})
.addEdge(START, "node1")
.addEdge("node1", "node2")
.addEdge("node2", END);
};
test("基本测试,针对整张图做一个测试", async () => {
// 测试核心
// 1. 执行 2. 给出预期 3. 查看执行的结果是否符合预期
// 1. 创建图的实例
const builder = createGraph();
const checkpointer = new MemorySaver();
const graph = builder.compile({ checkpointer });
// 2. 执行整张图
const result = await graph.invoke(
{
my_key: "初始值",
},
{
configurable: {
thread_id: "1",
},
},
);
// 3. 断言
expect(result.my_key).toBe("node2节点信息");
});
单节点测试
有些时候,我们并不想跑完整个流程。比如:
- 某一个节点逻辑比较复杂
- 或者这个节点本身就像一个纯函数
- 我们只想验证它在某种输入下,输出是不是符合预期
这时候,就可以使用 单节点测试。
核心做法
在 graph 编译完成之后,可以直接:
- 从
compiledGraph.nodes中取出某个节点 - 直接调用这个节点的
invoke
这样做的结果是:
- 节点会像一个普通函数一样被执行
- 只关注输入和输出
- 不依赖整个流程
一个非常关键的点
有一个很容易被忽略,但非常重要的说明:直接调用节点,不会触发 checkpointer。
也就是说:
- 单节点测试,本质是一个纯逻辑测试
- 和 checkpoint、恢复、持久化没有任何关系
这点在理解上一定要拎清楚,否则很容易产生误解。
import { test, expect } from "vitest";
import { StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
import { z } from "zod/v4";
const State = z.object({
my_key: z.string(),
});
// 工厂函数
const createGraph = () => {
return new StateGraph(State)
.addNode("node1", () => {
return { my_key: "node1节点信息" };
})
.addNode("node2", () => {
return { my_key: "node2节点信息" };
})
.addNode("node3", (state) => {
return { my_key: `${state.my_key} -> 节点3处理完毕` };
})
.addNode("node4", () => {
throw new Error("节点4发生的测试错误");
})
.addEdge(START, "node1")
.addEdge("node1", "node2")
.addEdge("node2", END)
.addEdge(START, "node3")
.addEdge("node3", END)
.addEdge(START, "node4")
.addEdge("node4", END);
};
test("测试节点1", async () => {
const builder = createGraph();
const checkpointer = new MemorySaver();
const graph = builder.compile({ checkpointer });
// 拿到了节点1的执行结果
const result = await graph.nodes["node1"].invoke({
my_key: "初始值",
});
// 断言
expect(result.my_key).toBe("node1节点信息");
});
test("测试节点3", async () => {
// 每一个测试用例需要有一个独立的graph
const builder = createGraph();
const checkpointer = new MemorySaver();
const graph = builder.compile({ checkpointer });
const result = await graph.nodes["node3"].invoke({
my_key: "aaa",
});
expect(result.my_key).toBe("aaa -> 节点3处理完毕");
});
test("测试节点4", async () => {
// 每一个测试用例需要有一个独立的graph
const builder = createGraph();
const checkpointer = new MemorySaver();
const graph = builder.compile({ checkpointer });
await expect(
graph.nodes["node4"].invoke({
my_key: "bbb",
}),
).rejects.toThrow("节点4发生的测试错误");
});
部分执行测试
前面两种测试方式,其实都比较好理解。但在真实项目中,很容易遇到这样一种情况:
- graph 已经比较复杂
- 中间有很多节点
- 你只想测试“流程中间的一小段”
- 但又不想为了测试去重构成 subgraph
为了解决这个问题,langgraph 提供了 部分执行 的能力。
核心思想
部分执行的核心思想只有一句话:通过 checkpoint,模拟流程已经走到某个节点之后,再继续往下执行。
具体来说,就是两步:
- 使用
updateState- 人工构造一个 state
- 并指定它“相当于哪个节点刚刚执行完”
- 使用同一个
thread_id再次invoke- 让流程从这个位置继续跑
- 并在指定节点后中断
这样一来,就可以做到:
- 不从 START 开始
- 只测试你关心的那一段流程
这种方式的意义
这种测试方式非常“工程化”,它解决的是:
- 复杂流程难以精确测试的问题
- 尤其适合中大型 graph
- 或者包含人工介入的场景
你可以把它类比成:加载一个存档,直接从关卡中间开始测试。
import { test, expect } from "vitest";
import { StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
import { z } from "zod/v4";
const State = z.object({
my_key: z.string(),
step_history: z.array(z.string()).default([]), // 做字段变化的记录,方便观察流程,相当于节点足迹
});
const createGraph = () => {
return (
new StateGraph(State)
.addNode("node1", (state) => ({
my_key: "node1节点信息",
step_history: [...state.step_history, "经过node1"],
}))
// 节点2
.addNode("node2", (state) => ({
my_key: "node2节点信息",
step_history: [...state.step_history, "经过node2"],
}))
// 节点3
.addNode("node3", (state) => ({
my_key: "node3节点信息",
step_history: [...state.step_history, "经过node3"],
}))
// 节点4
.addNode("node4", (state) => ({
my_key: "node4节点信息",
step_history: [...state.step_history, "经过node4"],
}))
.addEdge(START, "node1")
.addEdge("node1", "node2")
.addEdge("node2", "node3")
.addEdge("node3", "node4")
.addEdge("node4", END)
);
};
test("跳过node1,直接从node2开始执行,并在node3停下", async () => {
// 每一个测试用例需要有一个独立的graph
const builder = createGraph();
const checkpointer = new MemorySaver();
const graph = builder.compile({ checkpointer });
const config = {
configurable: {
thread_id: "test-thread-1",
},
};
// 更新状态:因为要跳过node1,更新一个假的node1节点执行完的状态
await graph.updateState(
config,
{
my_key: "我是伪造的节点1数据",
step_history: ["伪造的node1的执行历史"],
},
// 这个参数表明当前的这份状态是node1产生的
// 系统看到这个,就会去找node1的下一个节点
"node1",
);
// 断言
const result = await graph.getState(config); // 拿到当前最新的状态
expect(result.next).toEqual(["node2"]); // 下一个节点应该是node2节点
expect(result.values.my_key).toBe("我是伪造的节点1数据");
// 接下来要跑到node3停止
// 第1个参数是null,代表不要新的状态输入,而是使用checkpoint存档里面的状态继续跑
const result2 = await graph.invoke(null, {
...config,
interruptAfter: ["node3"], // 代表了执行完node3节点后中断
});
// 断言
expect(result2.my_key).toBe("node3节点信息");
expect(result2.step_history).toEqual([
"伪造的node1的执行历史",
"经过node2",
"经过node3",
]);
// 再次查看当前的状态
const finalState = await graph.getState(config);
// 因为目前是在node3中断了,所以下一个节点应该是node4
expect(finalState.next).toEqual(["node4"]);
});

浙公网安备 33010602011771号