Testing
The SDK provides deterministic, provider-neutral testing utilities for Agent workflows, Sandbox sessions, Realtime sessions, and Voice pipelines. These utilities run in memory, make no model, sandbox-provider, or Realtime API requests, and record the normalized interactions that the SDK owns. The runnable recipes below disable tracing for each run so that the default trace processor does not upload test activity when an OpenAI API key is configured.
Use them to test orchestration owned by your application and the SDK: tool execution, handoffs, guardrails, retries, streaming, session behavior, Sandbox capabilities, Realtime event handling, and Voice pipeline composition. Use real provider adapters or integration environments for behavior owned by an external model, network protocol, sandbox provider, or audio system.
Find the recipe you need
| I want to... | Use | Go to |
|---|---|---|
| Return a fixed final answer | ScriptedModel with assistant_message() |
Return a fixed response |
| Exercise a multi-turn tool loop | function_call() followed by an assistant response |
Test a tool workflow |
| Choose a response from the request | ModelStep.respond() or a responder mapping |
Derive a response from the request |
| Assert what the runner sent to the model | calls, first_call, or last_call |
Inspect model calls |
| Test a streamed run | A normal response step, or ModelStep.stream() for exact events |
Test streaming |
| Test an error or retry decision | ModelStep.raise_error() |
Inject model failures |
| Detect an accidental workflow change | Exact FIFO steps plus assert_complete() |
Detect workflow drift |
Test a SandboxAgent without starting a sandbox |
scripted_sandbox_session() plus ScriptedModel |
Test a Sandbox Agent workflow |
| Match Sandbox calls or derive their results | match or responder on a Sandbox step |
Configure Sandbox steps |
| Test a Realtime session without opening a connection | ScriptedRealtimeModel and RealtimeStep |
Test a Realtime session |
| Test a Realtime tool workflow | Emit a RealtimeModelToolCallEvent and expect tool output |
Test a Realtime tool workflow |
| Test a static or streamed Voice pipeline | ScriptedSTTModel, ScriptedTTSModel, and a scripted or real workflow |
Test a Voice pipeline |
| Test provider serialization or wire payloads | The real provider adapter with a controlled network transport | Choose the correct boundary |
Imports
The testing APIs live next to the runtime boundary they replace:
| Boundary | Import path |
|---|---|
| Agent model and Sandbox workflows | agents.testing |
| Realtime model transport | agents.realtime.testing |
| Voice STT, TTS, and workflow components | agents.voice.testing |
Testing symbols are intentionally kept out of the top-level agents import.
Agent workflow recipes
Return a fixed response
Pass one sequence of normalized output items for each expected model call. The output-sequence shorthand receives a deterministic response ID and usage for one request.
import pytest
from agents import Agent, RunConfig, Runner
from agents.testing import ScriptedModel, assistant_message
@pytest.mark.asyncio
async def test_fixed_response() -> None:
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."
assert len(model.calls) == 1
model.assert_complete()
Finish deterministic workflow tests with model.assert_complete(). It catches the case where the workflow stopped before consuming every configured step.
Test a tool workflow
Script one model response that calls the tool and a second response that produces the final answer. The real SDK tool pipeline runs between those model calls.
import pytest
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"
@pytest.mark.asyncio
async def test_tool_workflow() -> None:
model = ScriptedModel(
[
[function_call("get_weather", {"city": "Tokyo"}, call_id="call_1")],
[assistant_message("It is sunny in Tokyo.")],
]
)
agent = Agent(name="Weather assistant", model=model, tools=[get_weather])
result = await Runner.run(
agent,
"What is the weather in Tokyo?",
run_config=RunConfig(tracing_disabled=True),
)
assert result.final_output == "It is sunny in Tokyo."
assert len(model.calls) == 2
assert model.last_call is not None
assert any(
item.get("type") == "function_call_output"
for item in model.last_call.input
)
model.assert_complete()
This pattern covers tool input validation, execution, result conversion, hooks, guardrails, and the next model turn. Calling the Python function directly would bypass those SDK behaviors.
Derive a response from the request
Use ModelStep.respond() when a response genuinely depends on the normalized model call or when an assertion belongs at the model boundary. The responder may be synchronous or asynchronous and may return any step shape accepted by ScriptedModel.
import pytest
from agents import Agent, RunConfig, Runner
from agents.testing import ModelCall, ModelStep, ScriptedModel, assistant_message
def respond(call: ModelCall):
assert call.streamed is False
assert call.input == [{"content": "Summarize this", "role": "user"}]
return {"output": [assistant_message("Handled the normalized request.")]}
@pytest.mark.asyncio
async def test_request_aware_response() -> None:
model = ScriptedModel([ModelStep.respond(respond)])
agent = Agent(name="Assistant", model=model)
result = await Runner.run(
agent,
"Summarize this",
run_config=RunConfig(tracing_disabled=True),
)
assert result.final_output == "Handled the normalized request."
model.assert_complete()
ScriptedModel accepts ModelStep, the equivalent dictionary form, ModelResponse, a normalized output-item sequence, or an exception. Prefer fixed output sequences when a response does not depend on the call because fixed scripts make unexpected turns easier to diagnose.
Inspect model calls
ScriptedModel records each call before it resolves or raises the selected step.
| Member | Contains |
|---|---|
calls |
Every ModelCall in invocation order |
first_call |
The first call, or None |
last_call |
The most recent call, or None |
remaining_steps |
The number of configured steps not yet consumed |
Common assertions include call.input, call.model_settings, call.tools, call.handoffs, and call.streamed. Mutable request data is snapshotted at the invocation boundary, and each public history accessor returns detached snapshots. Tool, handoff, output-schema, and tracing objects keep their runtime identity.
Structured call_index and input_index error fields are zero-based so they directly index calls[...] or the supplied step sequence. Human-readable error messages display one-based call or step numbers.
Use enqueue() or extend() when one test needs to append model steps incrementally. Create a new ScriptedModel for an independent scenario; the utility does not reset consumed steps or call history.
Test streaming
A normal response step supports both Runner.run() and Runner.run_streamed(). For common assistant messages, reasoning items, function calls, and apply-patch calls, ScriptedModel generates normalized start, delta, item-completion, and terminal response events. The terminal response carries the complete output and usage.
Use ModelStep.stream() only when the exact normalized TResponseStreamEvent sequence is part of the behavior under test:
step = ModelStep.stream(
events,
output=[assistant_message("The terminal output used by the runner.")],
)
events may be a fixed sequence or an async factory that receives the recorded ModelCall. The optional output is the response returned if the same step is used in a non-streaming call. Exact stream events are SDK-normalized events, not Responses API or Chat Completions wire chunks.
Automatic streaming rejects normalized output-item kinds whose incremental lifecycle is not implemented. Use ModelStep.stream(...) for those items instead of relying on a partial event sequence.
Inject model failures
Use ModelStep.raise_error() to fail one model call. Optional retry advice belongs to that exact scripted error:
from agents import ModelRetryAdvice
from agents.testing import ModelStep
step = ModelStep.raise_error(
RuntimeError("temporary failure"),
retry_advice=ModelRetryAdvice(suggested=True, replay_safety="safe"),
)
The runner's retry policy decides whether advice causes another attempt. Each retry is another model call and consumes the next scripted step. The Python helper accepts a fixed ModelRetryAdvice value; use a custom Model when retry advice itself must vary dynamically by attempt.
Detect workflow drift
Treat the scripted calls as the expected workflow shape. An extra model request raises UnexpectedModelCall; an early exit leaves steps for assert_complete() to report.
When your test framework supports teardown or finalizers, place assert_complete() there if you also want unconsumed steps reported after another assertion fails. Do not catch mismatch errors in a normal regression test.
| Error | Structured fields | Meaning |
|---|---|---|
InvalidModelStep |
reason, input_index |
A step is malformed and is rejected before entering the queue |
UnexpectedModelCall |
call, call_index |
The workflow made another model call after the script ended |
UnconsumedModelSteps |
remaining_steps |
The workflow ended before using every step |
Sandbox Agent recipes
Test a Sandbox Agent workflow
Combine ScriptedModel with scripted_sandbox_session() to exercise the real SandboxAgent runtime without creating a local container or remote sandbox. The model script chooses a capability tool, while the Sandbox script defines what the corresponding SandboxSession method returns.
import pytest
from agents import RunConfig, Runner
from agents.sandbox import ExecResult, SandboxAgent
from agents.sandbox.capabilities import Shell
from agents.testing import (
ScriptedModel,
assistant_message,
function_call,
scripted_sandbox_session,
)
@pytest.mark.asyncio
async def test_sandbox_workflow() -> None:
sandbox = scripted_sandbox_session(
[
{
"method": "exec",
"match": lambda call: call.args == ("pwd",),
"result": ExecResult(
stdout=b"/workspace\n",
stderr=b"",
exit_code=0,
),
}
]
)
model = ScriptedModel(
[
[function_call("exec_command", {"cmd": "pwd"}, call_id="call_1")],
[assistant_message("The workspace is /workspace.")],
]
)
agent = SandboxAgent(
name="Workspace assistant",
model=model,
capabilities=[Shell()],
)
async with sandbox:
result = await Runner.run(
agent,
"Which directory are you in?",
run_config=RunConfig(
sandbox={"session": sandbox},
tracing_disabled=True,
),
)
assert result.final_output == "The workspace is /workspace."
assert [call.method for call in sandbox.calls] == ["exec"]
sandbox.assert_complete()
model.assert_complete()
This test crosses two normalized SDK boundaries. It covers tool argument validation, capability routing, Sandbox session invocation, delivery of the tool result to the next model turn, and final output handling. It does not test whether a real model chooses the command or how a real sandbox provider executes it.
Configure Sandbox steps
Each matching Sandbox call consumes the next step in one global FIFO sequence. A method mismatch, matcher rejection, or matcher exception leaves that step pending. Set method, choose exactly one outcome, and add match only when the call details matter.
| Step member | Use it when... |
|---|---|
result |
The method should return a fixed typed value |
responder |
The result depends on the detached SandboxCall |
error |
The method should raise a specific exception |
match |
The call should be rejected before producing its outcome unless the matcher returns a value other than False |
The supported scripted method names are apply_patch, exec, ls, mkdir, pty_exec_start, pty_write_stdin, read, rm, and write. Only configured model-facing capabilities are exposed. The two PTY methods are exposed together when either PTY method is configured because they form one interactive-shell capability, but calls still consume the global FIFO script.
sandbox.calls contains detached SandboxCall snapshots with zero-based call_index, method, positional args, and read-only kwargs. Static results are also snapshotted when the script is created. io.BytesIO and io.StringIO values are supported; use a custom Sandbox session for other live stream objects or lifecycle behavior.
| Error | Structured fields | Meaning |
|---|---|---|
InvalidSandboxStep |
reason, input_index, method |
A step is malformed or names an unsupported method |
UnexpectedSandboxCall |
call, call_index, actual_method, expected_method, remaining_steps |
The workflow called the wrong method or continued after the script ended |
SandboxCallMatcherError |
call, call_index, method |
A step matcher returned False |
UnconsumedSandboxSteps |
remaining_steps, pending_methods |
The workflow ended before using every step |
The returned object is the session itself. Pass it directly to RunConfig(sandbox={"session": sandbox}); there is no wrapper .session attribute.
Realtime recipes
Test a Realtime session
ScriptedRealtimeModel implements the Python SDK's normalized RealtimeModel boundary. Each RealtimeStep matches one outbound RealtimeModelSendEvent and then emits normalized inbound RealtimeModelEvent objects or raises an injected error.
import pytest
from agents.realtime import (
RealtimeAgent,
RealtimeModelOutputTextDeltaEvent,
RealtimeModelSendUserInput,
RealtimeRawModelEvent,
RealtimeRunner,
)
from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel
@pytest.mark.asyncio
async def test_realtime_message() -> None:
reply = RealtimeModelOutputTextDeltaEvent(
item_id="item_1",
delta="Hello!",
response_id="response_1",
)
model = ScriptedRealtimeModel(
[
RealtimeStep(
expect=RealtimeModelSendUserInput(user_input="Hello"),
emit=[reply],
)
]
)
runner = RealtimeRunner(
RealtimeAgent(name="Assistant"),
model=model,
config={"tracing_disabled": True},
)
observed_reply = False
async with await runner.run() as session:
await session.send_message("Hello")
async for event in session:
if isinstance(event, RealtimeRawModelEvent) and event.data == reply:
observed_reply = True
break
assert observed_reply
assert model.sent_events == (RealtimeModelSendUserInput(user_input="Hello"),)
assert model.closed is True
model.assert_complete()
An expectation may be an exact event value, an event class matched with isinstance, or a callable that receives the outbound event and returns True for a match. Strict mode is enabled by default. With strict=False, unrelated outbound events are recorded but do not consume a pending step; this is useful when a session emits incidental events that are outside the behavior under test.
Use connect_events to emit inbound events during connection. Use connect_error or close_error for lifecycle failures, and use RealtimeStep(error=...) for a failure tied to one matched send. A step cannot define both emit and error.
Test a Realtime tool workflow
Attach a real function tool to RealtimeAgent, emit a normalized tool call, and expect the SDK to send the tool output through the model boundary. Setting async_tool_calls to False makes this small example complete during connection without test-specific waiting machinery.
import pytest
from agents.decorators import tool
from agents.realtime import (
RealtimeAgent,
RealtimeModelSendToolOutput,
RealtimeModelToolCallEvent,
RealtimeRunner,
)
from agents.realtime.testing import RealtimeStep, ScriptedRealtimeModel
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order by ID."""
return f"Order {order_id} has shipped."
@pytest.mark.asyncio
async def test_realtime_tool_workflow() -> None:
tool_call = RealtimeModelToolCallEvent(
name="lookup_order",
call_id="call_1",
arguments='{"order_id":"order_123"}',
)
def matches_tool_output(event) -> bool:
return (
isinstance(event, RealtimeModelSendToolOutput)
and event.tool_call.call_id == "call_1"
and event.output == "Order order_123 has shipped."
)
model = ScriptedRealtimeModel(
[RealtimeStep(expect=matches_tool_output)],
connect_events=[tool_call],
)
agent = RealtimeAgent(
name="Order assistant",
tools=[lookup_order],
)
runner = RealtimeRunner(
agent,
model=model,
config={"async_tool_calls": False, "tracing_disabled": True},
)
async with await runner.run():
pass
model.assert_complete()
This exercises the real Realtime tool lookup, argument validation, execution, and output routing. It does not prove that a real model will choose the tool.
Inspect Realtime calls and lifecycle
| Member | Contains |
|---|---|
connect_calls |
Credential-free, detached connection snapshots |
sent_events |
Detached outbound event snapshots in invocation order |
remaining_steps |
Expected outbound sends that remain |
listeners |
Currently registered listener objects |
connected, closed, close_calls |
Current in-memory lifecycle state |
Connection history records only whether API-key or header fields were supplied; it never stores their values. URL snapshots remove user information, query parameters, and fragments. Mutable event data and settings are detached, while live SDK objects such as tools, handoffs, and playback trackers preserve identity.
Finish with model.assert_complete() and let the RealtimeSession async context manager close the model. The Python utility intentionally does not provide pending expectation promises, implicit timeouts, or a separate assert_closed() helper.
| Error | Structured fields | Meaning |
|---|---|---|
UnexpectedRealtimeSend |
actual, expected |
A strict outbound send did not match the next step, or no step remained |
UnconsumedRealtimeSteps |
remaining_steps |
The session ended before using every expected send |
RealtimeScriptError |
none | The script was used in an invalid lifecycle state, such as sending while disconnected |
Voice pipeline recipes
Test a Voice pipeline
Combine scripted STT and TTS models with SingleAgentVoiceWorkflow and an Agent backed by ScriptedModel to test the full speech-to-text -> Agent -> text-to-speech pipeline without provider requests.
import numpy as np
import pytest
from agents import Agent
from agents.testing import ScriptedModel, assistant_message
from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline
from agents.voice.testing import (
ScriptedSTTModel,
ScriptedTTSModel,
TTSResult,
pcm16_samples,
)
@pytest.mark.asyncio
async def test_voice_pipeline() -> None:
model = ScriptedModel([[assistant_message("Hello there.")]])
stt = ScriptedSTTModel("hello")
pcm = pcm16_samples([0, 100, -100, 0])
tts = ScriptedTTSModel([TTSResult([pcm])])
pipeline = VoicePipeline(
workflow=SingleAgentVoiceWorkflow(
Agent(name="Voice assistant", model=model)
),
stt_model=stt,
tts_model=tts,
config={"tracing_disabled": True, "tts_settings": {"buffer_size": 1}},
)
result = await pipeline.run(AudioInput(np.zeros(2, dtype=np.int16)))
events = [event async for event in result.stream()]
assert events
assert [call.text for call in tts.calls] == ["Hello there."]
stt.assert_complete()
tts.assert_complete()
model.assert_complete()
Use ScriptedVoiceWorkflow instead when the pipeline's STT/TTS lifecycle is under test but Agent orchestration is not:
from agents.voice.testing import ScriptedVoiceWorkflow
workflow = ScriptedVoiceWorkflow(
turns=["Hello there."],
start="Welcome.",
)
The start step is consumed by on_start(). VoicePipeline calls on_start() only for StreamedAudioInput; a static AudioInput run does not consume start. Each normal turn records its transcription and consumes one configured result. A string is one fragment; a sequence of strings controls fragment boundaries before text splitting and TTS.
Test streamed transcription
ScriptedSTTModel accepts static transcriptions and independently scripted streamed sessions. A session may be a ScriptedTranscriptionSession, a sequence of transcription turns, an exception, or a single string:
from agents.voice.testing import ScriptedSTTModel, ScriptedTranscriptionSession
session = ScriptedTranscriptionSession(["first turn", "second turn"])
stt = ScriptedSTTModel(sessions=[session])
Closing ScriptedTranscriptionSession stops iteration and leaves skipped turns for assert_complete() to report. ScriptedTTSModel similarly consumes one TTSResult, byte-chunk sequence, or exception per call.
Inspect Voice calls
| Component | Recorded history |
|---|---|
ScriptedSTTModel |
calls, session_calls, and live created_sessions identities |
ScriptedTTSModel |
calls containing text and detached settings |
ScriptedVoiceWorkflow |
transcriptions in turn order |
Static audio buffers and mutable settings are snapshotted at invocation time. A StreamedAudioInput and created transcription-session objects keep their live identity because the pipeline continues to use them.
| Error | Structured fields | Meaning |
|---|---|---|
UnexpectedVoiceCall |
operation |
A static transcription, streamed session, TTS call, workflow start, or workflow turn had no configured step |
UnconsumedVoiceSteps |
remaining_steps |
One or more configured Voice steps remain |
Call assert_complete() on every scripted Voice component that the test configures. ScriptedSTTModel.assert_complete() also checks turns in the transcription sessions that it created.
Choose the correct boundary
Use ScriptedModel when a test should exercise the SDK run loop, tools, handoffs, guardrails, sessions, retries, or normalized streaming without depending on a model provider.
Use scripted_sandbox_session() with ScriptedModel when a test should exercise SandboxAgent capabilities and orchestration without starting a sandbox provider. Keep provider creation, process execution, filesystem fidelity, persistence, resource limits, and isolation checks in integration tests against the real sandbox provider.
Use ScriptedRealtimeModel when a test should exercise RealtimeSession behavior or RealtimeAgent tool and handoff orchestration without opening a WebSocket connection. Keep raw Realtime client/server events, authentication, network recovery, and audio transport behavior on the real transport or in an integration environment. Realtime API sessions keep a connection open while the client sends input and receives events, so those network and protocol concerns belong below the normalized model boundary. See the OpenAI Realtime API guide for production connection architectures.
Use the Voice testing components when a test should exercise STT/TTS ordering, streamed transcription cleanup, workflow fragment delivery, or complete Voice pipeline composition without speech providers. Use real audio models and representative audio when transcription quality, generated speech, encoding compatibility, latency, or playback is the subject of the test.
Do not use these utilities to test Responses API or Chat Completions request serialization, authentication headers, provider defaults, HTTP payloads, provider stream chunks, Realtime wire frames, or provider-specific lifecycle behavior. Keep the real adapter and replace or control its network boundary for those tests. With openai v3, OpenAI adapter tests should use httpx2 request, response, transport, and exception types; legacy httpx is not a core dependency of the Agents SDK.
Final checklist
- Script only interactions owned by the normalized model, Sandbox session, Realtime model, or Voice pipeline boundary.
- Assert important public request or call fields instead of private runner state.
- Prefer fixed response steps; use responders only for request-dependent behavior.
- Prefer automatic model streaming; use exact streams only when event-level behavior matters.
- End each scripted component test with its
assert_complete()method. - Use async context managers for Realtime and Sandbox lifecycle cleanup when the surrounding test owns that lifecycle.
- Assert structured error fields instead of parsing human-readable messages.
- Keep provider wire tests on real adapters with controlled network transports.
Scope and current limitations
The testing modules deliberately do not provide:
- Convenience builders for every normalized model output item. Use
assistant_message()andfunction_call()for common cases, and pass other normalized items directly. - A provider-protocol simulator. Exact model streams use normalized SDK events rather than Responses API or Chat Completions wire chunks.
- A high-level simulated Realtime server. Tests explicitly match normalized outbound sends and emit the normalized inbound events required by the scenario.
- Unordered Sandbox or Realtime expectations. Both utilities consume expected steps in one global order.
- Test-runner-specific matchers, fixtures, implicit timeouts, or automatic teardown.
- Reset APIs.
ScriptedModelsupportsenqueue()andextend()for an incremental script, but create a new scripted component for an independent scenario.
Use a custom implementation of the corresponding public interface when a test requires malformed streams, controlled suspension or concurrency, exact cancellation, or a lifecycle boundary that the scripted utilities cannot preserve. Document that specialized boundary in the test.