Code Advanced hard · 8 min

Testing conditional routing

What you will learn
Verify that your graph's conditional edges route to the correct nodes under different state conditions using deterministic test patterns.

Why this matters

Conditional routing is where bugs hide in production: a route that works for 90% of inputs but fails on edge cases. Without proper testing, you deploy a graph that silently routes to the wrong node, corrupting downstream processing. Testing conditional edges before production prevents silent failures.

Skip if: Do not write conditional routing tests if your edges are purely deterministic and non-conditional (e.g., always route to the same next node). Also skip these tests if your routing depends on external API calls that are already tested elsewhere: test the logic, not the API.

Explanation

Conditional routing in langgraph uses edge functions that inspect the current state and return the name of the next node to visit. Testing this means isolating the routing logic, feeding it controlled state inputs, and verifying it returns the expected node name.

Mechanically: you invoke the graph with carefully crafted state objects, intercept the path the graph takes (using get_state() or streaming the nodes visited), and assert that the execution matched your expected route. The key is testing boundary conditions: the exact state values that flip the routing decision.

At the advanced level, this includes testing routing under partial state (missing optional fields), testing routing with state that causes multiple conditional branches to evaluate, and ensuring your routing logic doesn't accidentally revisit nodes or create infinite loops.

Analogy

Conditional routing is like a railway switch: your train (the execution) can go left or right depending on the track conditions. Testing is manually sending test trains through with known configurations, watching which branch they take, and verifying it matches the switch logic diagram.

Code

python
import json
from typing import Literal
from dataclasses import dataclass
from langgraph.graph import StateGraph, START, END
import anthropic

@dataclass
class TicketState:
    ticket_id: str
    priority: str
    category: str
    resolved: bool = False
    current_node: str = ""

def route_ticket(state: TicketState) -> Literal["escalate", "assign", "close"]:
    """Route based on priority and category."""
    if state.priority == "critical" and state.category == "billing":
        return "escalate"
    elif state.priority in ["high", "critical"]:
        return "assign"
    else:
        return "close"

def process_escalate(state: TicketState) -> TicketState:
    state.current_node = "escalate"
    return state

def process_assign(state: TicketState) -> TicketState:
    state.current_node = "assign"
    return state

def process_close(state: TicketState) -> TicketState:
    state.current_node = "close"
    state.resolved = True
    return state

builder = StateGraph(TicketState)
builder.add_node("escalate", process_escalate)
builder.add_node("assign", process_assign)
builder.add_node("close", process_close)

builder.add_edge(START, "escalate_decision")

builder.add_node("escalate_decision", lambda state: state)
builder.add_conditional_edges(
    "escalate_decision",
    route_ticket,
    {
        "escalate": "escalate",
        "assign": "assign",
        "close": "close"
    }
)

builder.add_edge("escalate", END)
builder.add_edge("assign", END)
builder.add_edge("close", END)

graph = builder.compile()

test_cases = [
    {
        "name": "Critical billing ticket routes to escalate",
        "input": TicketState(ticket_id="T001", priority="critical", category="billing"),
        "expected_node": "escalate"
    },
    {
        "name": "High priority non-billing routes to assign",
        "input": TicketState(ticket_id="T002", priority="high", category="technical"),
        "expected_node": "assign"
    },
    {
        "name": "Low priority routes to close",
        "input": TicketState(ticket_id="T003", priority="low", category="general"),
        "expected_node": "close"
    },
    {
        "name": "Critical non-billing routes to assign",
        "input": TicketState(ticket_id="T004", priority="critical", category="technical"),
        "expected_node": "assign"
    }
]

results = []
for test in test_cases:
    config = {"configurable": {"thread_id": test["input"].ticket_id}}
    final_state = graph.invoke(test["input"], config)
    actual_node = final_state.current_node
    passed = actual_node == test["expected_node"]
    results.append({
        "test": test["name"],
        "expected": test["expected_node"],
        "actual": actual_node,
        "passed": passed,
        "resolved": final_state.resolved
    })

