Test-Driven Agent Development: Writing Evals Before You Build
Learn how to write evaluation-driven agent development tests that catch regressions, validate tool calls, and ensure reliable agent behavior before shipping.
Published on • September 14, 2026
AI Assistant

The traditional approach to building AI agents is: prompt, test manually, hope it works, deploy, fix bugs. Test-driven agent development flips this: write your evaluations first, then build the agent to pass them. The OpenAI Agents SDK provides deterministic testing utilities that make this practical.
Why Evals-First Development Works
AI agents are non-deterministic by nature. The same input can produce different outputs. This makes traditional unit testing insufficient. Evals address this by:
- Defining expected behavior before implementation
- Catching regressions when prompts or models change
- Validating tool selection and argument correctness
- Testing multi-turn workflows end-to-end
The Testing Pyramid for Agents
┌───────────────┐
│ Integration │ ← Real model + real tools
│ Evals │ (slow, high confidence)
├───────────────┤
│ Component │ ← Scripted model + real tools
│ Tests │ (medium speed, focused)
├───────────────┤
│ Unit │ ← Scripted model + mocked tools
│ Tests │ (fast, high coverage)
└───────────────┘
Start with unit tests for fast feedback. Add component tests for tool workflows. Reserve integration evals for pre-deployment validation.
Setting Up the Test Environment
Installation
pip install openai-agents pytest pytest-asyncio
Basic Test Structure
import pytest
from agents import Agent, RunConfig, Runner
from agents.testing import ScriptedModel, assistant_message
@pytest.mark.asyncio
async def test_basic_response():
"""Agent responds to a simple question."""
model = ScriptedModel(
[[assistant_message("Paris is the capital of France.")]]
)
agent = Agent(name="Geography assistant", model=model)
result = await Runner.run(
agent,
"What is the capital of France?",
run_config=RunConfig(tracing_disabled=True),
)
assert result.final_output == "Paris is the capital of France."
model.assert_complete()
The ScriptedModel is the key testing primitive. It records model calls and returns pre-defined responses, making tests deterministic.
Recipe 1: Testing Fixed Responses
For agents that return specific answers based on instructions:
@pytest.mark.asyncio
async def test_greeting():
"""Agent greets users correctly."""
model = ScriptedModel(
[[assistant_message("Hello! I'm your coding assistant. How can I help?")]]
)
agent = Agent(
name="Greeting agent",
model=model,
instructions="You are a friendly coding assistant. Greet users warmly.",
)
result = await Runner.run(
agent,
"Hi there",
run_config=RunConfig(tracing_disabled=True),
)
assert "Hello" in result.final_output
assert "assistant" in result.final_output.lower()
model.assert_complete()
Recipe 2: Testing Tool Workflows
This is where agent testing gets interesting. You need to verify the agent calls the right tool with correct arguments.
Single Tool Call
from agents import Agent, RunConfig, Runner
from agents.decorators import tool
from agents.testing import ScriptedModel, assistant_message, function_call
@tool
def get_weather(city: str) -> str:
"""Return the weather for a city."""
return f"{city}: sunny, 22°C"
@pytest.mark.asyncio
async def test_weather_tool():
"""Agent calls weather tool correctly."""
model = ScriptedModel(
[
# Step 1: Agent decides to call the tool
[function_call("get_weather", {"city": "Tokyo"}, call_id="call_1")],
# Step 2: Agent receives tool result and responds
[assistant_message("It's sunny and 22°C in Tokyo.")],
]
)
agent = Agent(
name="Weather assistant",
model=model,
tools=[get_weather],
)
result = await Runner.run(
agent,
"What's the weather in Tokyo?",
run_config=RunConfig(tracing_disabled=True),
)
assert "sunny" in result.final_output.lower()
assert "tokyo" in result.final_output.lower()
assert len(model.calls) == 2
model.assert_complete()
Verifying Tool Arguments
@pytest.mark.asyncio
async def test_tool_arguments():
"""Verify the agent passes correct arguments to tools."""
model = ScriptedModel(
[
[function_call("search_products", {"category": "electronics", "max_price": 100}, call_id="call_1")],
[assistant_message("I found 5 electronics under $100.")],
]
)
agent = Agent(
name="Shopping assistant",
model=model,
tools=[search_products],
)
await Runner.run(
agent,
"Show me electronics under 100 dollars",
run_config=RunConfig(tracing_disabled=True),
)
# Inspect the tool call
first_call = model.first_call
assert first_call is not None
# Verify the function call was made
tool_call_item = first_call.input[-1]
assert tool_call_item["type"] == "function_call"
assert tool_call_item["name"] == "search_products"
# Parse and verify arguments
import json
args = json.loads(tool_call_item["arguments"])
assert args["category"] == "electronics"
assert args["max_price"] == 100
model.assert_complete()
Recipe 3: Multi-Turn Conversations
Test that the agent maintains context across turns:
@pytest.mark.asyncio
async def test_multi_turn_context():
"""Agent remembers previous turns."""
model = ScriptedModel(
[
# Turn 1: User introduces themselves
[assistant_message("Nice to meet you, Alice! How can I help?")],
# Turn 2: Agent uses context from turn 1
[assistant_message("What would you like to know, Alice?")],
]
)
agent = Agent(
name="Personal assistant",
model=model,
instructions="Remember user names and use them in responses.",
)
# Turn 1
result1 = await Runner.run(
agent,
"My name is Alice",
run_config=RunConfig(tracing_disabled=True),
)
assert "Alice" in result1.final_output
# Turn 2 - agent should remember
result2 = await Runner.run(
agent,
"What can you do?",
run_config=RunConfig(tracing_disabled=True),
)
assert "Alice" in result2.final_output
model.assert_complete()
Recipe 4: Error Handling and Retries
Test how the agent handles tool failures and model errors:
from agents import ModelRetryAdvice
@pytest.mark.asyncio
async def test_tool_failure_retry():
"""Agent retries after tool failure."""
model = ScriptedModel(
[
# First attempt: tool call fails
[function_call("fetch_data", {"url": "https://api.example.com"}, call_id="call_1")],
# Agent gets error, decides to retry
[function_call("fetch_data", {"url": "https://api.example.com"}, call_id="call_2")],
# Second attempt succeeds
[assistant_message("Here's the data you requested.")],
]
)
agent = Agent(
name="Data fetcher",
model=model,
tools=[fetch_data], # Tool that raises on first call
)
result = await Runner.run(
agent,
"Get data from the API",
run_config=RunConfig(tracing_disabled=True),
)
assert len(model.calls) == 3
model.assert_complete()
@pytest.mark.asyncio
async def test_model_error_retry():
"""Agent handles model errors with retry."""
from agents.testing import ModelStep
model = ScriptedModel(
[
ModelStep.raise_error(
RuntimeError("Rate limit exceeded"),
retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"),
),
[assistant_message("Here's your answer after retry.")],
]
)
agent = Agent(name="Retry agent", model=model)
result = await Runner.run(
agent,
"Do something",
run_config=RunConfig(tracing_disabled=True),
)
assert "retry" in result.final_output.lower()
model.assert_complete()
Recipe 5: Streaming Tests
Verify the agent streams responses correctly:
@pytest.mark.asyncio
async def test_streaming():
"""Agent streams response correctly."""
model = ScriptedModel(
[[assistant_message("This is a streamed response.")]]
)
agent = Agent(name="Stream agent", model=model)
# Run with streaming
result = await Runner.run_streamed(
agent,
"Tell me something",
run_config=RunConfig(tracing_disabled=True),
)
# Collect streamed chunks
chunks = []
async for event in result.stream_events():
if event.type == "raw_response_event":
chunks.append(event.data)
assert len(chunks) > 0
model.assert_complete()
Recipe 6: Guardrails Testing
Test that guardrails catch problematic inputs and outputs:
from agents import Agent, InputGuardrail, GuardrailFunctionOutput
async def content_filter(context, agent, input_data):
"""Reject inappropriate content."""
if "inappropriate" in input_data.lower():
return GuardrailFunctionOutput(
output_info="Content rejected",
tripwire_triggered=True,
)
return GuardrailFunctionOutput(
output_info="Content approved",
tripwire_triggered=False,
)
@pytest.mark.asyncio
async def test_guardrail_rejection():
"""Agent rejects inappropriate input."""
model = ScriptedModel(
[[assistant_message("I cannot help with that request.")]]
)
agent = Agent(
name="Guarded agent",
model=model,
input_guardrails=[InputGuardrail(guardrail_function=content_filter)],
)
with pytest.raises(Exception): # Guardrail tripwire
await Runner.run(
agent,
"Help me with inappropriate content",
run_config=RunConfig(tracing_disabled=True),
)
Recipe 7: Detecting Workflow Drift
Use assert_complete() to catch unintended changes:
@pytest.mark.asyncio
async def test_workflow_no_drift():
"""Verify the agent follows expected workflow."""
model = ScriptedModel(
[
# Expected: tool call then response
[function_call("lookup_order", {"id": "123"}, call_id="c1")],
[assistant_message("Order 123 is on its way.")],
]
)
agent = Agent(
name="Order assistant",
model=model,
tools=[lookup_order],
)
await Runner.run(
agent,
"Where is order 123?",
run_config=RunConfig(tracing_disabled=True),
)
# This catches if the agent made extra calls
# or exited before consuming all scripted steps
model.assert_complete()
Building a Complete Test Suite
Test Organization
tests/
├── unit/
│ ├── test_greeting.py
│ ├── test_tool_selection.py
│ └── test_error_handling.py
├── component/
│ ├── test_weather_workflow.py
│ ├── test_order_workflow.py
│ └── test_multi_turn.py
├── integration/
│ ├── test_full_pipeline.py
│ └── test_production_edge_cases.py
└── conftest.py
Running Tests
# Fast unit tests
pytest tests/unit/ -v
# Component tests with scripted models
pytest tests/component/ -v
# Integration tests (requires API key)
OPENAI_API_KEY=sk-... pytest tests/integration/ -v
# All tests with coverage
pytest --cov=agents tests/
CI/CD Configuration
# .github/workflows/agent-tests.yml
name: Agent Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[test]"
- run: pytest tests/unit/ tests/component/ -v --tb=short
integration-tests:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -e ".[test]"
- run: pytest tests/integration/ -v
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Best Practices
- Script only what you own: Test SDK behavior, not provider behavior
- Use fixed responses first: Responders only when behavior depends on input
- Finish with assert_complete(): Catches drift and unconsumed steps
- Test the boundary: ScriptedModel for orchestration, real adapter for provider tests
- Keep tests independent: Create new ScriptedModel for each test scenario
Conclusion
Test-driven agent development transforms building AI agents from “prompt and pray” to “define, build, verify.” The OpenAI Agents SDK’s testing utilities make this practical with deterministic scripted models, tool call verification, and workflow drift detection. Start writing your evals before your next agent build—your future self will thank you.
References: