DEV Community

Cover image for Testing LLM Tool Calls With Pytest: Asserting the Right Tool, Arguments, and Order
Mykhailo Krasnovskyi
Mykhailo Krasnovskyi

Posted on

Testing LLM Tool Calls With Pytest: Asserting the Right Tool, Arguments, and Order

Your agent's tool-call harness needs its own tests. Models call tools with negative amounts, skip required steps, invent tool names and keep going after a failure. You won't see that in a demo. You can see it in production.

This tutorial builds a deterministic pytest suite for the code around an LLM's tool calls, using Python 3.11 or newer, pytest, pydantic v2 and a scripted fake LLM client. No API keys, no network, and the full suite runs in under a second.

The tests check 6 things:

  • The selected tool: The agent runs exactly what the model asked for, and nothing else
  • Arguments: Schema and values are validated before any tool runs
  • Order: No refund before a successful lookup
  • Failure mid-chain: A broken tool stops the flow, and the model is told why
  • Unexpected crashes: A tool that raises something you didn't plan for is reported, not fatal
  • CI: The suite gates every pull request in GitHub Actions

A mocked LLM tests your harness, not the model. The harness is the loop that receives tool calls, validates them, runs tools, handles errors, and passes results back. You own it, and it does the damage when it trusts the model too much.

Whether the real model picks the right tool is a separate question that needs to be evaluated against real output. More on that at the end.

Step 1: The Agent Loop

The agent talks to the LLM through a small interface, so any provider can plug in behind a thin adapter.

agent.py:

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol

from pydantic import BaseModel, ValidationError


@dataclass
class ToolCall:
    name: str
    arguments: dict[str, Any]


@dataclass
class FinalAnswer:
    text: str


Step = ToolCall | FinalAnswer


class LLMClient(Protocol):
    def next_step(self, messages: list[dict[str, Any]]) -> Step: ...


class ToolError(Exception):
    """Raised by a tool when the underlying system fails."""


@dataclass
class Tool:
    args_model: type[BaseModel]
    func: Callable[..., Any]
    requires: str | None = None  # name of a tool that must succeed first


@dataclass
class CallRecord:
    name: str
    arguments: dict[str, Any]
    ok: bool


@dataclass
class AgentResult:
    answer: str
    calls: list[CallRecord] = field(default_factory=list)


def _serialise(result: Any) -> str:
    """Tool results go to the model as JSON, not as a Python repr."""
    return json.dumps(result, default=str)


def run_agent(client: LLMClient, tools: dict[str, Tool], user_message: str,
              max_steps: int = 5) -> AgentResult:
    messages: list[dict[str, Any]] = [{"role": "user", "content": user_message}]
    calls: list[CallRecord] = []

    for _ in range(max_steps):
        step = client.next_step(messages)
        if isinstance(step, FinalAnswer):
            return AgentResult(answer=step.text, calls=calls)

        tool = tools.get(step.name)
        if tool is None:
            result, ok = f"unknown tool: {step.name}", False
        elif tool.requires and not any(c.name == tool.requires and c.ok for c in calls):
            result, ok = f"blocked: {step.name} needs a successful {tool.requires} first", False
        else:
            try:
                args = tool.args_model.model_validate(step.arguments)
                result, ok = _serialise(tool.func(**args.model_dump())), True
            except ValidationError as exc:
                result, ok = f"invalid arguments: {exc.errors(include_url=False)}", False
            except ToolError as exc:
                result, ok = f"tool error: {exc}", False
            except Exception as exc:
                # A tool raised something we did not plan for. Report it to the
                # model instead of letting it kill the request.
                result, ok = f"tool crashed: {type(exc).__name__}: {exc}", False

        # Record the assistant turn that produced the call, then its result.
        messages.append({"role": "assistant",
                         "tool_calls": [{"name": step.name, "arguments": step.arguments}]})
        calls.append(CallRecord(step.name, step.arguments, ok))
        messages.append({"role": "tool", "name": step.name,
                         "content": result, "is_error": not ok})

    raise RuntimeError(f"agent did not finish in {max_steps} steps")

Enter fullscreen mode Exit fullscreen mode

5 design choices make it testable:

  1. Every tool call passes through a pydantic model before the tool runs
  2. requires enforces order in code, not in the prompt
  3. Errors return to the model as a tool message, so it can respond instead of improvising
  4. The catch-all except Exception means a tool written by another team cannot take down the request. Without it, one KeyError escapes run_agent and your user gets a 500 instead of an apology
  5. Both the assistant turn and the tool result land in messages, which is the shape the OpenAI and Anthropic APIs expect. A tool result with no matching assistant call is rejected by both

One limit to be explicit about: requires is a sequence gate, not an argument binding. It checks that some lookup_order succeeded, not that it was for the order now being refunded. Step 6 tests exactly that gap.

Step 2: The Argument Schemas

tools.py:

from decimal import Decimal
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field


class LookupOrderArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^A-\d{4}$")


class IssueRefundArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^A-\d{4}$")
    amount: Decimal = Field(gt=0, max_digits=8, decimal_places=2)
    reason: Literal["damaged", "late", "other"]

Enter fullscreen mode Exit fullscreen mode

Keep extra="forbid". Models sometimes invent parameters, and without it a made-up "force": true passes silently.
max_digits=8is worth adding alongside gt=0. On its own, decimal_places=2 accepts scientific notation, so a model emitting "1e2" produces Decimal("1E+2") — a 100.00 refund that sails through the schema.

Step 3: Fixtures and the Scripted LLM

conftest.py:

from decimal import Decimal
from typing import Any

import pytest

from agent import Step, Tool, ToolError
from tools import IssueRefundArgs, LookupOrderArgs


class ScriptedLLM:
    """Returns pre-recorded steps in order and keeps every prompt it saw."""

    def __init__(self, steps: list[Step]):
        self._steps = list(steps)
        self.seen: list[list[dict[str, Any]]] = []

    def next_step(self, messages: list[dict[str, Any]]) -> Step:
        self.seen.append([dict(m) for m in messages])
        if not self._steps:
            raise AssertionError("agent asked the LLM for more steps than scripted")
        return self._steps.pop(0)


@pytest.fixture
def orders() -> dict[str, dict[str, Any]]:
    return {"A-1001": {"total": Decimal("49.90"), "status": "delivered"}}


@pytest.fixture
def refunds() -> list[dict[str, Any]]:
    return []


@pytest.fixture
def tools(orders, refunds) -> dict[str, Tool]:
    def lookup_order(order_id: str) -> dict[str, Any]:
        if order_id not in orders:
            raise ToolError(f"order {order_id} not found")
        return orders[order_id]

    def issue_refund(order_id: str, amount: Decimal, reason: str) -> dict[str, str]:
        if order_id not in orders:
            # Never assume an earlier lookup covered this order id.
            raise ToolError(f"order {order_id} not found")
        if amount > orders[order_id]["total"]:
            raise ToolError("refund exceeds order total")
        refunds.append({"order_id": order_id, "amount": amount, "reason": reason})
        return {"refund_id": f"R-{len(refunds)}"}

    return {
        "lookup_order": Tool(LookupOrderArgs, lookup_order),
        "issue_refund": Tool(IssueRefundArgs, issue_refund, requires="lookup_order"),
    }


@pytest.fixture
def llm():
    """Returns the class, so each test scripts its own steps: llm([...])."""
    return ScriptedLLM

Enter fullscreen mode Exit fullscreen mode

Keep all four files in one directory so from agent import ... resolves without packaging.

2 fixtures do most of the work:

  • refunds is your side-effect log. The key assertion is usually one line: did money move when it shouldn't have?
  • ScriptedLLM.seen records every prompt the agent sent, so you can check what the model was told after a failure

The guard inside issue_refund matters more than it looks. Every tool should validate its own inputs rather than trusting that an earlier step in the chain checked them. Without it, a refund for an unknown order raises a KeyError from inside the tool.

For more realistic scripts, record a few real model responses, map them to ToolCall objects, and replay them here.

Step 4: Test the Selected Tool

test_tool_calls.py:

from decimal import Decimal

import pytest

from agent import FinalAnswer, ToolCall, run_agent


def test_status_question_calls_lookup_only(llm, tools, refunds):
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        FinalAnswer("Order A-1001 was delivered."),
    ])

    result = run_agent(client, tools, "Where is my order A-1001?")

    assert [c.name for c in result.calls] == ["lookup_order"]
    assert result.calls[0].ok
    assert refunds == []


def test_unknown_tool_is_reported_not_crashed(llm, tools):
    client = llm([
        ToolCall("cancel_subscription", {"user_id": "u1"}),
        FinalAnswer("I can't do that here."),
    ])

    result = run_agent(client, tools, "Cancel my plan")

    assert result.calls[0].ok is False
    last_tool_msg = client.seen[-1][-1]
    assert last_tool_msg["is_error"] is True
    assert "unknown tool" in last_tool_msg["content"]

Enter fullscreen mode Exit fullscreen mode

Models do invent tool names. The loop should report it back to the model, not throw a KeyError at the user.

Step 5: Validate Arguments, Schema and Values

Schema

One parametrized test covers the most common ways a model gets arguments wrong:

@pytest.mark.parametrize("bad_args", [
    {"order_id": "1001", "amount": "10.00", "reason": "late"},              # wrong id format
    {"order_id": "A-1001", "amount": "-5", "reason": "late"},               # negative amount
    {"order_id": "A-1001", "amount": "0.00", "reason": "late"},             # zero amount
    {"order_id": "A-1001", "amount": "10.001", "reason": "late"},           # sub-cent precision
    {"order_id": "A-1001", "amount": "10.00"},                              # missing reason
    {"order_id": "A-1001", "amount": "10.00", "reason": "because"},         # not in enum
    {"order_id": "A-1001", "amount": "10.00", "reason": "late", "force": True},  # extra field
])
def test_invalid_refund_arguments_never_reach_the_tool(llm, tools, refunds, bad_args):
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        ToolCall("issue_refund", bad_args),
        FinalAnswer("Something went wrong."),
    ])

    result = run_agent(client, tools, "Refund A-1001")

    assert result.calls[1].ok is False
    assert refunds == []
    assert "invalid arguments" in client.seen[-1][-1]["content"]

Enter fullscreen mode Exit fullscreen mode

Values

A valid schema doesn't make a request sensible. Check the values that reach the tool and the business rules behind them:

def test_refund_values_match_the_request(llm, tools, refunds):
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        ToolCall("issue_refund", {"order_id": "A-1001", "amount": "49.90", "reason": "damaged"}),
        FinalAnswer("Refunded 49.90."),
    ])

    run_agent(client, tools, "A-1001 arrived broken, refund please")

    assert refunds == [{"order_id": "A-1001", "amount": Decimal("49.90"), "reason": "damaged"}]


def test_refund_above_order_total_is_rejected(llm, tools, refunds):
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        ToolCall("issue_refund", {"order_id": "A-1001", "amount": "99.00", "reason": "other"}),
        FinalAnswer("I couldn't process that refund."),
    ])

    result = run_agent(client, tools, "Refund 99 for A-1001")

    assert result.calls[1].ok is False
    assert refunds == []
    assert "exceeds order total" in client.seen[-1][-1]["content"]

Enter fullscreen mode Exit fullscreen mode

The string "49.90" goes in and Decimal("49.90") comes out. That's pydantic coercion, and the test locks it in, because floats and money don't mix.

Step 6: Assert Call Order

def test_refund_flow_runs_in_order(llm, tools):
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        ToolCall("issue_refund", {"order_id": "A-1001", "amount": "49.90", "reason": "late"}),
        FinalAnswer("Done."),
    ])

    result = run_agent(client, tools, "Refund A-1001, it was late")

    assert [(c.name, c.ok) for c in result.calls] == [
        ("lookup_order", True),
        ("issue_refund", True),
    ]


def test_refund_before_lookup_is_blocked(llm, tools, refunds):
    client = llm([
        ToolCall("issue_refund", {"order_id": "A-1001", "amount": "49.90", "reason": "late"}),
        FinalAnswer("I need to check the order first."),
    ])

    result = run_agent(client, tools, "Just refund A-1001")

    assert result.calls[0].ok is False
    assert "blocked" in client.seen[-1][-1]["content"]
    assert refunds == []


def test_lookup_of_one_order_does_not_unlock_another(llm, tools, refunds):
    """requires is a sequence gate, not an argument binding."""
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        ToolCall("issue_refund", {"order_id": "A-2002", "amount": "10.00", "reason": "late"}),
        FinalAnswer("I couldn't refund that order."),
    ])

    result = run_agent(client, tools, "Check A-1001 then refund A-2002")

    assert [(c.name, c.ok) for c in result.calls] == [
        ("lookup_order", True),
        ("issue_refund", False),
    ]
    assert refunds == []

Enter fullscreen mode Exit fullscreen mode

Assert on a list of (name, ok) tuples. When the sequence drifts, you get one readable failure instead of five scattered asserts.

That third test is the one worth copying into your own suite. A model that looks up one order and refunds a different one satisfies requires perfectly — the sequence is correct and the money is still wrong. Order checks alone will not catch it; only the tool's own guard will.

Step 7: Simulate a Failure Mid-Chain

This is the scenario that matters most in production. The lookup fails, and the model follows its plan and tries to refund anyway.

def test_lookup_failure_stops_the_refund(llm, tools, refunds):
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-9999"}),
        ToolCall("issue_refund", {"order_id": "A-9999", "amount": "10.00", "reason": "other"}),
        FinalAnswer("I couldn't find that order. A human agent will follow up."),
    ])

    result = run_agent(client, tools, "Refund A-9999")

    assert [(c.name, c.ok) for c in result.calls] == [
        ("lookup_order", False),
        ("issue_refund", False),
    ]
    assert refunds == []
    # the model saw the failure before deciding its next step
    assert "not found" in client.seen[1][-1]["content"]
    assert "human agent" in result.answer


def test_unexpected_tool_exception_is_reported_not_raised(llm):
    """A tool that raises something unplanned must not kill the request."""
    def boom(order_id: str):
        raise TimeoutError("order service did not respond")

    from agent import Tool
    from tools import LookupOrderArgs

    tools = {"lookup_order": Tool(LookupOrderArgs, boom)}
    client = llm([
        ToolCall("lookup_order", {"order_id": "A-1001"}),
        FinalAnswer("Our order service is down. Please try again shortly."),
    ])

    result = run_agent(client, tools, "Where is A-1001?")

    assert result.calls[0].ok is False
    assert "tool crashed: TimeoutError" in client.seen[-1][-1]["content"]


def test_agent_gives_up_after_max_steps(llm, tools):
    client = llm([ToolCall("lookup_order", {"order_id": "A-1001"})] * 6)

    with pytest.raises(RuntimeError, match="did not finish"):
        run_agent(client, tools, "loop forever", max_steps=5)

Enter fullscreen mode Exit fullscreen mode

What these 3 tests prove:

  1. client.seen[1] is the prompt for step two. Checking it proves the error reached the model before it chose its next move, not just that the error happened
  2. Real tools raise TimeoutError, ConnectionError and KeyError, not only the exception type you defined. The second test is the difference between a degraded answer and a 500
  3. The max-steps test catches a model stuck in a loop, which happens in practice and burns real tokens

Run the suite:

bash, shell

pip install pytest "pydantic>=2"
pytest -q

Enter fullscreen mode Exit fullscreen mode

17 tests (the parametrized one counts seven times), finished in well under a second.

Step 8: Gate Pull Requests in GitHub Actions

.github/workflows/tool-calls.yml:

yami

name: tool-call-tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-python@v7
        with:
          python-version: "3.11"

      - run: pip install pytest "pydantic>=2"
      - run: pytest -q

Enter fullscreen mode Exit fullscreen mode

Pin pydantic to v2. Every example here uses model_validate, model_config and ConfigDict, none of which exist in pydantic v1, and an unpinned install on a fresh runner is the kind of thing that breaks on a Monday.

No secrets, no API calls and no flakiness from model output, so these tests can safely block every pull request.

The Next Layer: Testing the Real Model

Scripted tests prove your harness holds up when the model misbehaves. They don't tell you how often the real model misbehaves, or what it says to the user afterward.

That layer needs:

Scenarios run against the real model
Multi-turn conversations, not just single requests
Evaluation of the final answers
Our guide on how to test AI agents covers it.

If you'd like a team to build both layers for your product — harness tests like these, plus scenario testing against real models — see our AI product and LLM testing services.

Top comments (0)