Testing
Runs have real side effects (Slack, email, ad changes) and every live run costs tokens. Integration tests should never hit the live API. m8tes.testing (SDK 4.8.0+) runs your code against the real client with only the network swapped out:
from m8tes.testing import MockM8tes, StreamBuilder, agent_payload
client = MockM8tes() # real M8tes wired to a mock transport. Nothing reaches the network
client.mock.add("POST", "/agents/", json=agent_payload(id=7, name="Ops Mate"))
client.mock.add("POST", "/runs/",
stream=StreamBuilder().metadata(run_id=42).text("Hello world").done())
agent = client.agents.create(name="Ops Mate")
with client.runs.create(agent_id=agent.id, message="Do X") as stream:
for event in stream: # real typed events (TextDeltaEvent, DoneEvent, ...)
...
assert stream.text == "Hello world"
client.mock.calls # every request sent, with parsed JSON bodiesThe mock is mounted at the transport layer: retries, idempotency keys, typed errors, and SSE parsing are the production paths. No extra deps, no running server.
Test your failure paths
The first thing worth testing is what your code does when a run fails. Build a failing stream, or a full error envelope:
import pytest
from m8tes import BillingError, RunFailedError
from m8tes.testing import MockM8tes, StreamBuilder, error_envelope
# A run that fails upstream mid-stream:
client = MockM8tes()
client.mock.add("POST", "/runs/",
stream=StreamBuilder().metadata(run_id=42)
.error("Sandbox quota exhausted", error_code="SANDBOX_QUOTA_EXHAUSTED")
.done())
with pytest.raises(RunFailedError):
list(client.runs.create(agent_id=7, message="Do X", raise_on_error=True))
# An API error before the run starts. Raises the same typed exception prod raises:
client = MockM8tes()
client.mock.add("POST", "/runs/", status=402, json=error_envelope(
"Balance depleted.", status=402, error_code="TOKEN_BALANCE_DEPLETED",
details={"topup_url": "https://m8tes.ai/developer"}))
try:
client.runs.create_and_wait(agent_id=7, message="Do X")
except BillingError as e:
assert e.error_code == "TOKEN_BALANCE_DEPLETED"Fixtures for the same route are consumed in order (that's how you script a status poll), so use a fresh client or distinct routes per scenario. Mocked
429/500responses are retried by the real client with real backoff (~1.5s): that's the production retry path doing its job, so prefer 4xx fixtures and assert onclient.mock.callscounts instead of timing.
Use your own client construction
If your app builds its own M8tes (from config, per tenant, ...), install the transport into it instead of using MockM8tes:
from m8tes import M8tes
from m8tes.testing import MockTransport
client = my_app.build_client() # your code, unchanged
mock = MockTransport()
mock.install(client)
mock.add("GET", "/agents/7", json=agent_payload(id=7))Pin the tenant scope
A query string on a fixture makes it query-aware: the request must carry those parameters, so a test whose code drops user_id fails instead of silently matching. mock.calls records the parsed query as params for direct assertions. This is the test that keeps multi-tenant isolation honest:
client = MockM8tes()
client.mock.add("GET", "/agents/?user_id=tenant-a", json=page_payload(agent_payload()))
client.agents.list(user_id="tenant-a") # matches
assert client.mock.calls[-1].params["user_id"] == "tenant-a"
client.agents.list() # AssertionError. The scope went missingFixtures without a query string match any query. Recorded headers are credential-redacted (Authorization becomes <redacted>), so snapshots and CI logs never carry a real key.
Payload factories
agent_payload, run_payload, task_payload, and page_payload build realistic v2 wire shapes with sensible defaults. Override only what your test cares about. error_envelope builds the standard error format, including error_code.
For end-to-end verification against a real backend before going live, use a separate account with a small balance. See Going Live.