Skip to navigation

Test Your Agents

Unit-test a framework adapter without Band, then check the connection with one integration test

FakeAgentTools from band.testing stands in for a room’s tools, so adapter unit tests run without a Band connection, credentials, or model calls. A short integration test with a dedicated test agent then checks that the agent starts against Band.

The examples test ChatCompletionsAdapter from Create a Framework Adapter with the Python SDK, band-sdk 6.0.1.

Prerequisites

  • Python 3.11 or later
  • chat_adapter.py from Create a Framework Adapter in the project directory
  • For the integration test only: a second agent, created as in the Quickstart, with its ID and API key in agent_config.yaml under test_agent

Set Up pytest

Install the test dependencies:

pip install band-sdk==6.0.1 openai==3.22.1 pytest==9.1.1 pytest-asyncio==1.4.0

Configure pytest in pyproject.toml:

pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
pythonpath = ["."]
markers = ["integration: tests that connect to Band"]

asyncio_mode = "auto" runs every async def test_* function without a decorator, and pythonpath lets the tests import chat_adapter. The integration marker keeps the Band test out of unit runs.

Write Unit Tests

Each test builds a PlatformMessage, stubs the model client, calls on_message() with a fresh FakeAgentTools(), and checks what the adapter did.

tests/test_chat_adapter.py
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from openai.types.chat import ChatCompletion
from band import BandConfigError, Emit
from band.core.types import PlatformMessage
from band.testing import FakeAgentTools
from chat_adapter import ChatCompletionsAdapter, ChatHistoryConverter
def make_message(content: str) -> PlatformMessage:
return PlatformMessage(
id="msg-1",
room_id="room-1",
content=content,
sender_id="user-1",
sender_type="User",
sender_name="Alice",
message_type="text",
metadata={},
created_at=datetime.now(timezone.utc),
)
def completion(content: str | None = None, tool_calls: list | None = None) -> ChatCompletion:
message = {"role": "assistant", "content": content, "tool_calls": tool_calls}
return ChatCompletion.model_validate(
{
"id": "cmpl-1",
"object": "chat.completion",
"created": 0,
"model": "gpt-5.4-mini",
"choices": [
{"index": 0, "finish_reason": "tool_calls" if tool_calls else "stop", "message": message}
],
}
)
def send_message_call(text: str) -> dict:
arguments = f'{{"content": "{text}", "mentions": ["@alice"]}}'
return {"id": "call-1", "type": "function", "function": {"name": "band_send_message", "arguments": arguments}}
def fake_client(*responses: ChatCompletion) -> MagicMock:
client = MagicMock()
client.chat.completions.create = AsyncMock(side_effect=list(responses))
return client
async def handle(adapter, tools, text, history=None, bootstrap=True) -> None:
await adapter.on_message(
msg=make_message(text),
tools=tools,
history=history or [],
participants_msg=None,
contacts_msg=None,
is_session_bootstrap=bootstrap,
room_id="room-1",
)
async def test_model_replies_through_band_send_message():
client = fake_client(completion(tool_calls=[send_message_call("Sunny, 22 C")]), completion())
adapter = ChatCompletionsAdapter(client=client)
tools = FakeAgentTools()
await handle(adapter, tools, "@weather what is the weather in Lisbon?")
assert tools.tool_calls == [
{
"tool_name": "band_send_message",
"arguments": {"content": "Sunny, 22 C", "mentions": ["@alice"]},
}
]
tools.assert_event_sent(message_type="tool_call", count=1)
tools.assert_event_sent(message_type="tool_result", count=1)
async def test_model_text_is_relayed_when_no_tool_replies():
client = fake_client(completion(content="Sunny, 22 C"))
adapter = ChatCompletionsAdapter(client=client)
tools = FakeAgentTools()
await handle(adapter, tools, "@weather what is the weather in Lisbon?")
tools.assert_message_sent(
content="Sunny, 22 C", mentions=["user-1"], count=1
)
assert tools.turn.complete
async def test_emit_off_reports_no_events():
client = fake_client(completion(tool_calls=[send_message_call("Hi")]), completion())
adapter = ChatCompletionsAdapter(client=client, emit=())
tools = FakeAgentTools()
await handle(adapter, tools, "@weather hi")
assert tools.events_sent == []
async def test_bootstrap_history_reaches_the_model():
client = fake_client(completion())
adapter = ChatCompletionsAdapter(client=client)
history = [{"role": "user", "content": "[Bob]: The office is in Lisbon"}]
await handle(adapter, FakeAgentTools(), "@weather where is the office?", history=history)
sent = client.chat.completions.create.call_args.kwargs["messages"]
assert history[0] in sent
def test_converter_separates_own_and_other_agents():
converter = ChatHistoryConverter()
converter.set_agent_name("Weather Agent")
raw = [
{"content": "It is sunny", "sender_name": "Weather Agent", "sender_type": "Agent", "message_type": "text"},
{"content": "I booked it", "sender_name": "Travel Agent", "sender_type": "Agent", "message_type": "text"},
{"content": "{}", "sender_name": "Weather Agent", "sender_type": "Agent", "message_type": "tool_call"},
]
assert converter.convert(raw) == [
{"role": "assistant", "content": "It is sunny"},
{"role": "user", "content": "[Travel Agent]: I booked it"},
]
async def test_model_failure_propagates():
client = MagicMock()
client.chat.completions.create = AsyncMock(side_effect=TimeoutError("model timed out"))
adapter = ChatCompletionsAdapter(client=client)
with pytest.raises(TimeoutError):
await handle(adapter, FakeAgentTools(), "@weather hi")
async def test_cleanup_drops_room_state():
adapter = ChatCompletionsAdapter(client=fake_client(completion()))
await handle(adapter, FakeAgentTools(), "@weather hi")
await adapter.on_cleanup("room-1")
assert "room-1" not in adapter.rooms
def test_unsupported_emit_is_rejected():
with pytest.raises(BandConfigError, match="does not support emit"):
ChatCompletionsAdapter(client=MagicMock(), emit={Emit.THOUGHTS})
pytest -m "not integration"

