PRACTICAL GUIDE / LangGraph conditional edge testing
Test LangGraph Conditional Router Branches
Learn LangGraph conditional edge testing with table-driven router cases, complete branch mappings, edge states, and focused graph wiring checks.
In this guide11 sections
- What does LangGraph conditional edge testing prove?
- How do routing functions and path maps control execution?
- Why should the router be tested as a pure function first?
- How do you build a table for every router branch?
- How do you verify path-map completeness?
- How do you test empty, malformed, and boundary states?
- How do you test toolscondition without a live model?
- When is a compiled graph wiring test still necessary?
- How do you run the conditional-router test workflow?
- FAQ
- Can a router be tested without compiling the graph?
- What happens when a router returns an unmapped key?
- Should every route use a Literal return type?
- How do you test toolscondition?
- Can a router return multiple destinations?
- When should Command replace a conditional edge?
- Conclusion: Make every route forceable without a model
What you will learn
- What does LangGraph conditional edge testing prove?
- How do routing functions and path maps control execution?
- Why should the router be tested as a pure function first?
- How do you build a table for every router branch?
LangGraph conditional edge testing begins with the router as a pure state-to-route function. Use a table of crafted states to assert every return key, verify that each key maps to a valid destination, then add a small compiled-graph test for wiring and terminal behavior. A live model is unnecessary when the branch input is already known.
The official LangGraph Graph API guide defines nodes, state, and edges, including conditional edges that call a routing function after a node executes. A path map can translate router return values to destination node names. These explicit parts create three distinct test surfaces: decision logic, mapping completeness, and graph wiring.
This article does not cover checkpoint replay or human approval lifecycle. LangGraph checkpoint testing owns saved state and time travel, while interrupt, resume, and approval testing owns paused execution. Here the goal is exhaustive control over "where next?"
What does LangGraph conditional edge testing prove?
A conditional edge is application control flow. The router reads state and selects one or more destinations, or termination. A wrong decision can skip validation, send a routine request to escalation, call a tool after a final answer, or enter a loop. Final output may still look plausible, so output-only tests are weak evidence for routing.
Router tests prove that known state maps to the intended route. Mapping tests prove every declared route has a destination and every destination is allowed. Graph tests prove the conditional edge is attached to the correct source node and the selected node actually executes. These statements overlap but are not interchangeable.
The repository challenge ai-agents-langgraph-conditional-edges-quiz in AI_AGENTS_CHALLENGES, at src/db/seed-data/challenges/expansion-ai-agents.ts, identifies the core cases: add_conditional_edges, tools_condition, unmapped return keys, pure function tables, escalation, final answers, tool calls, and empty messages. It also explains why relying on model-backed end-to-end runs leaves branches unforced.
Routing coverage should be defined from the router's domain, not from whichever branches a model happened to produce. If the possible keys are tools, answer, escalate, and invalid, the test suite needs a state for each. Add boundaries where predicates overlap, such as high risk plus missing identity, and state which rule has priority.
Treat route names as part of the graph contract. Renaming escalate to human_review can require a path-map update, visualization review, checkpoint compatibility decision, and trace query update. The pure test will expose a changed return, while completeness checks show whether construction changed with it. An intentional rename should update these artifacts together rather than weakening expected values.
Route observability should identify the decision without dumping full state. Record the source node, selected semantic route, destination, graph revision, and a sanitized reason code when the application provides one. This evidence lets a failing trajectory distinguish a wrong router decision from a correct decision followed by a broken node.
Agent trajectory evaluation can later assess complete paths. Conditional-edge unit tests provide deterministic proof for individual decisions that trajectory grading can reference.
How do routing functions and path maps control execution?
After the source node completes, LangGraph passes current state to the routing function. The function returns a destination name directly or a value translated through a path map. It can also return multiple destinations for fan-out. The graph schedules the selected node or nodes in the next step.
The route function and path map form one contract. If the router returns semantic keys such as "needs_review", the path map must translate each key to a graph node. If the router returns actual node names, a map may not be needed, but the allowed names remain part of the contract. A typo is control-flow failure, not a cosmetic difference.
Use return type annotations such as Literal["tools", "answer", "escalate"] when the set is fixed. This helps static review and graph rendering, but runtime state can still violate assumptions. A cast, stale branch, or unhandled default can return another string. Tests should compare the declared set with the mapping and invoke every branch.
The LangGraph graph-use guide provides conditional branching examples. Follow the current API for StateGraph, START, END, and add_conditional_edges. Do not mix fixed and conditional outgoing edges from the same source unless parallel execution is intentionally part of the design, because both paths can run.
For each source node, document one routing mechanism and its destination set. A review should be able to answer whether termination is represented by END, a semantic map key, or a node that performs finalization.
The source node and router also share a state contract. A direct router test can supply risk="high" manually, but it does not prove that the preceding classifier writes risk under the same key or uses the expected value set. Add one focused integration case that invokes the source node and then the router with controlled dependencies. Keep the full route matrix at the pure layer, where every state remains easy to force.
Conditional entry points deserve the same separation. Table-test the entry router with initial request state, verify every entry outcome has a registered destination, and compile a small graph that records which first node ran. Do not mix entry fixtures with post-node fixtures because they often have different required fields and validation guarantees.
If a node returns Command to update state and choose a destination together, extract its decision rule when practical and test that rule directly. Then assert the Command carries both the intended update and goto value. A final graph case proves the destination exists and receives the updated state. Changing the API shape does not remove the need to test decision, declaration, and wiring as separate contracts.
Why should the router be tested as a pure function first?
Pure function testing gives exact state control. It can create tool calls, escalation flags, retry counts, missing fields, and boundary values without asking an LLM or executing unrelated nodes. Each failure identifies the decision rule rather than graph construction, model behavior, or downstream effects.
Keep routers free of network calls and hidden mutable state. A router should read the supplied state and return a destination. If it needs external policy data, load that data before routing or inject a policy object through explicit context. Hidden I/O makes branch tests slow and introduces failures unrelated to the route.
The first code example defines a typed state and table-tests a support router. Priority is explicit: invalid state is rejected before risk escalation, escalation precedes tool use, and a final answer ends the graph.
from typing import Literal, TypedDict
import pytest
Route = Literal["invalid", "escalate", "tools", "answer"]
class SupportState(TypedDict, total=False):
user_id: str
risk: str
tool_calls: list[dict[str, object]]
final_answer: str
def route_support_case(state: SupportState) -> Route:
if not state.get("user_id"):
return "invalid"
if state.get("risk") == "high":
return "escalate"
if state.get("tool_calls"):
return "tools"
return "answer"
@pytest.mark.parametrize(
("state", "expected"),
[
({}, "invalid"),
({"risk": "high"}, "invalid"),
({"user_id": "U-17", "risk": "high"}, "escalate"),
({"user_id": "U-17", "tool_calls": [{"name": "lookup"}]}, "tools"),
({"user_id": "U-17", "final_answer": "Complete"}, "answer"),
],
)
def test_route_support_case(state: SupportState, expected: Route):
assert route_support_case(state) == expectedThe second case proves priority rather than only branch reachability: high risk without identity routes to invalid. Add such overlap cases whenever multiple predicates can be true. Without them, a refactor can reorder conditions and change policy while every single-feature case still passes.
Evaluating handoff routing and context transfer addresses whether an escalation receives the right context. The pure router test first proves that escalation is selected.
How do you build a table for every router branch?
Derive rows from the route contract. Start with one minimal state per destination. Add one state immediately on each numeric or categorical boundary. Add overlap states that establish precedence. Add degenerate states such as missing keys, empty lists, None where allowed, and unexpected enum values according to the application's validation policy.
Do not create dozens of random dictionaries without an oracle. Every row should name why it exists. A test ID such as missing-user-wins-over-high-risk communicates more than an index. Pytest's ids argument can make those names appear in failure output.
Use a route coverage table during review:
| State pattern | Expected route | Contract protected | Downstream node |
|---|---|---|---|
| Missing user identity | invalid | Preconditions before action | reject_input |
| High-risk request | escalate | Human review policy | human_review |
| Valid tool calls | tools | Execute requested tools | tool_node |
| Valid state without calls | answer | Finish response | finalize |
| Unknown risk label | invalid | Closed enum handling | reject_input |
Branch coverage tools can show whether Python lines executed, but they cannot tell whether test data reflects product rules. Keep the semantic table beside code review. When a new route is added, require a destination, minimal state, boundary, and overlap case.
Avoid asserting only that the return is in an allowed set. That catches wild strings but not wrong valid destinations. Each crafted state needs one expected route. For multi-destination routing, assert the intended set and any ordering guarantee explicitly.
How do you verify path-map completeness?
Represent expected router outcomes as a set owned by the test or exported from a shared policy definition. Compare it with the path-map keys. Then compare path-map values with registered graph node names plus permitted terminal targets. This catches a new route without a map, stale map entries, and destinations removed during refactoring.
from typing import get_args
PATH_MAP = {
"invalid": "reject_input",
"escalate": "human_review",
"tools": "tool_node",
"answer": "finalize",
}
def test_path_map_covers_every_declared_route():
declared_routes = set(get_args(Route))
assert set(PATH_MAP) == declared_routes
def test_path_map_targets_registered_nodes():
registered = {"reject_input", "human_review", "tool_node", "finalize"}
assert set(PATH_MAP.values()) <= registeredIf the router returns END directly, include it in the allowed destination policy but not the registered-node set. If the graph uses semantic keys, keep keys distinct from node names so tests show which side changed.
Static completeness does not prove each branch is reachable. The parameter table does that. Reachability does not prove mapping completeness if the table forgot a new declared key. Keep both checks because they catch different maintenance errors.
Add a reverse check when several semantic outcomes intentionally share one destination. For example, policy_denied and invalid_input may both route to reject_input, but operators may still need the semantic key. Assert the many-to-one mapping explicitly so a future simplification does not erase diagnostic meaning. Duplicate destination values are not automatically defects; unexplained duplicates are review signals.
If nodes are assembled by a factory, run completeness against the factory's registered node set instead of a copied list where practical. A copied set can drift in the same change as the map. Keep one human-readable expected route set in the test, since deriving both sides entirely from production objects can make a self-consistent defect pass.
This is related to tool selection correctness, but the router's decision is application code, not a model score. Exact assertions are appropriate when state has a deterministic expected route.
How do you test empty, malformed, and boundary states?
Decide where validation belongs. A graph may validate input before routing and guarantee a complete state, or the router may own defensive handling. Tests should reflect that boundary. Do not silently add fallback routing for malformed state if the contract requires a clear validation failure.
Empty message lists deserve explicit treatment in chat graphs. A router that reads state["messages"][-1] will raise an index error. The application can reject empty input, route to initialization, or return END; choose one and test it. The correct answer is not universal.
For numeric limits such as retry count, test below, exact, and above the boundary. For status fields, test every enum member and an unknown value. For optional lists, distinguish missing, empty, and populated when the type allows each. For messages, distinguish a final assistant message, an AI message with tool calls, a tool result, and a human message.
Malformed-state tests should not invent impossible values if a validated schema always blocks them before the router. Instead, test the validation layer and one graph case proving validation runs before routing. If untrusted persisted state or schema upgrades can reach the router, malformed values are realistic and need a policy.
Persisted state introduces compatibility questions even when checkpoint behavior is outside this article's main scope. A newly required routing field may be absent from an older thread. Decide whether the router applies a default, sends the thread to migration, or rejects continuation. Add one fixture representing the supported old shape. LangGraph checkpoint recovery scenarios examine recovery more broadly, while this case protects the route chosen from a known legacy state.
Side-effect flags also deserve boundary cases. A state that records a completed payment or sent message should not route back to the corresponding action merely because another field requests retry. The router test can prove the skip or reconciliation route directly. Duplicate agent side effects after LangGraph resume covers the resumed execution risk that makes this branch important.
Debugging LangGraph agents stuck after interrupts handles a different class of state problem. Do not use checkpoint or interrupt recovery cases as substitutes for ordinary router boundaries.
How do you test tools_condition without a live model?
tools_condition examines message state and routes based on whether the last AI message includes tool calls. The official LangChain tools guide shows it connected to a ToolNode. To test the condition, create AI messages directly. A model is unnecessary because the fixture already contains the message shape that a model would have produced.
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.graph import END
from langgraph.prebuilt import tools_condition
def test_tools_condition_routes_tool_call():
state = {
"messages": [
HumanMessage(content="Find order O-17"),
AIMessage(
content="",
tool_calls=[
{
"name": "lookup_order",
"args": {"order_id": "O-17"},
"id": "call-17",
"type": "tool_call",
}
],
),
]
}
assert tools_condition(state) == "tools"
def test_tools_condition_routes_final_answer_to_end():
state = {
"messages": [
HumanMessage(content="Say hello"),
AIMessage(content="Hello"),
]
}
assert tools_condition(state) == ENDThe final assertion uses LangGraph's exported terminal marker instead of copying its string value. This freezes the routing contract while allowing the framework constant to remain the source of truth.
Also test the application's state container form, since prebuilt conditions may accept a message list or a dictionary with a messages key according to current APIs. Empty state should follow a documented validation or routing rule. Tool-call IDs and arguments belong to separate tool execution tests; this condition asks only whether tool calls are present.
When is a compiled graph wiring test still necessary?
A pure router can pass while the graph attaches it after the wrong node, supplies a stale path map, omits a destination node, or connects the chosen node to an unintended successor. Compile a small graph with controlled nodes and assert which node marks state. This is wiring proof, not a reason to rerun the entire product graph for every router row.
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class GraphState(TypedDict):
route: str
visited: list[str]
def choose(state: GraphState) -> str:
return state["route"]
def mark(name: str):
def node(state: GraphState) -> dict[str, list[str]]:
return {"visited": [*state["visited"], name]}
return node
builder = StateGraph(GraphState)
builder.add_node("decide", lambda state: {})
builder.add_node("left", mark("left"))
builder.add_node("right", mark("right"))
builder.add_edge(START, "decide")
builder.add_conditional_edges(
"decide",
choose,
{"go_left": "left", "go_right": "right"},
)
builder.add_edge("left", END)
builder.add_edge("right", END)
graph = builder.compile()
def test_graph_wires_go_right_to_right_node():
result = graph.invoke({"route": "go_right", "visited": []})
assert result["visited"] == ["right"]Use reducers correctly if multiple branches can update the same state key. The simple list replacement above is suitable only for one selected branch. Fan-out tests need annotated reducers and assertions for merged state.
For fan-out, give each controlled node a unique marker and return order-independent state through an appropriate reducer. Assert that the selected destination set ran once, excluded nodes did not run, and merged values preserve all expected markers. Then inject a failure in one branch and test the application's documented partial-result or failure behavior. A router returning two names proves selection, not successful parallel state handling.
Subgraphs need a named boundary. A parent router may select a subgraph as one destination, while routing inside that subgraph has its own table and map. Test each router at its owning level and add one parent-to-subgraph wiring case. Avoid a single end-to-end case that can fail in either router without showing which decision changed.
Testing AI agents supplies the broader system view for model, tool, retrieval, and memory behavior. Use model-backed runs after deterministic routing tests to verify that upstream nodes construct state capable of reaching the right branch; do not ask those runs to provide exhaustive branch coverage.
The official LangGraph testing guide also describes testing nodes and partial execution. Apply those patterns where the route leads into a larger section, but keep the routing assertion visible.
How do you run the conditional-router test workflow?
Use this sequence when creating or changing conditional control flow:
- List every semantic route, terminal outcome, and permitted multi-destination result.
- Define typed state and a typed router return where the route set is fixed.
- Write one minimal state fixture for each route.
- Add boundaries, malformed cases that can reach the router, and overlapping predicates that establish priority.
- Parameterize direct router calls with an exact expected destination.
- Compare declared route outcomes with path-map keys.
- Compare path-map destinations with registered nodes and permitted terminal markers.
- Test prebuilt conditions directly with controlled message objects.
- Compile a small graph with observable nodes and prove destination wiring.
- Add trajectory or model-backed scenarios only for behavior that deterministic state cannot establish.
When a test fails, classify it as decision, mapping, or wiring. A decision failure points to predicate logic or product policy. A mapping failure points to graph construction. A wiring failure points to source attachment, destination registration, or downstream edges. This taxonomy is more actionable than "agent returned the wrong answer."
Include route changes in pull-request review evidence. Show the old and new outcome sets, path map, affected state fields, new or removed fixtures, and one graph wiring result. If a branch is removed, explain what now handles its states. If precedence changes, include an overlap case that demonstrates the intended policy. This makes control-flow changes reviewable without requiring a reviewer to infer them from a rendered graph alone.
After deployment, compare observed semantic route names with the declared set and investigate unknown values or branches that never appear in representative traffic. Production observations are not a substitute for tests, and absence does not prove a route is dead. They can reveal state combinations missing from the fixture table. Convert confirmed gaps into small deterministic cases before changing router logic.
Keep route fixtures free of customer data. Preserve only fields that influence the decision, replace identifiers with synthetic values, and document why each field is present. Minimal state makes failures readable and reduces the chance that unrelated context accidentally becomes a hidden predicate during refactoring.
LangGraph testing interview scenarios surveys broader discussion topics. In code review, keep the concrete route table and completeness assertions close to the graph implementation.
FAQ
Can a router be tested without compiling the graph?
Yes. Call the routing function with crafted state and assert its exact result. This is the fastest way to force every branch. Compile a separate controlled graph to prove that the function and path map are attached correctly.
What happens when a router returns an unmapped key?
The graph cannot follow the intended mapped branch. Catch this before execution by comparing declared outcomes with path-map keys and by invoking each route. Keep one graph-level failure case if the runtime error contract matters to operators.
Should every route use a Literal return type?
Use Literal when destinations form a fixed set. It improves type review and graph visibility, but runtime tests are still required. Dynamic fan-out can use a sequence or other supported type with an explicit allowed-destination policy.
How do you test tools_condition?
Create message fixtures with and without tool calls, invoke the function directly, and assert the exact current return value. Add empty-state behavior according to the application contract. There is no need to ask a live model to generate the fixture.
Can a router return multiple destinations?
Yes. Test the full destination collection and then compile controlled parallel nodes to verify reducers, merged state, and failure behavior. Single-route tests do not prove fan-out semantics.
When should Command replace a conditional edge?
Choose Command when a node must update state and route together. Keep a conditional edge for a separate routing decision after a node. Test either design locally, then prove graph wiring with controlled nodes.
Conclusion: Make every route forceable without a model
Conditional edges are ordinary control flow expressed through state and graph topology. Table-driven router tests, path-map completeness checks, prebuilt-condition fixtures, and one controlled graph case can prove each layer without provider variability.
When a model-backed scenario later follows the wrong path, the deterministic suite has already ruled out many local causes. That leaves a clearer investigation into model output, state construction, or orchestration instead of treating routing as opaque agent behavior.
// LIVE COURSE / THE TESTING ACADEMY
AI Tester Blueprint
Master GenAI, AI Agents, MCP, RAG, CrewAI. Build 23+ real AI projects.
From the instructor behind this guide.
AI testing roles are up 180% and pay 12-22 LPA. 12+ weeks / 65+ live hrs / Sat-Sun 8:30 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official docs.langchain.com reference
docs.langchain.com
Primary documentation selected and verified for the claims in this guide.
- 02Official docs.langchain.com reference
docs.langchain.com
Primary documentation selected and verified for the claims in this guide.
- 03Official docs.langchain.com reference
docs.langchain.com
Primary documentation selected and verified for the claims in this guide.
- 04Official docs.langchain.com reference
docs.langchain.com
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Can a LangGraph router be tested without compiling the graph?
Yes. A routing function that reads state and returns one or more destinations can be called like an ordinary Python function. Table-test it first with crafted states. A separate compiled-graph case should then prove that the router is attached to the intended source and reaches the mapped destination.
What happens when a LangGraph router returns an unmapped key?
A path map cannot route a key it does not contain, so execution cannot follow the intended branch. Protect the contract before runtime by comparing the router's declared outcomes with path-map keys and by testing representative state for each outcome. Keep an integration case for the compiled graph's failure behavior.
Should every LangGraph route use a Literal return type?
A Literal annotation is useful when a router returns a fixed set of named destinations because type checking and graph visualization can see that set. It is not a substitute for runtime tests. Dynamic fan-out may use other return types, and its allowed destinations still need an explicit test oracle.
How do you test LangGraph tools_condition?
Build message-state fixtures whose last AI message either contains tool calls or does not, then invoke tools_condition directly and assert the returned route. Include empty or malformed state according to the application contract. Use fake messages, not a live model, because the condition only needs the resulting message shape.
Can a LangGraph router return multiple destinations?
Yes. Conditional routing can return a sequence for fan-out. Test the destination set or ordered sequence according to the documented application contract, then compile controlled nodes to verify merged state and reducer behavior. A single-destination table does not prove parallel routing, state combination, or failure handling.
When should Command replace a conditional edge?
Use Command when one node needs to update state and choose the next destination together. Use a conditional edge when routing follows a node and does not need that combined result. Whichever form you choose, test the decision as local logic and add a graph case for destination wiring.
RELATED GUIDES
Continue the learning route
GUIDE 01
LangGraph Testing Interview Questions and Scenarios
Master LangGraph testing interview questions with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Testing LangGraph Checkpoint Resume and Time-Travel Branches
Test LangGraph checkpoints, interrupted-run resume, replay re-execution, time-travel forks, reducer behavior, side-effect safety, and state lineage.
GUIDE 03
Test LangGraph Interrupt, Resume, and Human Approval Paths
Master LangGraph interrupt resume testing with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
Debug LangGraph Agents Stuck After Interrupts
Master debug LangGraph interrupt stuck with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
Evaluate AI Agent Tool Selection Correctness
Master AI agent tool correctness metric with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 06
Evaluating Agent Handoff Routing and Context Transfer
Evaluate agent handoff routing, context preservation, privacy filtering, escalation behavior, specialist ownership, and end-to-end resolution quality.