unlob Docs
Browse documentation

Testing agents that call unlob

Three response shapes are worth a fixture each, and none of them need a real key.

An agent that only ever gets tested against a clean 200 will not have handled partial: true, a 429 with no retry-after, or a 402 the first time it meets one in production. All of them are cheap to fixture and none require a real API key, so there is no reason to find out how your agent handles them for the first time against a live account.

Four fixtures worth keeping

A partial result. The response is a normal 200 with results populated and "partial": true. The agent should surface this as an incomplete answer, not report the short list as everything there is. This is the single most consequential fixture to have, because a model handed a short list with no explanation will say “nothing found” — see Reading a result for why the field exists.

A 429 without retry-after. The hard-capped key that is out of credits. The agent should stop and say so, not retry. Pair it with a 429 that does carry retry-after, so a test can assert the two are handled differently rather than both being treated as “try again.”

A 503 at capacity. The server was too busy to take the request and said so immediately, with a retry-after. The agent should sleep that value and retry, and should not confuse it with the other 503 — no shard could serve the request — which carries no retry-after and does not clear on a schedule.

A 402. The account-suspended case. Distinct from both 429s, and the one case where retrying is not merely wasteful but will never succeed until someone acts outside the agent’s own loop — settling billing in the console.

// fixtures/unlob-partial.json
{ "total": 3, "results": [ /* … */ ], "partial": true }
// fixtures/unlob-429-credits.http
HTTP/1.1 429 Too Many Requests
x-ratelimit-limit: 600
x-ratelimit-remaining: 0

monthly credits used up, and this key stops at its allowance rather than billing overage
// fixtures/unlob-429-ratelimit.http
HTTP/1.1 429 Too Many Requests
retry-after: 23
x-ratelimit-remaining: 0

rate limit exceeded

The retry-after on a rate-limit refusal is a real number of seconds — the time until your minute turns over — so fixture it as something other than 1. A client that passes a test against retry-after: 1 by sleeping a hardcoded second will retry into the same window in production and be refused again.

// fixtures/unlob-503-capacity.http
HTTP/1.1 503 Service Unavailable
retry-after: 1

server is at capacity; retry shortly

Where each framework hooks in

The framework guides on this site each show a real client wired to a real base URL, which is exactly the seam to intercept in a test. A few concrete points:

  • httpx-based clients (the Python, Pydantic AI, LlamaIndex and CrewAI examples) mock cleanly with respx, matching on the route and returning one of the fixtures above instead of calling httpx.AsyncClient.get.
  • fetch-based clients (TypeScript, Mastra, Vercel AI SDK) intercept at fetch itself — msw or a hand-rolled stub — since none of these guides wrap fetch in anything deeper that would need its own mock.
  • Dependency-injected clients, shown on the Pydantic AI page’s Deps pattern, need no monkeypatching at all: construct the agent with a mock client directly, which is the reason that pattern is worth the small extra indirection over a module-level client.
  • MCP-adapter tools (langchain-mcp-adapters, Mastra’s MCPClient, the Agents SDK’s MCPServerStreamableHttp) are the one case worth testing against a real connection rather than a mock, at least once: the adapter’s job is translating the MCP frame into the framework’s own tool shape, and a fixture at the HTTP layer would skip past the part most likely to have a real bug. Point a test run at a real key with a tight budget instead, and save the fixtures above for the tool-execution logic layered on top.

A worked assertion

Using the respx-mocked Deps pattern from the Pydantic AI page as the concrete case:

import respx
import httpx

@respx.mock
async def test_agent_reports_incomplete_on_partial():
    respx.get("https://api.unlob.com/search").mock(
        return_value=httpx.Response(200, json={"total": 3, "results": [], "partial": True})
    )
    client = httpx.AsyncClient(base_url="https://api.unlob.com")
    result = await agent.run("test query", deps=Deps(client=client))
    assert "incomplete" in result.output.lower()

The assertion is on the agent’s own language, not on the mock — the fixture only proves the tool received partial: true; the test’s actual job is confirming the agent’s system prompt and tool schema together turn that into a caveat the user sees, rather than a confident short answer.

What this does not cover

None of this replaces a smoke test against the real API before shipping. A mock proves your agent handles the shapes correctly once it sees them; it does not prove your base URL, header name or key format are actually correct — x-api-key versus Authorization: Bearer is an easy typo that every fixture above will happily pass through unnoticed, because the mock never checks the request at all unless you assert on it too. Assert on the outgoing request’s headers at least once per client, not only on the response handling.

FAQ

Do I need a real API key to run these tests? No. Every fixture above is a canned response; nothing here calls the real API, and a suite built entirely on them can run with no key configured at all.

Should I test against the real API in CI? A single smoke test with a low-cost call (GET /account is free and unauthenticated failure modes aside, costs nothing) is worth having, separate from the fixtured suite, specifically to catch a wrong header name or a revoked key before it reaches production.

What about testing the MCP handshake itself? That is what Troubleshooting and the Inspector are for — a one-off manual check when wiring up a new client, not something to fixture into a CI suite per commit.

Bottom line

Four fixtures — a partial result, both shapes of 429, an at-capacity 503, and a 402 — cover the failure modes that actually change what an agent should do, and every framework on this site mocks them at the same seam its guide already shows: the HTTP client. Test the language your agent produces from each fixture, not just that the fixture was received.

Next