How to Test AI Agents: Unit, Integration and Chaos Testing
You've spent weeks building an autonomous agent that negotiates SaaS contracts via email. In your local notebook, it's brilliant. It drafts perfect counteroffers and knows when to walk away. But yesterday, in production, it approved a 40% discount because the underlying model updated its tokenization logic, confusing the percentage extraction tool.
This is the reality of building agentic systems in 2026. The stack has matured-context windows are massive, inference is cheap-but the engineering surface area has exploded. You aren't just testing code; you're testing probabilistic reasoning, external tool reliability, and state management across long-running loops.
If you treat AI agents like standard microservices, you will fail. If you treat them like pure prompts, you will burn cash. You need a hybrid testing strategy that respects the non-deterministic nature of LLMs while enforcing deterministic boundaries around your business logic. Here is the framework I use to ship reliable agents.
Unit Testing the Deterministic Boundaries
The biggest mistake I see teams make is trying to unit test the LLM itself. Don't do it. The model is a third-party service you don't control, and its output is inherently non-deterministic. Instead, unit test everything around the model.
Your agent is likely composed of tool definitions, prompt templates, and output parsers. These are deterministic code paths that deserve 100% coverage. Specifically, focus on input validation for your tools. If your agent calls a create_invoice tool, the schema validation must happen before the API call is ever made.
In my pipelines, I use Pydantic models to strictly define tool arguments. I then write unit tests that feed edge cases into these validators, ensuring the agent cannot construct invalid tool calls even if the model hallucinates.
# tools.py
from pydantic import BaseModel, Field, validator
class CreateInvoiceArgs(BaseModel):
amount: float = Field(gt=0)
currency: str
client_id: str
@validator('currency')
def check_currency(cls, v):
if v not in ['USD', 'EUR', 'GBP']:
raise ValueError('Unsupported currency')
return v
test_tools.py
import pytest
from tools import CreateInvoiceArgs
def test_invalid_currency_rejected():
with pytest.raises(ValueError):
CreateInvoiceArgs(amount=100.0, currency='BTC', client_id='c_123')
def test_negative_amount_rejected():
with pytest.raises(ValueError):
CreateInvoiceArgs(amount=-50.0, currency='USD', client_id='c_123')
Beyond schemas, unit test your prompt rendering logic. If you use f-strings or template engines to inject context, verify that special characters don't break the structure. I've seen agents fail because a user's name contained a curly brace that broke the Jinja2 template. Treat prompt templates as code, not text. Mock the data injection and assert the final string structure matches expectations before it ever hits the inference API.
Integration Testing the Agent Loop
Once your boundaries are secure, you need to test how the agent orchestrates them. This is where integration testing comes in. The goal here is to verify the flow: Does the agent recognize when to use a tool? Does it handle the tool's response correctly? Does it know when to stop?
To make these tests deterministic, you must mock the LLM. You cannot rely on model.generate() during CI/CD. Instead, record specific model responses that trigger specific branching logic in your agent's state machine.
If you are using a framework like LangGraph or AutoGen, you can inject a fake model provider that returns predefined messages. This allows you to simulate a multi-turn conversation without spending credits or waiting on latency.
# test_agent_flow.py
from unittest.mock import Mock
from agent_framework import Agent, State
def test_agent_refunds_after_verification():
# Mock the LLM to return a tool call followed by a final answer
mock_llm = Mock()
mock_llm.generate.side_effect = [
# Turn 1: Agent decides to check user tier
{"tool_calls": [{"name": "get_user_tier", "args": {"user_id": "u_1"}}]},
# Turn 2: Agent decides to process refund based on tool output
{"content": "Refund processed successfully."}
]
agent = Agent(llm=mock_llm)
state = State(user_input="Please refund my last order", user_id="u_1")
result = agent.run(state)
# Assert the agent called the right tool
assert mock_llm.generate.call_count == 2
assert state.tools_called[0].name == "get_user_tier"
assert result.final_response == "Refund processed successfully."
This approach tests the glue. It verifies that your agent parses the tool call, executes the function, feeds the result back into the context, and generates a final response. It also helps you catch infinite loops. In the test above, if the agent entered a loop trying to verify the user tier repeatedly, the call_count assertion would fail, or you would hit a hardcoded recursion limit in your test harness.
Integration tests should also cover the "happy path" and the "expected failure path." What happens when the tool returns a 404? Does the agent apologize and ask for clarification, or does it crash? Simulate tool errors in your mocks to ensure your error handling logic is robust.
Chaos Testing for Non-Determinism and Drift
Unit and integration tests cover your code, but they don't cover the model's behavior in the wild. Models drift. APIs latency spikes. Tokens get rate-limited. This is where chaos engineering for AI agents becomes critical.
Chaos testing for agents involves intentionally injecting noise into your system to observe how it degrades. In 2026, model providers are reliable, but they aren't perfect. You need to know what happens when the model returns malformed JSON, ignores your system prompt, or takes 30 seconds to respond.
I implement chaos decorators around my LLM clients. These decorators can randomly corrupt outputs, introduce latency, or raise exceptions during local staging runs.
# chaos_llm.py
import random
import time
from functools import wraps
def chaos_injection(probability=0.1):
def decorator(func):
@wraps(func)
def wrapper(args, *kwargs):
if random.random() < probability:
# Scenario 1: Latency spike
time.sleep(5)
if random.random() < probability:
# Scenario 2: Malformed JSON response
return {"content": "{ invalid_json: true }"}
if random.random() < probability:
# Scenario 3: Rate limit simulation
raise Exception("429 Too Many Requests")
return func(args, *kwargs)
return wrapper
return decorator
Apply to your production LLM client in staging
@chaos_injection(probability=0.2)
def generate_completion(prompt, **kwargs):
return real_llm_client.generate(prompt, **kwargs)
Run your integration test suite against this chaotic client. Does your agent retry on 429 errors? Does it have a fallback mechanism when JSON parsing fails? Does the latency spike trigger a timeout in your upstream API gateway?
Another crucial aspect of chaos testing is semantic drift. When a provider releases a new model version (e.g., gpt-5.1 vs gpt-5.0), behavior can shift subtly. I maintain a "golden dataset" of 50-100 complex prompts with expected outcomes. Before promoting a new model version to production, I run this dataset through the new model. If the semantic similarity score drops below a threshold (e.g., 0.85 cosine similarity on embeddings), the deployment is blocked. This prevents silent regressions where the agent is still "working" but performing significantly worse.
Building the Evaluation Pipeline
Testing isn't a one-off task; it's a pipeline. In traditional DevOps, you have linting, unit tests, and deployment. For AI agents, you need an Evaluation Gate.
Your CI/CD pipeline should look like this:
I use tools like Arize Phoenix or LangSmith to track these evals, but you can start simple with a script that scores outputs. The key metric here is Pass@K. Run your agent on a test task K times (e.g., 5 times). If it succeeds 3 out of 5 times, is that acceptable? For a code generation agent, maybe. For a financial transaction agent, absolutely not.
Define your success criteria per use case.
- Support Bot: Success = Correct answer provided (Eval: LLM-as-a-Judge).
- Data Extraction: Success = Valid JSON matching schema (Eval: Pydantic validation).
- Action Agent: Success = Tool executed with correct args (Eval: Integration log check).
Automate this. If a developer changes a system prompt to be "more friendly," but the evaluation pipeline shows a 15% drop in tool-calling accuracy, the merge request should fail automatically. This shifts the culture from "let's try it and see" to "let's verify it before shipping."
Key Takeaways
- Mock the LLM, Test the Logic: Never rely on live model responses for unit or integration tests. Mock the inference to test your orchestration, state management, and tool parsing deterministically.
- Validate Boundaries Rigorously: Use strict schemas (Pydantic) for all tool inputs and outputs. Unit test these validators to prevent hallucinated arguments from reaching external APIs.
- Embrace Chaos: Inject latency, errors, and malformed outputs into your staging environment to ensure your agent degrades gracefully rather than crashing or looping infinitely.
- Eval Gates are Mandatory: Implement automated evaluation pipelines using golden datasets. Block deployments if semantic accuracy or cost metrics regress beyond defined thresholds.
Published on agentic.dev.