pytest reports 8 passed. After you add the integration test below, the same command reports 8 passed, 1 deselected.

The tests cover the adapter’s contract with the SDK: the model’s reply goes through band_send_message or is relayed by the adapter, tool events follow emit, bootstrap history reaches the model, other agents’ messages are not taken for the agent’s own, a model failure leaves on_message() so the SDK marks the message failed, and on_cleanup() frees the room.

Stub the Model

fake_client() replaces the OpenAI client with a MagicMock whose chat.completions.create returns prepared ChatCompletion objects in order, so every run is deterministic and needs no API key. The adapter takes the client as a constructor argument, which is what lets a test pass one in. Assert on what the adapter sent to the model and to FakeAgentTools, never on model wording.

What FakeAgentTools Records

FakeAgentTools implements the same interface as the room tools the SDK passes to on_message(), and records each call in a list.

AttributeRecords
messages_sentEach send_message(content, mentions) call, as {"id", "content", "mentions"}
events_sentEach send_event(content, message_type, metadata) call, as {"id", "content", "message_type", "metadata"}
tool_callsEach execute_tool_call(tool_name, arguments) call, as {"tool_name", "arguments"}
participants_added, participants_removedEach add_participant() and remove_participant() result
context_callsEach fetch_room_context() call

Three behaviors differ from a live room, and tests need to account for them:

  • execute_tool_call() records the call and returns {"status": "ok"} without running the tool. An adapter that runs its own tool loop sends replies through it, so its replies appear in tool_calls, not in messages_sent.
  • get_tool_schemas(), get_openai_tool_schemas(), and get_anthropic_tool_schemas() return an empty list.
  • send_message() enforces mentions as Band does: with no mentions it raises BandToolError with At least one mention is required.

Seed the room with constructor arguments, for example FakeAgentTools(participants=[...], peers=[...], contacts=[...], room_context=[...]). Each seed is validated against the platform’s response model, so a malformed record fails when the fake is built.

The assertion helpers fail with a readable message that lists what was sent:

HelperPasses when
assert_message_sent(content=..., mentions=..., count=...)A sent message matches each argument you give. count checks the total number of messages.
assert_event_sent(message_type=..., count=...)An event of that type was sent, and count of them when given
assert_no_messages_sent()No message was sent

For an adapter that runs work in the background, await tools.until_said("fragment") waits until a sent message contains that text.

Write an Integration Test

An integration test starts the agent against Band and checks that startup completes. Use a dedicated test agent: Band keeps one connection per agent, so a test that connects as your running agent disconnects it.

tests/test_integration.py
from unittest.mock import MagicMock
import pytest
from dotenv import load_dotenv
from band import Agent
from chat_adapter import ChatCompletionsAdapter
@pytest.mark.integration
async def test_agent_connects_to_band():
load_dotenv()
adapter = ChatCompletionsAdapter(client=MagicMock())
agent = Agent.from_config("test_agent", adapter=adapter)
await agent.start()
try:
assert agent.is_running
assert adapter.system_prompt.startswith(f"You are {agent.agent_name}")
finally:
assert await agent.stop(timeout=10)
pytest -m integration

pytest reports 1 passed, 8 deselected. A pass means the API key is valid, the agent’s metadata loaded, on_started() built the system prompt, the WebSocket connected, and the agent stopped cleanly. The failures start() can raise are listed in Startup Errors.

Keep integration tests to connection and basic flow. To test a full exchange, add the test agent to a room, mention it from the web app, and watch the reply and the tool_call and tool_result events in the room timeline.

Next Steps