Article summary

TL;DR

  • Run the compiled LangGraph workflow with scripted AIMessage responses so the normal unit suite stays fast and deterministic.
  • Use the pytest-mock mocker fixture at the external tool boundary instead of patching LangGraph internals.
  • Assert the tool input, resulting ToolMessage, final graph state, and chosen failure policy.
  • Keep a separate integration or evaluation layer for the question a unit test cannot answer: whether a real model selects the right tool.

01 / TESTING SCOPE

A useful LangGraph unit test removes model uncertainty without removing the graph.

A tool-using agent can fail in ordinary software ways. The graph can route to the wrong node, a tool can receive the wrong argument, an external service can fail, or a tool result can disappear before the next model call.

Calling a live model in every test makes those failures harder to isolate. The test becomes slower, costs money, needs credentials, and can produce different text on the next run. Mocking graph.invoke() creates the opposite problem: the test is stable because none of the graph runs.

The useful boundary sits between those extremes. Run the compiled LangGraph workflow, but control the model responses and the external tool dependency.

How do you test a LangGraph agent with pytest?

Invoke the real compiled graph with scripted AIMessage responses, replace external tool dependencies with controlled mocks, and assert the state and collaborator calls that carry the required behavior. Keep real model selection and natural-language quality in a separate integration or evaluation suite.

This tutorial builds a small order-status agent and tests this path:

HumanMessage
  -> scripted model tool call
  -> ToolNode
  -> mocked OrderClient
  -> ToolMessage
  -> scripted final model response

The LangChain unit-testing guidance recommends replacing a real model with deterministic in-memory responses. We will apply that idea to a LangGraph StateGraph and use the pytest-mock mocker fixture for the order service.

Fake the model response, mock the external tool boundary, and keep the real graph execution in the test.

The result is a normal pytest test that needs no model API key and makes no network request. It proves that a known tool call travels through the graph correctly. It does not claim that a real model will choose that tool for every user prompt. We will preserve that distinction throughout the article.

This article tests an AI agent itself. If your problem is getting a coding agent to produce durable test suites, read how to guide AI coding agents to write better pytest tests.

02 / BUILD THE GRAPH

Build the smallest tool-using graph that exposes the important boundaries.

Create a new project and install the runtime dependencies:

mkdir langgraph-agent-testing
cd langgraph-agent-testing
uv init --bare --no-workspace
uv add langgraph langchain-core
uv add --dev pytest pytest-mock

The example below was verified with Python 3.13.12, LangGraph 1.2.11, langchain-core 1.6.2, pytest 9.1.1, and pytest-mock 3.15.1. LangGraph and LangChain evolve quickly, so keep the lockfile with the example and check the migration guide before changing major versions.

To run the complete project instead of copying each snippet, open the LangGraph agent testing example on GitHub. It includes the source files, locked dependencies, usage instructions, and the same tests shown below.

The finished project needs two source files:

langgraph-agent-testing/
├── agent.py
├── test_agent.py
├── pyproject.toml
└── uv.lock

Add the following code to agent.py:

from typing import Protocol

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import SystemMessage
from langchain_core.tools import tool
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition


class OrderClient(Protocol):
    def get_status(self, order_id: str) -> str: ...


def build_agent(model: BaseChatModel, order_client: OrderClient):
    @tool
    def get_order_status(order_id: str) -> str:
        """Return the current status of an order."""
        return order_client.get_status(order_id)

    tools = [get_order_status]
    model_with_tools = model.bind_tools(tools)

    def call_model(state: MessagesState):
        response = model_with_tools.invoke(
            [
                SystemMessage(content="Help customers track their orders."),
                *state["messages"],
            ]
        )
        return {"messages": [response]}

    builder = StateGraph(MessagesState)
    builder.add_node("model", call_model)
    builder.add_node("tools", ToolNode(tools, handle_tool_errors=False))
    builder.add_edge(START, "model")
    builder.add_conditional_edges("model", tools_condition)
    builder.add_edge("tools", "model")
    return builder.compile()

MessagesState keeps the conversation as graph state. The model node adds an AIMessage. tools_condition examines that message: a tool call routes to the tools node, while a normal response ends the graph. ToolNode executes the requested tool and adds its result as a ToolMessage before the graph returns to the model.

The OrderClient protocol is the boundary to the outside world. Its production implementation might call an HTTP API or database, but neither belongs in this unit test. build_agent() accepts the client and model instead of constructing them internally, so the test can replace each dependency without patching framework code.

This tutorial deliberately uses LangGraph’s core Graph API because the nodes and routing are the behavior under test. LangGraph v1 deprecated langgraph.prebuilt.create_react_agent; the high-level replacement is langchain.agents.create_agent. The explicit StateGraph API remains appropriate when you want direct control over orchestration.

03 / SCRIPT THE MODEL

Script the two model turns instead of calling a provider.

The agent needs two model responses for the successful path:

  1. The first response requests get_order_status with order ID A-42.
  2. The second response turns the tool result into the final answer.

Create test_agent.py with these imports and helpers:

import pytest
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from pytest_mock import MockerFixture

from agent import OrderClient, build_agent


def model_script(mocker: MockerFixture, *responses: AIMessage):
    model = mocker.Mock(spec=BaseChatModel)
    model_with_tools = mocker.Mock()
    model_with_tools.invoke.side_effect = responses
    model.bind_tools.return_value = model_with_tools
    return model


def order_status_tool_call() -> AIMessage:
    return AIMessage(
        content="",
        tool_calls=[
            {
                "name": "get_order_status",
                "args": {"order_id": "A-42"},
                "id": "call-1",
                "type": "tool_call",
            }
        ],
    )

model_script() does not imitate natural-language reasoning. It supplies the exact protocol messages needed to exercise the graph. side_effect returns one response per call, which matches the two model turns in this workflow.

The spec=BaseChatModel argument limits the mock to the model interface. A typo such as model.bind_tool(...) fails during test setup instead of silently creating another mock attribute. model.bind_tools.return_value represents the tool-bound model used inside the node.

order_status_tool_call() returns a real AIMessage, not a loose dictionary pretending to be one. LangGraph can therefore process the message through its normal routing and tool-execution path.

Official LangChain documentation also provides GenericFakeChatModel for scripted responses. A small fake can be a good choice when it implements the complete interface required by your agent builder. This example uses a specced pytest mock because build_agent() binds tools and we want that collaborator boundary to remain explicit.

04 / TEST THE TOOL PATH

Run the compiled graph and assert the observable tool contract.

Add the successful test below the helpers in test_agent.py:

def test_agent_looks_up_order_and_returns_status(mocker: MockerFixture):
    order_client = mocker.Mock(spec=OrderClient)
    order_client.get_status.return_value = "shipped"
    model = model_script(
        mocker,
        order_status_tool_call(),
        AIMessage(content="Order A-42 has shipped."),
    )
    agent = build_agent(model, order_client)

    result = agent.invoke({"messages": [HumanMessage(content="Where is order A-42?")]})

    order_client.get_status.assert_called_once_with("A-42")
    assert isinstance(result["messages"][-2], ToolMessage)
    assert result["messages"][-2].content == "shipped"
    assert result["messages"][-1].content == "Order A-42 has shipped."

Run the test:

uv run pytest test_agent.py -v

The result should contain the passing test:

test_agent.py::test_agent_looks_up_order_and_returns_status PASSED

Each assertion protects a distinct boundary:

  • assert_called_once_with("A-42") proves that the graph delivered the model’s structured argument to the external dependency.
  • The ToolMessage assertion proves that the tool result returned to the graph’s message state.
  • The final assertion proves that the graph completed the second model turn and exposed its response to the caller.

The final sentence is safe to compare exactly here because the test itself scripted that sentence. It does not measure the quality of a real model’s wording. A live-model test should normally assert a stable capability or use an evaluator instead of requiring one exact phrase.

The pytest-mock documentation describes mocker as a wrapper around Python’s patching API with automatic cleanup after each test. That cleanup matters in a suite: a return value configured here cannot leak into the next test.

Directly injecting OrderClient is clearer than patching an import path. If existing code constructs the dependency inside the module, use mocker.patch() on the name looked up by that module and prefer autospec=True. Do not patch ToolNode, tools_condition, or graph.invoke(). Those are the parts this test is supposed to exercise.

Learn the same mocking workflow in depth with the Pytest course lessons on `mocker`, patch targets, return values, side effects, and interaction assertions.

View course

05 / TEST FAILURES

Give the external failure path an explicit contract.

Successful tool calls are only half of the boundary. An order service can time out before it returns a status. Use the mock’s side_effect to make that failure deterministic:

def test_agent_propagates_order_service_timeout(mocker: MockerFixture):
    order_client = mocker.Mock(spec=OrderClient)
    order_client.get_status.side_effect = TimeoutError("order service unavailable")
    model = model_script(mocker, order_status_tool_call())
    agent = build_agent(model, order_client)

    with pytest.raises(TimeoutError, match="order service unavailable"):
        agent.invoke({"messages": [HumanMessage(content="Where is order A-42?")]})

The graph uses ToolNode(tools, handle_tool_errors=False), so this example deliberately lets the timeout escape. The caller can translate it into an HTTP error, retry policy, or user-safe response at the appropriate application boundary.

Propagation is not the only valid policy. A production agent could catch a known service exception and return a controlled ToolMessage, or it could retry a transient failure with a strict limit. What matters is choosing the behavior instead of accepting a framework default accidentally.

When you change the policy, change the test to protect the new observable result:

  1. Configure the dependency to raise the real exception type your adapter exposes.
  2. Invoke the compiled graph through the same public entry point.
  3. Assert the exception, state update, retry limit, or safe message promised to the caller.

Avoid asserting every internal call made while handling the error. The stable contract is what the graph does with the failure, not which private helper happens to catch it today.

Run both tests:

uv run pytest -v

The verified example produces:

test_agent.py::test_agent_looks_up_order_and_returns_status PASSED
test_agent.py::test_agent_propagates_order_service_timeout PASSED

2 passed

06 / TEST LIMITS

Keep model behavior in a separate integration and evaluation layer.

The two tests prove that your Python orchestration handles a known tool call correctly. They catch a renamed tool, a broken edge, the wrong argument mapping, a missing ToolMessage, or a changed timeout policy.

They do not prove that a real model will select get_order_status when a user asks “Has my parcel left yet?” The scripted first AIMessage already made that decision. Pretending otherwise gives a deterministic test more confidence than it earned.

Use three layers for different risks:

  1. Unit tests: script messages and mock external dependencies. Run these on every change.
  2. Integration tests: make a small number of real provider and service calls to verify credentials, schemas, and adapters.
  3. Agent evaluations: run a dataset of varied prompts and score tool choice, trajectory, final behavior, and regressions.

LangChain’s integration-testing guide reserves real API calls for tests that need to verify providers and external services. Its agent-evaluation guide covers trajectory matching when several valid message paths may satisfy the same capability.

Is this still a unit test if the compiled graph runs?

Yes. The test isolates one application component from its model provider and order service. Running several internal nodes does not turn it into an external integration test. The boundary and dependencies matter more than the number of functions executed.

Should I test every LangGraph node separately?

Not automatically. Test a node directly when it owns meaningful branching, validation, or state transformation that deserves focused examples. Do not create one test per node merely because the graph contains nodes. The compiled test already covers simple plumbing through the public invocation boundary.

Should I assert the complete message list?

Usually no. A full equality assertion couples the test to message metadata, order details, and framework changes that may not affect your requirement. Select the message type, content, tool argument, or state field that carries the behavior you need to preserve.

Can I replace mocker with monkeypatch?

Yes, but they solve slightly different problems. monkeypatch is convenient for replacing attributes, environment variables, dictionaries, and paths. mocker exposes the unittest.mock API, including specs, side effects, call inspection, spies, and automatic cleanup. Use the tool that makes the contract clearest in your suite.

07 / NEXT STEP

Start with one deterministic path, then add only the next risk.

The useful first test does not need a live model, tracing platform, evaluation dataset, or large fixture system. It needs one representative tool call, one controlled tool result, and assertions that prove the graph carried both across the correct boundary.

Add the timeout case next because it represents a different contract. Then stop. More tests should come from real risks: another routing branch, state persisted across turns, authorization before tool execution, or a regression you have observed.

If the agent will be exposed through an HTTP endpoint, keep these graph tests and add a smaller set of request-and-response checks at the application boundary. The FastAPI testing tutorial with pytest shows how to test that wider path without starting a server.

Before adapting the example, run uv run pytest -v unchanged. Then replace A-42 with an order ID from your own domain and make the tool return a status your application actually promises. Keep the model and service controlled until that deterministic path protects a real requirement.