teachyou.ai academy
← All posts
LangGraph

LangGraph Testing: Unit Testing Individual Nodes

Pramod Dutta · Jun 15, 2026 · 12 min read

Why Testing a LangGraph Node in Isolation Actually Matters

Most teams building agents with LangGraph skip straight to integration testing. They wire up the full graph, run a prompt through it, eyeball the output, and call it "tested." That works fine for a demo. It falls apart the moment you have five nodes, three conditional edges, and a state schema that keeps growing. When something breaks, you cannot tell whether the retriever node returned bad data, the router picked the wrong branch, or the final synthesis node hallucinated. You are debugging the whole system every single time.

Unit testing individual nodes fixes this. A LangGraph node is, at its core, a Python function that takes a state dictionary (or a Pydantic/TypedDict object) and returns a partial state update. That means every node is already shaped like something you can test the same way you'd test any other function: give it an input, check the output, mock anything external. There is no magic here — the graph orchestration layer disappears once you're inside a single node, and you're left with plain, testable Python.

The payoff is compounding. When you test nodes individually, you catch state-shape bugs before they propagate through five downstream nodes. You catch prompt regressions the moment you change a system message. You catch broken tool calls without needing to spin up an LLM call that costs money and takes ten seconds. And when your test suite is fast and deterministic, you can actually run it on every commit instead of "when someone remembers."

This article walks through practical patterns for unit testing LangGraph nodes: how to structure node functions so they are testable, how to build state fixtures, how to mock LLM calls and tool calls, how to test conditional edges and routers, and how to organize all of this in pytest so it scales as your graph grows.

