Testing
chattice.testing is the framework's testing toolkit: mocks, typed event
builders, and assertions that let you exercise a whole application pipeline
without ever touching raw Google JSON or standing up network transports.
The core engine never imports testing — the dependency is one-way.
A CI gate (tests/testing/test_gate.py) pins the core contract: an
application handler is tested end-to-end with MockBot + EventFactory,
with zero Google internals mocked.
Package contents
chattice/testing/
├── MockBot call-recording stand-in for the outgoing API
├── EventFactory typed event builders (no raw Google JSON)
├── FakeChatTransport gapic-contract fake transport
├── assert_card_has_button / assert_card_header
└── set_state_for FSM seeding helper
The DI pattern: feed_update(event, bot=mock_bot)
Handlers receive dependencies by parameter name. Inject the MockBot the
same way a real app injects the Bot — via feed_update context:
from chattice import Dispatcher, Router
from chattice.events import MessageEvent
from chattice.testing import EventFactory, MockBot
bot = MockBot()
router = Router()
@router.message()
async def echo(message: MessageEvent, bot: MockBot) -> str:
await bot.app.messages.create(message.space, text=message.text)
return "handled"
dispatcher = Dispatcher()
dispatcher.include_router(router)
result = await dispatcher.feed_update(EventFactory.message("ping"), bot=bot)
assert result == "handled"
bot.assert_message_sent("ping")
MockBot
MockBot records every outgoing call (send_message, update_message,
delete_message, get_message, get_space) with its arguments in
.calls and fabricates SDK proto responses — no transport, no network.
Preflight is not modelled: both identity namespaces record identically.
Assertions (each fails with an actionable message):
assert_message_sent(text: str | None = None, *, count: int = 1)— the outgoing calls, optionally with an exact text and call count.assert_no_messages()— proves a handler sent nothing.calls— the raw(kind, args)list for advanced checks (e.g. asserting onget_messagereads orupdate_messagepayloads).
MockBot mirrors the real Bot facade (bot.app.messages.*) —
DI supplies it by parameter name. Tests should exercise the operations used
by the application; importing a handler alone does not validate its calls.
EventFactory
EventFactory builds frozen domain events directly from their typed
constructors — unit tests never paste raw Google JSON:
message(text, *, user=None, space=None, thread=None, event_time=None)action(name, parameters=None, *, form_inputs=None, dialog=None, ...)added_to_space(...),removed_from_space(...),app_home(...),form_submit(function_name, ...)workspace_event(cloud_type, *, event_id="evt-test", ...)unknown_event(original_type, ...)
user/space accept either a typed reference or a Google resource-name
string ("users/user-a"); None uses test defaults (users/test,
spaces/test).
Card assertions
from chattice.cards import Button, ButtonList, Card, CardHeader, Section
from chattice.testing import assert_card_has_button, assert_card_header
card = Card(
header=CardHeader(title="Pipeline"),
sections=[
Section(
widgets=[ButtonList(buttons=[Button("Deploy", action="deploy.confirm")])]
)
],
)
assert_card_has_button(card, action="deploy.confirm", text="Deploy")
assert_card_header(card, title="Pipeline")
assert_card_has_button(card, *, action=None, text=None) and
assert_card_header(card, *, title=None, subtitle=None) search the card
and raise with the missing match described.
FSM seeding
Seed a workflow state for a test with set_state_for(storage, key, state),
then route through the dispatcher as usual:
from chattice.fsm import MemoryStorage, StorageKey, State, StatesGroup
from chattice.testing import set_state_for
class Flow(StatesGroup):
start = State()
waiting = State()
storage = MemoryStorage()
key = StorageKey(user="users/test", space="spaces/test", thread=None)
await set_state_for(storage, key, Flow.waiting) # the StateFilter now matches
Live integration suite
tests/integration/live exercises real Google Chat infrastructure and is
skipped by default — run it only with real credentials:
export CHATTICE_GOOGLE_CREDENTIALS=/path/to/service-account.json
export CHATTICE_GOOGLE_SPACE="spaces/SPACE"
pytest tests/integration/live -m google_live
Without credentials the suite reports 2 skipped (the network calls) — the
honest, expected state; the three contract tests (card round-trip,
command payloads, Workspace Pub/Sub replay) execute without network.
Setup, in short: a GCP project with the Google Chat API enabled, a
service-account JSON key, a Chat app set to Live with its membership
in a test space, and the CHATTICE_GOOGLE_CREDENTIALS +
CHATTICE_GOOGLE_SPACE env vars. The network bodies call the real API
and nothing fakes success — a green live run always means real
credentials were used.
Runnable pytest example
# tests/test_echo.py
import pytest
from chattice import Dispatcher, Router
from chattice.events import MessageEvent
from chattice.testing import EventFactory, MockBot
@pytest.fixture
def dispatcher() -> Dispatcher:
router = Router()
@router.message()
async def echo(message: MessageEvent) -> str:
return f"You said: {message.text}"
dp = Dispatcher()
dp.include_router(router)
return dp
@pytest.fixture
def bot() -> MockBot:
return MockBot()
@pytest.mark.asyncio
async def test_echo(dispatcher: Dispatcher, bot: MockBot) -> None:
result = await dispatcher.feed_update(EventFactory.message("ping"), bot=bot)
assert result == "You said: ping"
bot.assert_no_messages()
Add pytest-asyncio to the dev dependencies; asyncio_mode = "auto"
is already configured in the project, so the @pytest.mark.asyncio
decorator is optional there.