Agent Testing Strategies
Why This Matters
AI agents are fundamentally different from traditional software: their outputs are non-deterministic, their behavior depends on natural language understanding, and failures can manifest as subtly wrong answers rather than crashes. A chatbot that confidently gives incorrect medical advice "works" from a systems perspective but fails its purpose. Testing strategies must account for this non-determinism, validate semantic correctness (not just exact matches), and ensure agents behave safely under adversarial inputs.
Real-World Analogy
Testing an AI agent is like testing a self-driving car. Unit tests verify individual sensors work. Integration tests check that the camera, lidar, and GPS data merge correctly. E2E tests drive the car through real scenarios. But you also need adversarial testing (what if someone puts a sticker on a stop sign?), statistical testing (does it work 99.99% of the time, not just once?), and regression testing (did the latest update break left turns?).
Agent Test Framework
import asyncio
import time
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from enum import Enum
logger = logging.getLogger(__name__)
class TestStatus(Enum):
PASS = "pass"
FAIL = "fail"
SKIP = "skip"
ERROR = "error"
@dataclass(frozen=True)
class TestCase:
name: str
input_data: Any
expected_output: Any
description: str = ""
tags: tuple[str, ...] = ()
timeout: float = 30.0
@dataclass
class TestResult:
test_case: TestCase
status: TestStatus
actual_output: Any = None
error: Optional[str] = None
execution_time: float = 0.0
assertions_passed: int = 0
assertions_total: int = 0
class AgentTestSuite:
def __init__(self, name: str) -> None:
self.name = name
self._cases: list[TestCase] = []
self._results: list[TestResult] = []
self._setup: Optional[Callable] = None
self._teardown: Optional[Callable] = None
def add(self, case: TestCase) -> None:
self._cases.append(case)
def setup(self, func: Callable) -> Callable:
self._setup = func
return func
def teardown(self, func: Callable) -> Callable:
self._teardown = func
return func
async def run_one(self, case: TestCase, test_fn: Callable) -> TestResult:
start = time.monotonic()
try:
if self._setup:
await self._setup()
actual = await asyncio.wait_for(
test_fn(case.input_data), timeout=case.timeout,
)
passed, total = self._check(actual, case.expected_output)
status = TestStatus.PASS if passed == total else TestStatus.FAIL
return TestResult(
test_case=case, status=status, actual_output=actual,
execution_time=time.monotonic() - start,
assertions_passed=passed, assertions_total=total,
)
except asyncio.TimeoutError:
return TestResult(test_case=case, status=TestStatus.ERROR,
error="Timeout", execution_time=time.monotonic() - start)
except Exception as exc:
return TestResult(test_case=case, status=TestStatus.ERROR,
error=str(exc), execution_time=time.monotonic() - start)
finally:
if self._teardown:
await self._teardown()
async def run_all(self, test_fn: Callable) -> list[TestResult]:
self._results = []
for case in self._cases:
result = await self.run_one(case, test_fn)
self._results.append(result)
logger.info("[%s] %s: %s (%.3fs)",
self.name, case.name, result.status.value, result.execution_time)
return self._results
def summary(self) -> dict[str, Any]:
total = len(self._results)
passed = sum(1 for r in self._results if r.status == TestStatus.PASS)
failed = sum(1 for r in self._results if r.status == TestStatus.FAIL)
errors = sum(1 for r in self._results if r.status == TestStatus.ERROR)
return {
"total": total, "passed": passed, "failed": failed, "errors": errors,
"pass_rate": passed / total if total else 0.0,
"avg_time": sum(r.execution_time for r in self._results) / total if total else 0.0,
}
@staticmethod
def _check(actual: Any, expected: Any) -> tuple[int, int]:
if isinstance(expected, dict) and "__assertions__" in expected:
assertions = expected["__assertions__"]
passed = sum(
1 for a in assertions
if (a["type"] == "equals" and actual == a["value"])
or (a["type"] == "contains" and a["value"] in str(actual))
or (a["type"] == "type" and type(actual).__name__ == a["value"])
)
return passed, len(assertions)
return (1, 1) if actual == expected else (0, 1)
Mock LLM Client
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any, Optional
from enum import Enum
logger = logging.getLogger(__name__)
class MockBehavior(Enum):
FIXED = "fixed"
SEQUENTIAL = "sequential"
CONDITIONAL = "conditional"
class MockLLMClient:
def __init__(self, behavior: MockBehavior = MockBehavior.FIXED) -> None:
self._behavior = behavior
self._responses: list[str] = []
self._conditions: dict[str, str] = {}
self._index = 0
self.call_count = 0
self.call_history: list[dict[str, Any]] = []
self.total_tokens = 0
self.latency = 0.05
def set_fixed(self, response: str) -> None:
self._responses = [response]
self._behavior = MockBehavior.FIXED
def set_sequential(self, responses: list[str]) -> None:
self._responses = responses
self._behavior = MockBehavior.SEQUENTIAL
def set_conditional(self, conditions: dict[str, str]) -> None:
self._conditions = conditions
self._behavior = MockBehavior.CONDITIONAL
async def complete(self, prompt: str, **kwargs: Any) -> str:
self.call_count += 1
self.call_history.append({"prompt": prompt, **kwargs})
await asyncio.sleep(self.latency)
if self._behavior == MockBehavior.FIXED:
return self._responses[0]
elif self._behavior == MockBehavior.SEQUENTIAL:
resp = self._responses[self._index % len(self._responses)]
self._index += 1
return resp
else:
for cond, resp in self._conditions.items():
if cond.lower() in prompt.lower():
return resp
return "default response"
def stats(self) -> dict[str, Any]:
return {
"calls": self.call_count,
"tokens": self.total_tokens,
"avg_tokens": self.total_tokens / self.call_count if self.call_count else 0,
}
def reset(self) -> None:
self.call_count = 0
self.call_history.clear()
self.total_tokens = 0
self._index = 0
Evaluation Framework
import asyncio
import time
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EvalMetric:
name: str
compute_fn: Callable[[Any, Any], float]
weight: float = 1.0
@dataclass
class EvalResult:
metric_name: str
score: float
details: dict
computation_time: float
class AgentEvaluator:
def __init__(self) -> None:
self._metrics: list[EvalMetric] = []
self._results: list[EvalResult] = []
def add_metric(self, metric: EvalMetric) -> None:
self._metrics.append(metric)
async def evaluate(
self, agent_fn: Callable, test_data: list[dict[str, Any]],
) -> dict[str, Any]:
self._results = []
for metric in self._metrics:
start = time.monotonic()
scores = []
for item in test_data:
try:
prediction = await agent_fn(item["input"])
scores.append(metric.compute_fn(prediction, item["expected"]))
except Exception:
scores.append(0.0)
avg = sum(scores) / len(scores) if scores else 0.0
self._results.append(EvalResult(
metric_name=metric.name, score=avg,
details={"samples": len(test_data), "scores": scores},
computation_time=time.monotonic() - start,
))
return self._aggregate()
def _aggregate(self) -> dict[str, Any]:
total_weight = sum(m.weight for m in self._metrics)
weighted = sum(
r.score * next(m.weight for m in self._metrics if m.name == r.metric_name)
for r in self._results
)
return {
"overall_score": weighted / total_weight if total_weight else 0.0,
"metrics": {r.metric_name: {"score": r.score, "details": r.details} for r in self._results},
"total_time": sum(r.computation_time for r in self._results),
}
class MetricComputer:
@staticmethod
def accuracy(predictions: list[Any], expected: list[Any]) -> float:
correct = sum(1 for p, e in zip(predictions, expected) if p == e)
return correct / len(predictions) if predictions else 0.0
@staticmethod
def f1(predictions: list[str], expected: list[str], positive: str = "positive") -> float:
tp = sum(1 for p, e in zip(predictions, expected) if p == positive and e == positive)
fp = sum(1 for p, e in zip(predictions, expected) if p == positive and e != positive)
fn = sum(1 for p, e in zip(predictions, expected) if p != positive and e == positive)
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
return 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
@staticmethod
def semantic_similarity(text_a: str, text_b: str) -> float:
words_a = set(text_a.lower().split())
words_b = set(text_b.lower().split())
intersection = words_a & words_b
union = words_a | words_b
return len(intersection) / len(union) if union else 0.0
Test Data Generator
import random
import string
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class TestDataGenerator:
seed: Optional[int] = None
def __post_init__(self) -> None:
if self.seed is not None:
random.seed(self.seed)
def text_samples(self, n: int, min_len: int = 10, max_len: int = 500) -> list[str]:
return [
"".join(random.choices(string.ascii_letters + " ", k=random.randint(min_len, max_len)))
for _ in range(n)
]
def qa_pairs(self, n: int, questions: list[str], answers: list[str]) -> list[dict]:
return [
{"question": random.choice(questions), "expected_answer": random.choice(answers)}
for _ in range(n)
]
def edge_cases(self) -> list[dict[str, str]]:
return [
{"input": "", "type": "empty"},
{"input": " " * 500, "type": "whitespace"},
{"input": "a" * 10000, "type": "very_long"},
{"input": "<script>alert('xss')</script>", "type": "xss"},
{"input": "'; DROP TABLE users;--", "type": "sql_injection"},
{"input": "测试中文", "type": "unicode"},
{"input": "null", "type": "null_string"},
{"input": "undefined", "type": "undefined_string"},
]
Mathematical Foundations
Test Coverage:
Mutation Score (measures test quality):
F1 Score:
BLEU Score (for generation evaluation):
where BP is the brevity penalty and are modified n-gram precisions.
Performance Considerations
| Test Type | Latency | Cost | Coverage |
|---|---|---|---|
| Unit tests (mocked LLM) | <100ms | Negligible | Individual components |
| Integration tests | 1-5s | Low-Medium | Component interactions |
| E2E tests (real LLM) | 5-30s | Medium-High | Full workflow |
| Adversarial testing | 10-60s | High | Security edge cases |
| Load/performance tests | Minutes | High | Scalability limits |
Security Considerations
- Never use real credentials in tests. Use mock services and environment-specific test configs.
- Test adversarial inputs systematically: prompt injections, XSS payloads, SQL injection strings, and Unicode edge cases.
- Isolate test environments from production. Use separate databases, API keys, and network segments.
- Secure test data: if tests use real user data, it must be anonymized and encrypted at rest.
Common Pitfalls
| Pitfall | Consequence | Solution |
|---|---|---|
| Testing only happy paths | Miss critical failures | Include edge cases and error scenarios |
| Ignoring non-determinism | Flaky tests | Use statistical testing and fuzzy matching |
| Over-relying on E2E tests | Slow, expensive suites | Balance with unit and integration tests |
| Not mocking LLM calls | Non-deterministic results, API costs | Use MockLLMClient for unit tests |
| Static test data | Tests become stale | Use synthetic data generators |
| Ignoring performance | Production surprises | Include latency and throughput tests |
| Manual test execution | Slow feedback loops | Automate in CI/CD pipelines |
| No test monitoring | Unclear test health | Track flakiness and pass rates over time |
Interview Q&A
1. What is the testing pyramid for AI agents?
The testing pyramid recommends: Unit tests (70%) for individual components like parsers, validators, and prompt templates—these are fast, cheap, and deterministic. Integration tests (20%) for component interactions: agent-tool integration, LLM API contracts, and data pipelines. E2E tests (10%) for complete workflows with real LLMs—these validate end-to-end behavior but are slow and expensive. The key insight is that lower-level tests provide faster feedback and catch more bugs per dollar spent.
2. How do you test non-deterministic LLM outputs?
Testing non-deterministic outputs requires: (1) semantic similarity checks using embedding distance or Jaccard similarity instead of exact matching, (2) running multiple iterations and analyzing statistical distributions, (3) property-based testing for invariant properties (e.g., output must be valid JSON, must contain certain keywords), (4) fuzzy matching with configurable thresholds, (5) mock LLMs that return fixed responses for deterministic unit tests, and (6) golden datasets with acceptable output ranges.
3. What metrics should you use to evaluate agent performance?
Key metrics: (1) Correctness—accuracy, precision, recall, F1 for classification tasks; BLEU/ROUGE for generation. (2) Robustness—adversarial testing, edge case handling, degradation under noisy input. (3) Efficiency—latency (p50, p95, p99), tokens per request, cost per task. (4) Consistency—variance across multiple runs, determinism with same input. (5) Safety—content filtering effectiveness, injection resistance. (6) User satisfaction—task completion rate, response quality ratings. Weight metrics based on use case priorities.
4. How do you implement contract testing for agent APIs?
Contract testing: (1) define API contracts using OpenAPI specs for tool endpoints, (2) generate mock servers from contracts, (3) test both provider (tool server) and consumer (agent) against contracts, (4) verify request/response schemas match, (5) test error handling and edge cases. Use tools like Pact or Schemathesis for property-based contract testing. For LLM APIs, define expected response formats and validate that agent code handles all documented response types correctly.
5. What is the role of chaos testing in agent systems?
Chaos testing validates resilience by: (1) injecting failures—network timeouts, DNS failures, disk exhaustion, (2) simulating external service outages (LLM API down, tool servers unreachable), (3) testing recovery mechanisms and retry logic, (4) verifying graceful degradation (fallback responses when primary LLM fails), (5) measuring MTTR (Mean Time To Recovery). For agents specifically, test LLM API rate limiting, tool timeout handling, memory exhaustion during long conversations, and cascading failures in multi-agent systems.
6. How do you test agent safety and security?
Safety testing includes: (1) prompt injection testing with known attack patterns and variations, (2) data leakage detection using PII test cases in both inputs and outputs, (3) output validation for harmful, biased, or policy-violating content, (4) access control verification—ensure agents can't use unauthorized tools, (5) rate limiting effectiveness under abuse scenarios, (6) sandboxing boundary testing—attempt to escape confinement. Use red-team approaches, automated adversarial testing, and maintain an updated attack pattern library.
7. How do you create effective test data for agents?
Test data strategies: (1) synthetic data generators with controlled distributions for reproducibility, (2) real-world data with proper anonymization and consent, (3) adversarial examples generated using techniques like TextFooler or BERT-Attack, (4) diverse input formats including multilingual, domain-specific, and edge cases, (5) golden datasets with human-annotated expected outputs, (6) version-controlled test data with regression tracking. Update test data regularly as the agent evolves and new failure modes are discovered.
8. What CI/CD practices work best for agent testing?
CI/CD best practices: (1) fast unit tests on every commit (under 5 minutes), (2) integration tests on PR merge to main, (3) E2E tests nightly or on release candidates, (4) parallelized test execution for speed, (5) quality gates that block deployment on test failures, (6) test flakiness tracking with automatic quarantine of flaky tests, (7) feature flags for gradual rollout of agent changes, (8) automated rollback triggered by production monitoring anomalies, (9) separate CI pipelines for model changes vs. code changes.
KnowledgeCheck
-
What is the recommended distribution for the agent testing pyramid?
- a) 50/30/20
- b) 70/20/10
- c) 80/15/5
- d) 60/25/15
-
Why use mock LLM clients in unit testing?
- a) To reduce API costs only
- b) To achieve deterministic, reproducible results
- c) To improve LLM response quality
- d) To simplify code structure
-
What metric measures the percentage of killed mutations?
- a) Test coverage
- b) Mutation score
- c) F1 score
- d) BLEU score
-
What is contract testing used for in agent systems?
- a) Testing individual functions
- b) Verifying API compatibility between components
- c) Performance benchmarking
- d) Security vulnerability scanning
-
What should chaos testing simulate?
- a) Happy path scenarios only
- b) System failures and external service outages
- c) Normal user behavior patterns
- d) Code formatting issues
-
How should non-deterministic outputs be evaluated?
- a) Exact string matching
- b) Semantic similarity and statistical analysis
- c) Manual human review only
- d) Random sampling without metrics
Answers: 1-b, 2-b, 3-b, 4-b, 5-b, 6-b