What Makes a LangGraph Node Testable (and What Doesn't)

Before writing tests, it helps to look at what a node actually is. In LangGraph, a node is typically defined like this:

from typing import TypedDict

class GraphState(TypedDict):
    question: str
    documents: list[str]
    answer: str
    retry_count: int

def retrieve_node(state: GraphState) -> dict:
    question = state["question"]
    docs = retriever.get_relevant_documents(question)
    return {"documents": [d.page_content for d in docs]}

The function signature is the whole contract: input state in, partial state update out. That's the seam you test against. The problem is that retriever here is an external dependency — a vector store client, an API call, something with I/O and latency. If you test this node as written, you are really testing your vector database, not your node's logic.

The fix is a design habit more than a testing trick: keep nodes thin, and push dependencies (LLM clients, retrievers, tool clients, DB connections) to the edges of the function so they can be injected or patched. Two common patterns work well:

  • Module-level dependency, patched via mocking — keep retriever as a module-level object and patch it in tests with unittest.mock.patch.
  • Dependency injection via closures or classes — wrap the node in a factory function that takes the retriever as an argument, so tests can pass in a fake one directly without patching internals.

The second pattern scales better once you have a dozen nodes, because you're not fighting import paths and patch targets. Here's what it looks like:

def make_retrieve_node(retriever):
    def retrieve_node(state: GraphState) -> dict:
        docs = retriever.get_relevant_documents(state["question"])
        return {"documents": [d.page_content for d in docs]}
    return retrieve_node

Now your graph-building code calls make_retrieve_node(real_retriever), and your tests call make_retrieve_node(fake_retriever). No monkeypatching required. This one change makes the rest of this article dramatically easier.

Setting Up a Test Structure for LangGraph Projects

A LangGraph project's test layout should mirror how the graph itself is organized: one test module per node, plus separate modules for routing logic and full-graph integration tests. A structure that works well in practice:

project/
  graph/
    nodes/
      retrieve.py
      generate.py
      router.py
    state.py
    build_graph.py
  tests/
    unit/
      test_retrieve_node.py
      test_generate_node.py
      test_router.py
    integration/
      test_full_graph.py
    conftest.py

Keeping unit and integration tests physically separate matters because they run at different speeds and different frequencies. Unit tests for nodes should run in under a second total and execute on every save. Integration tests that exercise a real (or realistically mocked) LLM can be slower and run less often, maybe just in CI or before a PR.

Your conftest.py is where shared fixtures live — most importantly, a base state fixture that every node test can build on:

import pytest

@pytest.fixture
def base_state():
    return {
        "question": "What is LangGraph?",
        "documents": [],
        "answer": "",
        "retry_count": 0,
    }

Individual test files then override just the keys they care about, which keeps tests readable and keeps the state schema in one obvious place if it changes later.

Testing a Simple Node with pytest

Let's test the retrieve_node factory from earlier. The goal is to verify: given a fake retriever that returns known documents, does the node return the correct partial state update, with the correct keys and no side effects on the rest of the state?

from graph.nodes.retrieve import make_retrieve_node

class FakeDoc:
    def __init__(self, content):
        self.page_content = content

class FakeRetriever:
    def __init__(self, docs):
        self._docs = docs

    def get_relevant_documents(self, query):
        self.last_query = query
        return self._docs

def test_retrieve_node_returns_document_contents(base_state):
    fake_retriever = FakeRetriever([FakeDoc("doc one"), FakeDoc("doc two")])
    node = make_retrieve_node(fake_retriever)

    result = node(base_state)

    assert result == {"documents": ["doc one", "doc two"]}
    assert fake_retriever.last_query == "What is LangGraph?"

def test_retrieve_node_handles_empty_results(base_state):
    fake_retriever = FakeRetriever([])
    node = make_retrieve_node(fake_retriever)

    result = node(base_state)

    assert result == {"documents": []}

Notice what's being asserted: the exact shape of the dictionary returned, not just "did it not crash." LangGraph merges this dictionary into the overall state according to your reducers, so an extra or misspelled key here is a bug that will silently corrupt state three nodes downstream. Asserting the exact dict catches that immediately.

Also notice there is no langgraph import anywhere in this test. That's intentional — if your node function is well designed, testing it requires zero knowledge of the graph it lives in. You are testing a pure function.

Mocking LLM Calls Inside Nodes

Most interesting nodes call an LLM. You do not want your unit tests making real API calls — they're slow, they cost money, and their non-determinism makes assertions unreliable. The pattern is the same dependency-injection approach, applied to the LLM client:

def make_generate_node(llm):
    def generate_node(state: GraphState) -> dict:
        context = "\n".join(state["documents"])
        prompt = f"Answer using this context:\n{context}\n\nQuestion: {state['question']}"
        response = llm.invoke(prompt)
        return {"answer": response.content}
    return generate_node

To test this without hitting a real model, build a minimal fake that mimics the .invoke() interface LangChain chat models expose:

from unittest.mock import MagicMock
from graph.nodes.generate import make_generate_node

def test_generate_node_builds_answer_from_documents(base_state):
    fake_response = MagicMock()
    fake_response.content = "LangGraph is a library for building stateful agents."
    fake_llm = MagicMock()
    fake_llm.invoke.return_value = fake_response

    state = {**base_state, "documents": ["LangGraph docs snippet"]}
    node = make_generate_node(fake_llm)

    result = node(state)

    assert result["answer"] == "LangGraph is a library for building stateful agents."
    called_prompt = fake_llm.invoke.call_args[0][0]
    assert "LangGraph docs snippet" in called_prompt
    assert "What is LangGraph?" in called_prompt

This test does two useful things beyond checking the output. It inspects call_args to verify the prompt actually contains the retrieved context and the original question — which catches a very common bug class: a node that silently drops state fields when constructing a prompt. If someone refactors generate_node and forgets to interpolate documents into the prompt, this test fails immediately, long before you'd notice the model giving worse answers in production.

For nodes using structured output (.with_structured_output()), mock the return value as the parsed Pydantic object directly rather than trying to simulate the underlying JSON parsing — you are testing your node's logic, not LangChain's structured-output implementation.

from pydantic import BaseModel

class Verdict(BaseModel):
    is_relevant: bool
    reason: str

def test_grade_node_flags_irrelevant_documents(base_state):
    fake_structured_llm = MagicMock()
    fake_structured_llm.invoke.return_value = Verdict(is_relevant=False, reason="off topic")

    node = make_grade_node(fake_structured_llm)
    result = node({**base_state, "documents": ["unrelated text"]})

    assert result["documents"] == []

Testing Tool-Calling Nodes

Nodes that call tools deserve their own testing pattern because there are two things to verify: did the node call the tool with the right arguments, and did it correctly translate the tool's output back into state?

def make_weather_node(weather_tool):
    def weather_node(state: GraphState) -> dict:
        city = state["city"]
        result = weather_tool.invoke({"city": city})
        return {"weather_report": result}
    return weather_node
def test_weather_node_calls_tool_with_correct_city():
    fake_tool = MagicMock()
    fake_tool.invoke.return_value = "72F and sunny"

    node = make_weather_node(fake_tool)
    result = node({"city": "Austin"})

    fake_tool.invoke.assert_called_once_with({"city": "Austin"})
    assert result == {"weather_report": "72F and sunny"}

def test_weather_node_propagates_tool_errors(base_state):
    fake_tool = MagicMock()
    fake_tool.invoke.side_effect = TimeoutError("weather API timeout")

    node = make_weather_node(fake_tool)

    import pytest
    with pytest.raises(TimeoutError):
        node({"city": "Austin", **base_state})

That last test matters more than it looks. Agent graphs fail in production because of network timeouts and API errors far more often than because of bad prompts. Deciding — explicitly, in a test — whether a node should raise, retry, or return a fallback state value is a design decision you want written down and enforced, not discovered during an incident.

Testing Conditional Edges and Routing Logic

Routing functions in LangGraph are the other place bugs hide, because they are pure functions of state that decide which node runs next — and they are trivially easy to unit test since there's no LLM or tool involved most of the time.

def route_after_grading(state: GraphState) -> str:
    if not state["documents"]:
        if state["retry_count"] >= 3:
            return "give_up"
        return "rewrite_query"
    return "generate"
def test_router_retries_when_no_documents_and_under_limit(base_state):
    state = {**base_state, "documents": [], "retry_count": 1}
    assert route_after_grading(state) == "rewrite_query"

def test_router_gives_up_after_max_retries(base_state):
    state = {**base_state, "documents": [], "retry_count": 3}
    assert route_after_grading(state) == "give_up"

def test_router_proceeds_to_generate_when_documents_present(base_state):
    state = {**base_state, "documents": ["some doc"], "retry_count": 0}
    assert route_after_grading(state) == "generate"

Parametrize these once you have more than two or three branches, since routing functions tend to accumulate edge cases over time:

import pytest

@pytest.mark.parametrize("documents,retry_count,expected", [
    ([], 0, "rewrite_query"),
    ([], 2, "rewrite_query"),
    ([], 3, "give_up"),
    ([], 5, "give_up"),
    (["doc"], 0, "generate"),
    (["doc"], 3, "generate"),
])
def test_route_after_grading_all_branches(base_state, documents, retry_count, expected):
    state = {**base_state, "documents": documents, "retry_count": retry_count}
    assert route_after_grading(state) == expected

This single parametrized test documents every branch of your routing logic in one readable table. When someone adds a fourth branch later, extending this test is a one-line addition, not a rewrite.

Handling State Reducers and Partial Updates

A subtlety specific to LangGraph: if your state schema uses Annotated fields with reducers — for example Annotated[list[str], operator.add] to append to a list instead of overwriting it — your node's unit test should check the *return value* the node produces, not the merged result, because merging is LangGraph's job, not the node's.

from typing import Annotated
import operator

class GraphState(TypedDict):
    messages: Annotated[list[str], operator.add]

def log_node(state: GraphState) -> dict:
    return {"messages": [f"processed: {state['messages'][-1]}"]}
def test_log_node_returns_new_message_only():
    state = {"messages": ["hello"]}
    result = log_node(state)
    # correct: node returns the delta, not the full accumulated list
    assert result == {"messages": ["processed: hello"]}

A common bug is a node that tries to be "helpful" and returns the full accumulated list itself, duplicating entries once LangGraph applies the reducer on top. Writing this assertion explicitly, and keeping a short comment about *why*, saves the next person on your team from reintroducing that bug after a refactor.

If you want extra confidence, you can also write a narrow test that constructs the actual StateGraph reducer merge logic for one field and confirms it behaves as expected — but that's closer to an integration test of your state schema, and it belongs in its own test file, separate from node logic tests.

Fixtures, Fakes, and Keeping Tests Fast

As your node count grows, resist the urge to build one giant "mock everything" fixture. It becomes a bottleneck: every node test depends on a fixture that changes for unrelated reasons, and CI failures stop telling you anything specific. Instead, favor small, composable fixtures per dependency:

@pytest.fixture
def fake_llm_returning(request):
    def _make(content):
        response = MagicMock()
        response.content = content
        llm = MagicMock()
        llm.invoke.return_value = response
        return llm
    return _make

def test_generate_node_with_custom_answer(base_state, fake_llm_returning):
    llm = fake_llm_returning("custom test answer")
    node = make_generate_node(llm)

    result = node({**base_state, "documents": ["ctx"]})

    assert result["answer"] == "custom test answer"

A factory fixture like fake_llm_returning lets each test spell out exactly what response it needs in one line, which keeps tests self-documenting. It also means when you're reading a failing test six months from now, the expected LLM behavior is right there in the test body — you don't have to go hunting through a shared conftest to understand what was mocked and why.

Keep an eye on runtime, too. A well-structured node unit test suite for a graph with fifteen nodes should run in well under two seconds, since nothing in it should touch the network, a real model, or a real vector store. If your "unit" tests are slow, something has leaked past the mock boundary — usually an LLM client or retriever that got instantiated at import time instead of injected.

Bringing It Together: From Node Tests to Confident Graphs

Unit testing individual LangGraph nodes will not, by itself, guarantee your agent behaves correctly end to end — you still want a smaller number of integration tests that build the real graph, feed in a realistic input, and check the final state or the sequence of nodes visited. But node-level tests are what make those integration tests meaningful. When an integration test fails, you want the failure to point at one thing: either the graph's wiring (which node connects to which) or a specific node's behavior, and if you already have solid node unit tests passing, you can rule out the second cause almost instantly.

The pattern across everything in this article is consistent: keep nodes as plain functions with dependencies passed in, treat the returned dictionary as the real contract to assert against, mock at the boundary of your own code and the outside world (LLM clients, retrievers, tool clients), and test routing functions as pure functions of state since they usually don't need any mocking at all. None of this requires special LangGraph test utilities — it's ordinary pytest discipline applied to a graph-shaped codebase.

If you're building production agents with LangGraph and want a structured, hands-on walkthrough of graph design, state management, testing, and deployment patterns, our LangGraph Tutorial course on teachyou.ai covers this in depth, including full example projects you can adapt directly into your own test suites.