Use it with pytest¶
Cassetter ships with a pytest plugin. It is installed and activated automatically, you don't need to configure anything.
Mark a test¶
Add the @pytest.mark.vcr marker to any test that makes HTTP requests:
import httpx
import pytest
@pytest.mark.vcr
async def test_api_call():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/users")
assert response.status_code == 200
Each marked test gets its own cassette file at:
For a test test_api_call in tests/test_users.py, that is tests/cassettes/test_users/test_api_call.yaml.
For tests inside a class, the class name is included: TestUsers.test_api_call.yaml.
Record the cassette¶
By default the plugin runs in the none record mode: replay only, never touch the network. To record cassettes for the first time, pass --record-mode:
After that, run pytest normally and the tests replay from the cassettes.
Tip
Keeping none as the default is intentional. Your test suite will fail loudly if a cassette is missing or stale, instead of silently making real requests in CI.
You can read about all the modes in Record modes.
Access the cassette¶
If you need to inspect the recorded interactions, request the cassette fixture:
from cassetter import Cassette
@pytest.mark.vcr
async def test_with_cassette(cassette: Cassette):
async with httpx.AsyncClient() as client:
await client.get("https://api.example.com/users")
assert len(cassette.interactions) == 1
The fixture is also available under the name vcr, for compatibility with pytest-recording.
The cassette exposes vcrpy's introspection surface, so wire-contract assertions port over unchanged: cassette.requests returns the recorded requests with .method, .uri, .headers, .body, .path, .host, and .query attributes, and cassette.play_count, cassette.play_counts, and cassette.all_played report replay progress.
play_count, play_counts, and played_indices count HTTP interactions only, as in vcrpy. all_played covers every protocol. For gRPC and WebSocket interactions, use grpc_played_indices and ws_played_indices:
@pytest.mark.vcr
async def test_uses_every_recording(cassette: Cassette):
...
assert all(cassette.ws_played_indices)
assert cassette.all_played
A WebSocket interaction counts as played once its connection opens
ws_played_indices does not say whether every recorded frame was received.
@pytest.mark.vcr
async def test_sends_tool_definitions(cassette: Cassette):
...
request_body = json.loads(cassette.requests[0].body)
assert request_body["tools"][0]["name"] == "get_weather"
cassette.requests is what the cassette recorded, so it keeps passing after the code stops sending a field: the default matcher ignores the body. cassette.sent_requests is what the code sent during this test, replayed or live, with the same attributes. Assert on it to catch that drift:
@pytest.mark.vcr
async def test_sends_tool_definitions(cassette: Cassette):
...
request_body = json.loads(cassette.sent_requests[0].body)
assert request_body["tools"][0]["name"] == "get_weather"
sent_requests holds credentials
Requests are captured before before_record_request and scrubbing, so their headers and bodies still hold API keys. They stay in memory and are never written to the cassette. Requests to bypassed hosts are not captured, and only HTTP requests are.
Configure with vcr_config¶
Override the vcr_config fixture to set options for a whole module:
import pytest
@pytest.fixture(scope="module")
def vcr_config():
return {
"record_mode": "once",
"match_on": ["method", "uri", "json_body"],
"filter_headers": ["x-custom-secret"],
"ignore_hosts": ["*.googleapis.com"],
}
The supported keys are the options accepted by use_cassette(), plus the plugin's on_unplayed check:
| Key | Description |
|---|---|
record_mode |
Recording behavior, see Record modes |
match_on |
Fields used to match requests |
ignore_json_paths |
JSON paths ignored during matching |
filter_headers |
Headers stripped from cassettes |
filter_query_parameters |
Query params replaced in cassettes |
body_scrub_patterns |
Body field patterns scrubbed from cassettes |
filter_replacement |
Replacement string for filtered values |
cassette_dir |
Cassette directory, relative to the test file |
cassette_library_dir |
Cassette directory, used as is |
intercept |
Libraries to intercept |
max_age |
Cassette expiry, e.g. "30d" |
on_expiry |
What to do with expired cassettes |
on_unplayed |
What to do when a passing test leaves recordings unplayed, see Catch unplayed interactions |
ignore_localhost |
Bypass requests to localhost |
ignore_hosts |
Bypass requests to matching hosts |
before_record_request |
Hook to modify or skip requests |
before_record_response |
Hook to modify or skip responses |
uri_normalizer |
Callable applied to both URIs before matching |
before_record_ws_frame |
Hook to modify or skip WebSocket frames |
The fixture can also return a Cassetter, which is the same set of options as an object, shareable with code that calls use_cassette() directly:
from cassetter import Cassetter
@pytest.fixture(scope="module")
def vcr_config() -> Cassetter:
return Cassetter(record_mode="once", filter_headers=["x-custom-secret"])
Configure per test¶
The marker accepts overrides for a single test:
The first positional argument sets the cassette file name. The keyword arguments record_mode, cassette_dir, max_age, on_expiry, on_unplayed, ignore_hosts, and match_on override the module configuration for that test:
@pytest.mark.vcr(ignore_hosts=["gateway.example"], match_on=["method", "uri", "json_body"])
async def test_gateway(): ...
additional_matchers appends to the module's match_on instead of replacing it, as it does in VCR.py:
Unknown marker options fail the test
A marker option Cassetter does not support raises TypeError instead of being dropped. A dropped additional_matchers or ignore_hosts would weaken the test without telling you.
pytest-recording's default_cassette marker names the cassette too, so suites that already use it keep working:
@pytest.mark.default_cassette("custom_name.yaml")
@pytest.mark.vcr
async def test_special_case(): ...
The positional argument wins if a test carries both. Either way the name is resolved against the cassette directory, so pass a name rather than a path.
The command line flag --record-mode overrides everything.
Customize the cassette directory¶
Override the vcr_cassette_dir fixture:
Catch unplayed interactions¶
Replay passes as long as each request finds a match. A test that stops sending one of its recorded requests still passes, and the cassette quietly keeps an interaction nothing uses. Set on_unplayed to catch it:
| Value | Behavior |
|---|---|
ignore |
Don't check (default) |
warn |
Emit UnplayedInteractionsWarning |
fail |
Fail the test at teardown |
The report lists the unplayed interactions by protocol, e.g. HTTP [1]; gRPC [0]. Only passing tests on cassettes that can't record are checked: a failing test already says what went wrong, and a cassette that can record is still being written.
Set it for the whole suite with the vcr_on_unplayed ini option, and opt a single test out with @pytest.mark.vcr(on_unplayed="ignore"). The marker wins over vcr_config, which wins over the ini option.
Find orphaned cassettes¶
Over time, tests get renamed and deleted, and their cassettes stay behind. Find cassette files that no test uses: