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") 5 design choices make it testable:
- Every tool call passes through a pydantic model before the tool runs
-
requiresenforces order in code, not in the prompt - Errors return to the model as a tool message, so it can respond instead of improvising
- The catch-all
except Exceptionmeans a tool written by another team cannot take down the request. Without it, oneKeyErrorescapesrun_agentand your user gets a 500 instead of an apology - 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"] 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 Keep all four files in one directory so from agent import ... resolves without packaging.
2 fixtures do most of the work:
-
refundsis your side-effect log. The key assertion is usually one line: did money move when it shouldn't have? -
ScriptedLLM.seenrecords 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"] 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"] 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"] 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",Source:https://dev.to/krasmik/testing-llm-tool-calls-with-pytest-asserting-the-right-tool-arguments-and-order-3baeRelated posts
AMD Leverages AI + HRX Loom In Experiment For Quickly Porting 50 LLMs To Client GPUs

Thousands of GitHub repositories targeted in widespread attack — 4,800 accounts already compromised to steal cloud credentials

FBI seizes tools used by North Korean hackers, freezes $1.2bilion in stolen Binance funds

Your next chair could be a designer one thanks to the Herman Miller Black Friday sale

Popular gaming chair and desk maker Secretlab launches dedicated US brick-and-mortar locations — new shop in LA, spaces in Best Buy

