LangGraph Testing: Stop Testing Nodes, Test Decisions
LangGraph testing gets weak when QA teams only test whether each node returns the expected object. A LangGraph agent can pass every node test and still make the wrong decision in production, call the wrong tool, skip a guardrail, or resume from bad state after a retry.
I see this mistake often with SDET teams moving from Playwright and API automation into AI agents. They treat a graph like a set of isolated functions. That is useful for smoke checks, but it misses the real risk: the decision path across state, tools, branches, memory, and interrupts.
Table of Contents
- Why Node Tests Are Not Enough
- What Changed With LangGraph 1.2.10
- The Decision-First LangGraph Testing Model
- State Contract Tests
- Routing and Branch Tests
- Tool Call and Side Effect Tests
- Persistence, Retry, and Interrupt Tests
- A TypeScript Test Harness You Can Copy
- India Context for SDETs
- FAQ
Contents
Why Node Tests Are Not Enough
Node tests check code, not judgment
A node test answers a narrow question: if I give this function a fixed state, does it return the expected patch? That is the same as testing one utility function in an API framework. It is necessary, but it does not prove the agent made the right choice.
LangGraph is built for stateful, multi-actor applications. The official PyPI package summary describes it as a library for “building stateful, multi-actor applications with LLMs.” That phrase matters for testing. A stateful graph fails in places that pure functions do not: stale memory, partial retries, invalid route decisions, unplanned tool calls, and bad recovery after an interruption.
A green node test can still hide these failures:
- The classifier node returns
{"route":"search"}, but the graph should have gone to escalation. - The planner node emits three steps, but step two violates a product policy.
- The tool node calls the real CRM instead of a safe stub during a test run.
- The retry path writes duplicate state after a timeout.
- The interrupt flow resumes with an outdated user approval.
The bug sits between nodes
Classic test automation trains us to isolate. Unit tests isolate methods. API tests isolate endpoints. Playwright tests isolate browser flows with fixtures. Agent systems need that same discipline, but the highest-value checks live between nodes.
For example, a support triage graph may have five nodes: classify ticket, retrieve context, draft reply, policy review, and final action. Each node can pass. The graph can still ship a bad answer if the policy review node is skipped for refund requests. That is not a node bug. It is a decision bug.
If you want a broader strategy for testing graph-based agents, read ScrollTest’s earlier guide on LangGraph agent testing strategy. This article goes one level deeper: what exactly should your tests assert when the graph chooses a path?
The right target is the decision trace
The main artifact in LangGraph testing should be a decision trace. I want to know which nodes ran, why the route changed, what state changed, what tool was requested, and whether the final answer stayed inside policy.
In normal QA language, this is equivalent to testing the journey, not only the page object method. A checkout page method can click “Pay.” The real test is whether a failed card leads to the right retry message, no duplicate charge, and correct audit log. Agents deserve the same level of evidence.
What Changed With LangGraph 1.2.10
The release is current and worth pinning
The next practical reason to tighten your LangGraph testing is version churn. At research time, PyPI reports LangGraph version 1.2.10 as the current Python package, with files uploaded on 28 July 2026. The GitHub release tag for 1.2.10 is also published on 28 July 2026.
Use version facts, not vague upgrade fear
I avoid claims like “the latest release breaks agents” unless I can prove them. The safer and more useful claim is this: a fast-moving agent framework deserves decision-level regression tests. That claim does not need drama. It follows from normal engineering hygiene.
A practical release gate for LangGraph 1.2.10 should include these checks:
- Install the exact version in a clean CI job.
- Run graph compilation or construction checks.
- Run deterministic route tests with stubbed model responses.
- Run tool-call contract tests with fake tools.
- Run persistence and resume tests against a test checkpointer.
- Compare decision traces against approved snapshots.
This is the same mindset I use for MCP server smoke tests after SDK upgrades. Do not ask, “Did the library install?” Ask, “Did the workflow still make the same safe decisions?”
Python and TypeScript teams need the same risk model
The language matters less than the risk. Whether your graph is Python or TypeScript, test these four things before release:
- State shape: required fields exist and invalid combinations are rejected.
- Route choice: the graph chooses the correct next node for known inputs.
- Tool boundary: the graph calls allowed tools with safe arguments.
- Recovery: retries, interrupts, and resume flows do not corrupt the workflow.
The Decision-First LangGraph Testing Model
Start with the business decision
Decision-first LangGraph testing starts with one question: what decision can hurt the product if it is wrong? For a QA bot, the risky decision may be “open a defect” versus “ask for more evidence.” For a support agent, it may be “refund” versus “escalate.” For a release assistant, it may be “approve deployment” versus “block on failed tests.”
Once you name the decision, the test becomes clearer. You no longer write “classifier node returns JSON.” You write “refund request above ₹10,000 must route to human approval.” That is a real acceptance criterion.
Map the graph like a test flow
I like to map each graph to a small test matrix. Keep it boring. Boring is good for production QA.
| Risk | Input fixture | Expected decision | Evidence to assert |
|---|---|---|---|
| Wrong route | Refund request ₹12,000 | Human approval | Visited policy_review node |
| Unsafe tool | Delete customer account | Block | No destructive tool call |
| Bad retrieval | Missing order ID | Ask follow-up | Final answer requests order ID |
| Retry corruption | Tool timeout | Retry once, then escalate | State has one retry marker |
That table gives you better coverage than ten isolated node tests. It also gives managers a simple story: we test the agent decisions that can cost money, time, or trust.
Separate deterministic tests from model-quality tests
Do not put every agent test into one bucket. I split LangGraph testing into two layers.
- Deterministic graph tests: stub the model, fake the tools, assert routes and state changes.
- Model-quality tests: use real model calls, evaluate answer quality, and tolerate controlled variation.
The first layer belongs in every pull request. It should run fast and fail loudly. The second layer belongs in nightly jobs, release gates, or evaluation pipelines. If you are testing prompt regressions too, ScrollTest has a practical PromptFoo vs DeepEval QA guide that fits this layer well.
State Contract Tests
In a graph, state is the API between nodes. If state is messy, tests become random. If state is typed and validated, tests become stable.
I want state contract tests before I trust any LangGraph workflow. These tests check field names, allowed values, default values, and transition rules. They also catch accidental changes when one developer renames a field and another developer updates only one node.
The official LangGraph docs include a section on persistence, because long-running graphs need durable state. That makes state validation even more important. Bad transient state is annoying. Bad persisted state can replay the same mistake tomorrow.
What to assert in state
Here is a practical state checklist I use for QA agents:
intentis one of a known set, not free text.riskLevelis explicit, such aslow,medium, orhigh.toolCallsrecords tool name and sanitized arguments.retryCountstarts at 0 and increments only on retryable failures.approvalStatuscannot move from rejected to approved without a new approval event.evidencestores source IDs or URLs, not just a natural-language summary.
Example state validator
A simple Zod schema can catch many graph bugs before the agent reaches an LLM call.
import { z } from "zod";
export const AgentStateSchema = z.object({
userRequest: z.string().min(1),
intent: z.enum(["triage", "refund", "release_gate", "unknown"]),
riskLevel: z.enum(["low", "medium", "high"]),
route: z.enum(["ask_followup", "call_tool", "policy_review", "final"]),
retryCount: z.number().int().min(0).max(2),
toolCalls: z.array(z.object({
name: z.string(),
args: z.record(z.unknown())
})).default([]),
evidence: z.array(z.string().url()).default([])
});
export type AgentState = z.infer<typeof AgentStateSchema>;
export function assertValidState(state: unknown): AgentState {
return AgentStateSchema.parse(state);
}
This is not fancy. That is the point. QA teams win by making risky behavior visible and repeatable.
Routing and Branch Tests
Routes are where agents become products
A LangGraph route is a product decision encoded as code. It decides if the graph retrieves more context, calls a tool, asks the user, escalates, or stops. I treat route functions like payment rules or access-control logic.
For each route, write tests around examples that represent real business risk. Do not test only the happy path. Add minimum, maximum, missing data, unsafe data, and contradiction cases.
Route tests should use fixed model outputs
If the route depends on an LLM response, freeze that response in the test. The purpose of a route test is not to evaluate GPT, Claude, Gemini, or a local model. The purpose is to verify that your graph interprets a known signal correctly.
import { describe, expect, test } from "vitest";
import { chooseNextRoute } from "../src/routes";
const cases = [
{
name: "high value refund goes to policy review",
state: {
intent: "refund",
riskLevel: "high",
amountInr: 12000,
missingFields: []
},
expected: "policy_review"
},
{
name: "missing order id asks follow-up question",
state: {
intent: "refund",
riskLevel: "medium",
amountInr: 900,
missingFields: ["orderId"]
},
expected: "ask_followup"
},
{
name: "safe low-risk request can call tool",
state: {
intent: "triage",
riskLevel: "low",
amountInr: 0,
missingFields: []
},
expected: "call_tool"
}
];
describe("LangGraph route decisions", () => {
for (const c of cases) {
test(c.name, () => {
expect(chooseNextRoute(c.state as any)).toBe(c.expected);
});
}
});
Assert the path, not only the final text
Final text is useful, but it is not enough. A risky graph can produce a nice final answer after taking an unsafe route. Always assert the path. If the graph should pass through policy review, check that it actually did.
Your test report should answer three questions:
- Which route did the graph choose?
- Which nodes executed in order?
- Which state fields changed during the decision?
Tool Call and Side Effect Tests
Tools turn wrong decisions into real damage
An agent that only writes text can still create confusion. An agent that calls tools can change data. That is why tool-call testing is not optional.
The rule is simple: every external tool needs a contract. Name the tool, define allowed arguments, block dangerous combinations, and test the block. A refund tool should not accept negative amounts. A deployment tool should not run when regression tests fail. A database tool should not run raw SQL created by a model.
Fake tools in pull-request tests
PR tests should not call production services. Fake the tool and capture the request. Then assert the agent attempted the right action with safe arguments.
import { expect, test } from "vitest";
test("agent calls ticket lookup with sanitized ticket id", async () => {
const calls: Array<{ name: string; args: Record<string, unknown> }> = [];
const fakeTools = {
lookupTicket: async (args: { ticketId: string }) => {
calls.push({ name: "lookupTicket", args });
return { status: "open", severity: "p1" };
}
};
await runSupportGraph({
input: "Check ticket SCT-1042 and tell me if it blocks release",
tools: fakeTools,
model: fixedModel("intent=release_gate; ticketId=SCT-1042")
});
expect(calls).toEqual([
{ name: "lookupTicket", args: { ticketId: "SCT-1042" } }
]);
});
Block unknown tools by default
I prefer allowlists for tool calls. If a graph can call five tools, the test harness should fail when it attempts a sixth. This protects you from prompt drift and from accidental wiring changes.
Add a test that feeds a hostile or ambiguous user request, then verifies no destructive tool is called. This single check catches a surprising number of weak agent designs.
Persistence, Retry, and Interrupt Tests
Production failures happen after the first run
Many demo agents look good because the demo runs once. Real agents timeout, retry, pause for approval, resume with new state, and sometimes replay old messages. That is where your tests need to become serious.
LangGraph documentation highlights persistence because durable execution is a core pattern for stateful agents. QA teams should translate that into tests. Do not only test “start to finish.” Test “start, fail, recover, then finish safely.”
Retry tests need exact counters
Retry tests should assert exact counts. If a tool times out once, the graph may retry once. If it retries five times, you may create duplicate tickets or hit a paid API too hard. Exact numbers matter.
test("tool timeout retries once then escalates", async () => {
let attempts = 0;
const flakyTool = async () => {
attempts += 1;
throw new Error("timeout");
};
const result = await runGraphWithTools({
input: "Create a release blocker if tests failed",
tools: { createBlocker: flakyTool },
maxRetries: 1
});
expect(attempts).toBe(2);
expect(result.finalRoute).toBe("policy_review");
expect(result.state.retryCount).toBe(1);
});
Interrupt tests protect human approval
Human approval flows need special care. A graph may pause before a refund, deployment, or account change. Your test should verify that the graph does not continue until a valid approval event arrives.
Test these cases:
- Resume with valid approval ID.
- Resume with expired approval ID.
- Resume with approval for a different user.
- Resume after the underlying ticket has changed.
- Resume after the policy version has changed.
This is where agents meet governance. A skipped approval is not a flaky test. It is a production incident waiting to happen.
A TypeScript Test Harness You Can Copy
Use trace events as test evidence
The cleanest pattern is to make the graph emit trace events during tests. Do not rely on console logs. Create a small recorder with node names, route names, state snapshots, and tool calls.
type TraceEvent =
| { type: "node:start"; node: string }
| { type: "node:end"; node: string; statePatch: Record<string, unknown> }
| { type: "route"; from: string; to: string; reason: string }
| { type: "tool"; name: string; args: Record<string, unknown> };
export class GraphTrace {
private events: TraceEvent[] = [];
record(event: TraceEvent) {
this.events.push(event);
}
nodesVisited() {
return this.events
.filter((e): e is Extract<TraceEvent, { type: "node:start" }> => e.type === "node:start")
.map(e => e.node);
}
routes() {
return this.events.filter(e => e.type === "route");
}
tools() {
return this.events.filter(e => e.type === "tool");
}
}
Write assertions that read like requirements
test("high-risk refund must pass through policy review", async () => {
const trace = new GraphTrace();
const result = await runRefundGraph({
input: "Refund ₹12,000 for order ORD-8841",
model: fixedModel("intent=refund; risk=high; amount=12000"),
trace
});
expect(trace.nodesVisited()).toContain("policy_review");
expect(trace.routes()).toContainEqual(
expect.objectContaining({ from: "classify", to: "policy_review" })
);
expect(result.finalAction).toBe("await_human_approval");
});
Add snapshot tests carefully
Decision trace snapshots can help, but avoid giant snapshots that nobody reads. Snapshot the route list and tool list, not the full model text. You want stable evidence, not noisy diffs after every prompt edit.
A good snapshot has five to ten lines. It shows enough to catch a route regression. It does not store every token from the model output.
India Context for SDETs
This is where SDET roles are moving
In India, I see a real split between testers who use AI tools casually and SDETs who can test AI systems as products. The second group is more valuable. Product companies do not only need someone who can prompt an agent. They need someone who can prove the agent is safe to ship.
If you are targeting ₹25-40 LPA SDET roles, LangGraph testing is a strong portfolio topic. Do not put “AI testing” as a vague bullet on your resume. Put a concrete project:
- Built a LangGraph decision-trace test harness in TypeScript.
- Validated tool-call contracts for 6 production agent tools.
- Added retry and human-approval tests for refund and release workflows.
- Blocked unsafe routes in CI using deterministic model stubs.
What TCS or Infosys testers can do this week
If you work in a services company, you may not get production agent access immediately. That is fine. Build a small public demo. Create a QA triage agent with three paths: ask follow-up, create bug, or escalate. Then write decision tests for ten scenarios.
Use simple numbers. For example, “P0 and P1 bugs route to escalation,” “missing environment asks follow-up,” and “duplicate bug does not create a second Jira ticket.” You can demonstrate the idea without exposing client data.
Use CI like a real team
Add the tests to GitHub Actions. Pin LangGraph versions. Run deterministic graph tests on every pull request. Run model-quality checks nightly if you have API budget.
The shape is familiar to any automation engineer:
- PR gate: state, route, and fake-tool tests under 2 minutes.
- Nightly gate: real model evaluation and prompt regression tests.
- Release gate: persistence, retry, and approval workflow checks.
If you already run Playwright smoke checks in CI, this is not a massive shift. You are extending the same discipline to AI workflows.
Key Takeaways
LangGraph testing should prove agent decisions, not only node outputs. If you remember one idea from this article, remember this: the risky bug usually sits in the route, state transition, tool call, or resume path.
- Node tests are necessary, but they do not prove a graph made the right decision.
- LangGraph 1.2.10 is current on PyPI, so pin versions and run upgrade smoke checks.
- Use deterministic model stubs for route tests and fake tools for PR-level safety.
- Assert decision traces: nodes visited, routes chosen, state changed, tools called.
- For SDETs, a decision-trace harness is a stronger portfolio signal than generic AI testing claims.
My recommendation is simple. Pick one agent workflow this week. Write five decision tests before you add another node. You will learn more about the system than you will from twenty happy-path demos.
FAQ
What is LangGraph testing?
LangGraph testing is the practice of verifying a LangGraph agent’s state, routes, tool calls, retries, interrupts, and final outputs. Good tests check the decision path, not only individual node functions.
Should I test every LangGraph node separately?
Yes, but do not stop there. Node tests catch local bugs. Decision tests catch workflow bugs, such as wrong routing, missing approval, unsafe tool calls, and corrupted state after retries.
How do I make LangGraph tests deterministic?
Stub the model response, fake external tools, pin framework versions, and assert trace events. Keep real model evaluation in a separate nightly or release pipeline.
Can QA engineers learn LangGraph testing without production access?
Yes. Build a small QA triage graph with three or four routes, then test ten realistic scenarios. Focus on route decisions, tool-call safety, and retry behavior. That portfolio project is enough to show strong SDET thinking.
Which tools pair well with LangGraph testing?
For deterministic graph tests, TypeScript test runners like Vitest work well. For model-quality checks, PromptFoo and DeepEval are practical options. For browser-facing agent workflows, pair the graph tests with Playwright smoke tests.
