11 KiB
Testing Guidelines
Testing philosophy
VCR + public-API tests are the default. We test through the public API the way a user would (Agent(...), agent.run(...)) against real provider responses recorded as cassettes — provider APIs are the ultimate judge of whether the code is correct when run as intended, and that user-facing correctness is what we care about, not behavior in isolated units.
Unit tests still earn their place — for internal behavior that is definitory and worth pinning against drift. That includes behavior you can't reach or reliably trigger through the public API (pre-request guards, defensive branches no real model produces), but also behavior a VCR test wouldn't actually protect: our cassette matchers aren't always sensitive to the request body, so a changed internal payload can still match an existing recording and pass green — a unit test asserting the internal shape directly is what catches that regression. Each unit test should still say why it isn't (or can't be) a VCR test.
Recording cassettes needs provider API keys and isn't trivial, so contributors routinely under-test the real behavior — writing the VCR test a contributor couldn't is core maintainer work.
Test File Structure
from __future__ import annotations
import pytest
from inline_snapshot import snapshot
from pydantic_ai import Agent
from pydantic_ai.models import Model
# ... other imports
pytestmark = [pytest.mark.anyio, pytest.mark.vcr]
# fixtures/helpers immediately before their test
@pytest.fixture
def my_helper():
...
@pytest.mark.parametrize('model', ['openai', 'anthropic', 'google'], indirect=True)
@pytest.mark.parametrize('stream', [False, True])
async def test_feature(model: Model, stream: bool):
...
Parametrization with Expectations
For cartesian product tests, use a dict to map parameter combinations to expected results:
from vcr.cassette import Cassette
from pydantic_ai.models import Model
# expectation can be a dataclass, for more complex cases
EXPECTATIONS: dict[tuple[str, bool], str] = {
('openai', False): 'expected output for openai non-streaming',
('openai', True): 'expected output for openai streaming',
('anthropic', False): 'expected output for anthropic non-streaming',
('anthropic', True): 'expected output for anthropic streaming',
}
@pytest.mark.parametrize('model', ['openai', 'anthropic'], indirect=True)
@pytest.mark.parametrize('stream', [False, True])
async def test_feature(model: Model, stream: bool, request: pytest.FixtureRequest, vcr: Cassette):
"""What the test is asserting.
Use the `request` fixture to access test parameter values.
Use the `vcr` to make assertions about the HTTP requests if needed.
Another creative way of, for instance, asserting headers, is to use a patched httpx client fixture.
This spares us the overhead of parsing cassette fields, so it is to be preferred whenever optimal.
"""
model_name = request.node.callspec.params['model']
expected = EXPECTATIONS[(model_name, stream)]
agent = Agent(model)
if stream:
async with agent.run_stream('hello') as result:
output = await result.get_output()
else:
result = await agent.run('hello')
output = result.output
assert output == expected
Use the EXPECTATIONS dict only for a pure cartesian output lookup keyed by (model, stream). For feature-centric files where cases are heterogeneous — different inputs, expectations, and xfails per case, all run through one minimal comprehensive test — use a @dataclass Case with sensible defaults plus per-case overrides:
from dataclasses import dataclass, field
import pytest
from inline_snapshot import snapshot
from pydantic_ai import Agent
from pydantic_ai.messages import ModelMessage
@dataclass(frozen=True)
class Case:
id: str
model: str
prompt: str = 'hello'
instructions: str | None = None
expected_messages: list[ModelMessage] = field(default_factory=list[ModelMessage])
marks: tuple[pytest.MarkDecorator, ...] = ()
CASES = [
Case(
id='openai',
model='openai:gpt-5',
expected_messages=snapshot([...]),
),
Case(
id='anthropic',
model='anthropic:claude-sonnet-4-5',
instructions='be terse',
expected_messages=snapshot([...]),
marks=(pytest.mark.skipif(not anthropic_available(), reason='anthropic not installed'),),
),
]
@pytest.mark.parametrize('case', [pytest.param(c, id=c.id, marks=c.marks) for c in CASES])
async def test_feature(case: Case):
agent = Agent(case.model, instructions=case.instructions)
result = await agent.run(case.prompt)
assert result.all_messages() == case.expected_messages
Each case carries its own snapshot (not the central test body), so a reviewer can read the cases top to bottom and check that every expectation is realistic.
VCR Workflow
Record cassettes with --record-mode=rewrite, verify playback without the flag, and review diffs.
For detailed workflows see .claude/skills/testing-skill/SKILL.md.
Key Fixtures
From conftest.py
Model requests
allow_model_requests- bypasses the defaultALLOW_MODEL_REQUESTS = False
The model fixture (use with indirect=True)
The model fixture takes a string param (e.g. 'openai', 'anthropic', 'google') and returns a configured Model instance, using session-scoped API key fixtures that default to 'mock-api-key' (real keys loaded from env when recording).
See tests/conftest.py for the full list of supported param values.
@pytest.mark.parametrize('model', ['openai', 'anthropic'], indirect=True)
async def test_something(model: Model):
...
Environment management
env-TestEnvinstance for temporary env var changesdef test_missing_key(env: TestEnv): env.remove('OPENAI_API_KEY') with pytest.raises(UserError): ...
Binary content (session-scoped)
assets_path-Pathtotests/assets/image_content-BinaryImage(kiwi.jpg)audio_content-BinaryContent(marcelo.mp3)video_content-BinaryContent(small_video.mp4)document_content-BinaryContent(dummy.pdf)text_document_content-BinaryContent(dummy.txt)
SSRF protection for URL downloads
disable_ssrf_protection_for_vcr- required for VCR tests that download URL content (ImageUrl,AudioUrl,DocumentUrl,VideoUrlwithforce_download=True)- An autouse guard raises a
RuntimeErrorif a VCR test triggers SSRF validation without this fixture
Assertion Helpers
From conftest.py
IsNow(tz=timezone.utc)- datetime within 10 seconds of nowIsStr()- any string, supportsregex=r'...'IsDatetime()- any datetimeIsBytes()- any bytesIsInt()- any intIsFloat()- any floatIsList()- any listIsInstance(SomeClass)- instance of class
Additional helpers
IsSameStr()- asserts same string value across multiple uses in one assertionassert events == [ {'id': (msg_id := IsSameStr())}, {'id': msg_id}, # must match first ]
Best Practices
- Test through public APIs, not private methods (prefixed with
_) or helpers — validates actual user-facing behavior and prevents brittle tests tied to implementation details - Prefer feature-centric parametrized test files (e.g.
test_multimodal_tool_returns.py) over appending to monolithictest_<provider>.pyfiles — the legacy per-provider files are large and hard for agents to navigate; new features should get their own test file with aCaseclass and parametrized providers - Use
snapshot()for complex structured outputs (objects, message sequences, API responses, nested dicts) — catches unexpected changes more reliably than field-by-field assertions; useIsStrand similar matchers for variable values - Assert the core aspect of the change being introduced — use whatever means necessary: patching clients to inspect request payloads, tapping into pydantic-ai internals, snapshot comparisons. Snapshots are valuable for catching structural drift in objects and message arrays, but only use
result.all_messages()or output assertions when the structure demonstrates behavior you care about keeping consistent - Test both positive and negative cases for optional capabilities (model features, server features, streaming) — ensures features work when supported AND fail gracefully when absent
- Ensure test assertions match test names and docstrings — tests without proper assertions or that verify opposite behavior create false positives
- Test MCP against real
tests.mcp_serverinstance, not mocks — extend test server with helper tools to expose runtime context (instructions, client info, session state) - Remove stale test docstrings, comments, and historical provider bug notes when behavior changes
- Prefer
instructions=oversystem_prompt=when the test doesn't specifically need the system-prompt code path —instructions=is the canonical entry point for non-system-prompt-specific behavior (cacheable prefix, persona priming, format guidance), and reservingsystem_prompt=for tests that exercise the system-prompt machinery keeps intent legible - Never reference line numbers in test docstrings or comments (
lines 872-873,L42,line 100) — they go stale on the next edit to the referenced file. Describe the condition or behavior instead - When testing prompt caching, assert prefix stability, not just a cache hit —
cache_read_tokens > 0only proves that some prefix was reused, not that the prefix you intended stayed stable as history grows; pin it by asserting the cacheable region (serialized leading blocks up to the breakpoint) is byte-identical across consecutive requests and/or that the cache read covers the full prior prefix, since a per-request injection or serialization that moves with history length silently busts the cache with no error
Directory Structure
tests/
├── conftest.py # shared fixtures
├── json_body_serializer.py # custom VCR serializer
├── assets/ # binary test files
├── cassettes/ # VCR recordings for root tests
├── models/
│ ├── conftest.py # model-specific fixtures (if needed)
│ ├── cassettes/ # VCR recordings per test file
│ │ ├── test_openai/
│ │ ├── test_anthropic/
│ │ └── ...
│ └── test_*.py
├── providers/
│ └── test_*.py # provider initialization tests (unit)
└── test_*.py # feature tests (prefer VCR + parametrize)