Dev.to AI 🤖 Ai 👁 0 📖 4 min read

Day 22/90: Your AI Workflow Has Branches—Have You Tested Every Route?

An AI workflow can produce a reasonable answer while taking the wrong execution path. A request that needed clarification might reach a tool. An unsupported intent might fall through to a default handler. A graph might

An AI workflow can produce a reasonable answer while taking the wrong execution path.

A request that needed clarification might reach a tool. An unsupported intent might fall through to a default handler. A graph might select the correct branch but execute an additional node because of an incorrect edge.

On Day 15, I explored deterministic intent routing: which workflow should receive a request?

After Day 21’s discussion of agent reliability, the next question is:

Once a request enters a workflow, what determines its next step—and how do we verify that decision?

1. From intent routing to graph routing

Intent routing usually selects a workflow from the user’s request.

Conditional graph routing selects the next node from the workflow’s current state. That state might include:

  • Whether required information is missing.
  • Whether a tool returned usable evidence.
  • Whether an operation needs approval.
  • Whether an execution budget has been exhausted.

Anthropic describes routing as directing classified inputs to specialized tasks. It also distinguishes predefined workflows from agents that dynamically control their execution. A conditional graph can implement a workflow without requiring an autonomous agent.

For this example, I’ll use three destinations:

Condition Destination
Required information is missing Ask for clarification
Information is complete and intent is supported Run the handler
Information is complete but intent is unsupported Return a fallback

The order matters: missing information takes priority, even when the intent is supported.

That precedence is part of the behavior we need to test.

2. Keep the routing decision small

LangGraph provides add_conditional_edges() to choose destinations using a routing function. An explicit path map connects the function’s return values to node names.

Here is a minimal example.

# routing_graph.py
from typing import Literal, TypedDict

from langgraph.graph import END, START, StateGraph


Route = Literal["clarify", "handle", "fallback"]


class State(TypedDict):
    intent: str
    missing_information: bool
    visited: list[str]


def select_route(state: State) -> Route:
    if state["missing_information"]:
        return "clarify"

    if state["intent"] in {"search", "summarize"}:
        return "handle"

    return "fallback"


def record_visit(state: State, node: str) -> dict:
    return {"visited": [*state["visited"], node]}


def clarify(state: State) -> dict:
    return record_visit(state, "clarify")


def handle(state: State) -> dict:
    return record_visit(state, "handle")


def fallback(state: State) -> dict:
    return record_visit(state, "fallback")


builder = StateGraph(State)

builder.add_node("clarify", clarify)
builder.add_node("handle", handle)
builder.add_node("fallback", fallback)

builder.add_conditional_edges(
    START,
    select_route,
    {
        "clarify": "clarify",
        "handle": "handle",
        "fallback": "fallback",
    },
)

for node in ("clarify", "handle", "fallback"):
    builder.add_edge(node, END)

graph = builder.compile()

The nodes only record their execution so we can inspect the path. A real application would replace them with clarification, retrieval, or response logic.

The router itself has no model calls or external side effects. Its decisions can be checked directly.

3. Test the decision table

Testing only a supported request leaves important behavior unchecked.

Here is a parameterized test covering every destination, both supported intents, and clarification precedence:

# test_routing_graph.py
import pytest

from routing_graph import graph, select_route


CASES = [
    pytest.param(
        "search", True, "clarify",
        id="clarification-before-search",
    ),
    pytest.param(
        "summarize", True, "clarify",
        id="clarification-before-summary",
    ),
    pytest.param(
        "unknown", True, "clarify",
        id="clarification-before-fallback",
    ),
    pytest.param(
        "search", False, "handle",
        id="complete-search",
    ),
    pytest.param(
        "summarize", False, "handle",
        id="complete-summary",
    ),
    pytest.param(
        "unknown", False, "fallback",
        id="unsupported-intent",
    ),
]


@pytest.mark.parametrize("intent,missing,expected", CASES)
def test_routing_decision(intent, missing, expected):
    state = {
        "intent": intent,
        "missing_information": missing,
        "visited": [],
    }

    assert select_route(state) == expected


@pytest.mark.parametrize("intent,missing,expected", CASES)
def test_graph_execution_path(intent, missing, expected):
    state = {
        "intent": intent,
        "missing_information": missing,
        "visited": [],
    }

    result = graph.invoke(state)

    assert result["visited"] == [expected]

To run the example:

python -m pip install langgraph pytest
python -m pytest -q

These tests check two different things:

  1. Routing logic: Does the function choose the expected destination?
  2. Graph wiring: Does the compiled graph execute exactly that destination?

The second test matters because a correct routing function does not prove the surrounding graph is connected correctly.

4. Test what must not happen

Notice this assertion:

assert result["visited"] == [expected]

It checks the complete execution trace.

An assertion such as:

assert expected in result["visited"]

would also pass if an unintended node ran.

For a production workflow, the equivalent check might verify that a tool was never called when clarification was required, or that a write operation never ran without approval.

Correct execution includes the absence of forbidden actions.

5. Branch coverage has limits

These tests verify the routing policy for the supplied states.

They do not prove that an upstream classifier correctly understood the user, that missing information was detected accurately, or that the handler produced a useful answer.

Those need separate evaluations:

  • Labeled requests for intent classification.
  • Incomplete and ambiguous requests for clarification detection.
  • Realistic tool responses for downstream behavior.
  • Invalid inputs for boundary validation.

In particular, TypedDict describes the expected state for type checking; it does not validate incoming data at runtime.

A graph can follow its rules perfectly and still receive an incorrect state.

My takeaway

Conditional routing makes execution rules explicit. Testing makes those rules reviewable.

For each routing decision, I want to specify:

  • The condition that selects each destination.
  • Which condition wins when rules overlap.
  • The nodes that must execute.
  • The actions that must remain unexecuted.

A useful next experiment is to deliberately swap two destinations in the path map and check whether the graph tests catch the mistake.

When you test an AI workflow, do you verify only its answer—or also the path it took?

References

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.