← all posts
// testing · langgraph

Testing graphs: nodes as functions, topology as fixture

When we moved a client's ticket triage pipeline onto LangGraph in May, the old test suite didn't survive the trip. Those tests asserted on final output strings, and a nine-node graph with three conditional edges produces its final string by such an indirect route that every assertion went flaky within a week. I spent five evenings patching them before admitting the approach was dead, deleting all 23, and starting over from a different premise.

The premise: a LangGraph node is a function from state to a partial state update. That's the whole contract. Take it seriously and the testing story splits into two layers that want very different tests.

nodes are functions, so test them like functions

Every node in the triage graph gets unit tests that never touch the graph. Build a state dict by hand, call the node, assert on the returned update. No runtime, no checkpointer, no network. The classifier node has eleven of these. One per ticket category, plus the weird ones: empty subject line, pasted stack trace, Czech and English mixed in a single paragraph.

Nodes that call a model get a fake injected. I pass the model in when the node is constructed instead of importing it at module level, so tests hand over a stub that returns canned responses in order. It's the same trick I use on plain chains in langchain-testing; nothing about it is graph-specific. What is graph-specific: the stub's canned answer has to produce state the downstream node will accept, or your fixtures rot silently. A strict schema with reducers does half that validation for free, which is one more argument for the discipline in langgraph-state-design.

topology is a fixture, not a side effect

The second layer ignores what the nodes produce and pins down where the thread goes. Stream the graph in updates mode, collect node names in order, compare against the expected path.

steps = [next(iter(u)) for u in app.stream(inp, cfg, stream_mode="updates")]
assert steps == ["classify", "enrich", "draft", "human_gate"]

These tests read like the spec the client actually signed. A refund over the threshold must pass through human_gate. A ticket with no order ID must visit lookup before enrichment. There are nine of them, and per line of code they're the most valuable tests in the repo.

Unit tests prove the nodes work. Only a path assertion proves the graph ever visits them.

golden threads, replayed

The third piece is four anonymized production threads, exported from the Postgres checkpointer into sqlite files and committed as fixtures. An integration test replays each one against the current build and asserts two things: the visited path, and the set of keys in final state. Never the prose. Prose assertions are how the old suite died. When the state schema changes we regenerate the fixtures deliberately, in their own commit, so review shows exactly what moved.

The whole suite (41 node tests, nine path tests, four replays) runs in 74 seconds with zero network calls.

the edge that flipped

Here's what sold me for good. Two Fridays before writing this, a refactor of mine renamed confidence to score across the graph. The rename was clean except in one spot: the conditional edge after draft, an inline lambda deciding whether a reply pauses at human_gate or goes straight to send. Juggling the rename, I flipped the comparison. Low-confidence drafts (precisely the ones that needed human eyes) now sailed straight out the door.

Every unit test stayed green, because every node was still correct in isolation. The lambda had no test of its own; inline edge conditions never do. What went red was one path test: a fixture ticket with 0.41 confidence expected human_gate and got send. CI flagged it in under two minutes. Without that assertion we'd have shipped a quiet week of confident nonsense to unhappy customers.

Two things changed in the post-mortem. Edge conditions became named functions with their own unit tests, and every conditional edge now gets at least one path test per branch. I also let a model draft candidate tickets for the odd branches (roughly the workflow from ai-for-testing), though I still throw away two of every three suggestions.

The order matters: functions first, then paths, then replays.

#langgraph#testing#ci