for result in results:
    status = "✓ PASS" if result["passed"] else "✗ FAIL"
    print(f"{status} | {result['test']}")
    print(f"  Expected: {result['expected']}, Got: {result['actual']}")
    print(f"  Resolved: {result['resolved']}")
    print()

passed_count = sum(1 for r in results if r["passed"])
print(f"\nResults: {passed_count}/{len(results)} tests passed")
Output
✓ PASS | Critical billing ticket routes to escalate
  Expected: escalate, Got: escalate
  Resolved: False

✓ PASS | High priority non-billing routes to assign
  Expected: assign, Got: assign
  Resolved: False

✓ PASS | Low priority routes to close
  Expected: close, Got: close
  Resolved: True

✓ PASS | Critical non-billing routes to assign
  Expected: assign, Got: assign
  Resolved: False

Results: 4/4 tests passed

What just happened?

The code defined a routing function that examines ticket priority and category, then built a langgraph with a conditional edge that calls that routing function. Each test case invoked the graph with a specific TicketState, captured the node name that was visited (stored in state.current_node), and compared it to the expected target node. All four routing decisions executed correctly and the test harness validated each one against the expected outcome.

Common gotcha

The most common mistake is testing only the route_ticket function in isolation instead of testing the actual graph execution. A function might return the correct string, but if your conditional edge mapping is wrong ({"escalate": "assign_node_wrong_name"}), the graph will fail at runtime. Always test the full graph invocation, not just the routing function.

Error recovery

KeyError when calling graph.invoke()
You passed a TicketState object but the StateGraph expects a dict or the state object's fields aren't being recognized. Fix: ensure your dataclass is properly defined with type hints, and langgraph can serialize/deserialize it. Or convert to dict before passing: `graph.invoke(TicketState(...).__dict__)`
ConditionalEdgeValueError
Your routing function returned a string that doesn't match any key in the conditional_edges mapping dict. Fix: verify that `route_ticket()` return values (e.g. 'escalate') exactly match the keys you passed to `add_conditional_edges()` (e.g., 'escalate', not 'escalate_node').
AssertionError on expected_node mismatch
Your routing logic returned a different node than expected. Fix: trace through your routing function's conditions with the test input: did priority/category values match what you thought? Use print(route_ticket(test_input)) to debug the routing function in isolation first.
state.current_node is empty string
You didn't update the state inside each node function, so current_node was never set. Fix: ensure each node handler (process_escalate, process_assign, process_close) actually modifies and returns the state with the node name assigned.

Experienced dev note

Senior developers know that conditional routing bugs often lurk at the intersection of two conditions. Your tests pass for 'critical + billing' and 'critical + non-billing' separately, but the actual edge case is the OR/AND logic: which condition wins when both fire? Write tests that enumerate all combinations of your conditional variables (priority × category), not just the happy paths. Also: use `graph.get_state(config)` after invoke to inspect the full state, not just the output: state mutations in nodes often reveal hidden routing bugs that wouldn't show up in the final output.

Check your understanding

If your routing function has three conditions (priority, category, and a new 'is_vip' flag), and you want to test all meaningful paths, how many test cases is the minimum you should write, and what determines that number?

Show answer hint

The answer involves understanding that conditional routing creates a decision tree: you need to test enough cases to cover the distinct outcomes of your routing logic, not just the number of input variables. The minimum is determined by the number of different node names your routing function can return, plus one test per boundary condition where the decision flips (e.g., priority='high' vs 'critical', or category='billing' vs 'technical'). A correct answer would identify that it's not 2^N combinations, but rather the number of unique routing outcomes.

VERSION langgraph 0.2.x uses StateGraph and add_conditional_edges(). In earlier versions (< 0.2.0), the API was MessageGraph with different edge syntax. If upgrading from 0.1.x, rewrite your conditional edges to use the new add_conditional_edges() method and import START, END from langgraph.graph, not as string literals.
NEXT

Once your conditional routes are tested and reliable, learn how to test node cycles and infinite loop prevention: ensuring your graph doesn't accidentally route back to a node it already visited.

Community Notes

No notes yetBe the first to share a version-specific fix or tip.