[LangGrpah] Unit testing

在写普通函数的时候,测试其实是一件很自然的事情:给输入,看输出,对不对,一跑就知道。

但在 langgraph 里,情况会复杂一些。因为这里测试的对象,已经不只是一个函数了,而是:

  • 多个节点组成的流程
  • 带状态流转
  • 可能中断、可能恢复
  • 还可能依赖 checkpoint

也就是说,在 langgraph 里,我们测试的,其实是流程在某种状态下的行为是否符合预期。

正因为如此,langgraph 官方在测试这一块,给出了几种不同层级的测试方式,用来覆盖不同的开发场景。

  1. 整图测试
  2. 单节点测试
  3. 部分执行测试

整图测试

这是最基础、也是最常见的一种测试方式。它解决的问题只有一个:这个 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,模拟流程已经走到某个节点之后,再继续往下执行。

具体来说,就是两步:

  1. 使用 updateState
    • 人工构造一个 state
    • 并指定它“相当于哪个节点刚刚执行完”
  2. 使用同一个 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"]);
});
posted @ 2026-03-25 15:12  Zhentiw  阅读(3)  评论(0)    收藏  举报