VCR.py records real HTTP calls and replays them in tests. pip install vcrpy. Decorator: import vcr. @vcr.use_cassette("cassettes/users.yaml") def test_list_users(): resp = requests.get("https://api.example.com/users"); assert resp.status_code == 200. Context: with vcr.use_cassette("cassettes/create.yaml"): resp = requests.post(url, json=data). First run: makes real HTTP call and saves to YAML file. Subsequent runs: replays from file — no network. Record modes: record_mode="none" — fail if cassette missing. record_mode="new_episodes" — replay if exists, record new URIs. record_mode="all" — always re-record. record_mode="once" (default) — record if missing, replay if present. Config: my_vcr = vcr.VCR(cassette_library_dir="tests/cassettes", record_mode="none"). Match on: match_on=["method","scheme","host","port","path","query"] — tune what counts as the same request. match_on=["uri","method","body"]. Filter headers: my_vcr = vcr.VCR(filter_headers=["Authorization","X-API-Key"]) — strip secrets before saving. filter_post_data_parameters=["password","token"]. Hook: before_record_request=lambda req: req — transform before recording. before_record_response=lambda resp: resp. JSON cassette: serializer="json". decode_compressed_response=True. Python Requests: supported out of the box. httpx: pip install pytest-httpx or use vcr-httpx extra. aiohttp: pip install vcrpy[aiohttp]. urllib: supported. pytest: pip install pytest-recording. @pytest.mark.vcr — auto-discovers cassette at cassettes/test_module/test_name.yaml. pytest --vcr-record=none. CI: commit cassettes to source control — tests run without network. re_record_interval — auto-expire cassettes. Claude Code generates VCR.py cassette fixtures, record-mode configurations, and secret-scrubbing hooks.
CLAUDE.md for VCR.py
## VCR.py Stack
- Version: vcrpy >= 6.0 | pip install vcrpy pytest-recording
- Record: @vcr.use_cassette("path.yaml") | with vcr.use_cassette(...)
- Mode: record_mode="none" (CI) | "once" (default) | "new_episodes" | "all"
- Secrets: filter_headers=["Authorization"] | filter_post_data_parameters=["token"]
- Match: match_on=["uri","method","body"] — tune request identity
- pytest: @pytest.mark.vcr — auto-cassette path from test name
- Dir: cassette_library_dir="tests/cassettes" in VCR() config object
VCR.py Record-Replay Pipeline
# tests/test_with_vcr.py — VCR.py cassette usage
from __future__ import annotations
import json
import pytest
import requests
import vcr
# ─────────────────────────────────────────────────────────────────────────────
# VCR instance — project-wide settings
# ─────────────────────────────────────────────────────────────────────────────
PROJECT_VCR = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode="none", # fail if cassette missing — safe for CI
match_on=["uri", "method", "body"],
filter_headers=["Authorization", "X-API-Key", "Cookie"],
filter_post_data_parameters=["password", "token", "secret"],
decode_compressed_response=True,
serializer="yaml", # human-readable cassette files
)
# ─────────────────────────────────────────────────────────────────────────────
# Client under test
# ─────────────────────────────────────────────────────────────────────────────
BASE_URL = "https://jsonplaceholder.typicode.com"
class PostApiClient:
"""Thin wrapper around the JSONPlaceholder demo API."""
def list_posts(self, user_id: int | None = None) -> list[dict]:
params = {"userId": user_id} if user_id else {}
r = requests.get(f"{BASE_URL}/posts", params=params,
headers={"Authorization": "Bearer secret-token"})
r.raise_for_status()
return r.json()
def get_post(self, post_id: int) -> dict:
r = requests.get(f"{BASE_URL}/posts/{post_id}")
r.raise_for_status()
return r.json()
def create_post(self, payload: dict) -> dict:
r = requests.post(f"{BASE_URL}/posts", json=payload)
r.raise_for_status()
return r.json()
def update_post(self, post_id: int, payload: dict) -> dict:
r = requests.patch(f"{BASE_URL}/posts/{post_id}", json=payload)
r.raise_for_status()
return r.json()
def delete_post(self, post_id: int) -> None:
r = requests.delete(f"{BASE_URL}/posts/{post_id}")
r.raise_for_status()
@pytest.fixture
def api() -> PostApiClient:
return PostApiClient()
# ─────────────────────────────────────────────────────────────────────────────
# 1. @vcr.use_cassette decorator — one cassette per test
# ─────────────────────────────────────────────────────────────────────────────
class TestListPosts:
@vcr.use_cassette("tests/cassettes/list_posts.yaml")
def test_returns_list(self, api: PostApiClient) -> None:
"""
First run: makes real GET request, saves cassette.
Subsequent runs: replays from cassette — no network needed.
"""
posts = api.list_posts()
assert isinstance(posts, list)
assert len(posts) > 0
assert "title" in posts[0]
@vcr.use_cassette("tests/cassettes/list_posts_by_user.yaml")
def test_filter_by_user(self, api: PostApiClient) -> None:
posts = api.list_posts(user_id=1)
assert all(p["userId"] == 1 for p in posts)
@PROJECT_VCR.use_cassette("get_post_1.yaml")
def test_get_single_post(self, api: PostApiClient) -> None:
"""Uses the project-wide VCR instance — cassette at tests/cassettes/get_post_1.yaml."""
post = api.get_post(1)
assert post["id"] == 1
assert "title" in post
# ─────────────────────────────────────────────────────────────────────────────
# 2. Context manager — inline mock within test body
# ─────────────────────────────────────────────────────────────────────────────
class TestCreatePost:
def test_create_returns_201(self, api: PostApiClient) -> None:
payload = {"title": "Test", "body": "Hello", "userId": 1}
with vcr.use_cassette("tests/cassettes/create_post.yaml"):
result = api.create_post(payload)
assert "id" in result
def test_update_post(self, api: PostApiClient) -> None:
with PROJECT_VCR.use_cassette("update_post_1.yaml"):
result = api.update_post(1, {"title": "Updated title"})
assert "id" in result
def test_delete_post(self, api: PostApiClient) -> None:
with PROJECT_VCR.use_cassette("delete_post_1.yaml"):
api.delete_post(1) # no exception = success
# ─────────────────────────────────────────────────────────────────────────────
# 3. pytest-recording — @pytest.mark.vcr auto-cassette
# ─────────────────────────────────────────────────────────────────────────────
class TestWithPytestRecording:
"""
pytest-recording (pip install pytest-recording) provides @pytest.mark.vcr.
Cassette path is derived automatically: tests/cassettes/<module>/<TestClass.method>.yaml
Run with: pytest --vcr-record=none (CI: never hit network)
pytest --vcr-record=once (local: record if missing)
"""
@pytest.mark.vcr
def test_list_posts_auto_cassette(self, api: PostApiClient) -> None:
posts = api.list_posts()
assert len(posts) > 0
@pytest.mark.vcr
def test_get_post_auto_cassette(self, api: PostApiClient) -> None:
post = api.get_post(1)
assert post["id"] == 1
@pytest.mark.vcr(record_mode="new_episodes")
def test_list_with_new_episodes(self, api: PostApiClient) -> None:
"""Override record mode per-test."""
posts = api.list_posts(user_id=2)
assert len(posts) > 0
# ─────────────────────────────────────────────────────────────────────────────
# 4. Secret scrubbing — verify Authorization header is not saved
# ─────────────────────────────────────────────────────────────────────────────
class TestSecretScrubbing:
@PROJECT_VCR.use_cassette("scrubbed_request.yaml")
def test_auth_header_not_in_cassette(self, api: PostApiClient) -> None:
"""
PROJECT_VCR strips Authorization header before writing cassette.
The cassette file will NOT contain "Bearer secret-token".
"""
posts = api.list_posts()
assert posts is not None
# Manually verify cassette is clean:
import os
cassette_path = "tests/cassettes/scrubbed_request.yaml"
if os.path.exists(cassette_path):
with open(cassette_path) as f:
content = f.read()
assert "secret-token" not in content
# ─────────────────────────────────────────────────────────────────────────────
# 5. before_record_request hook — transform requests before saving
# ─────────────────────────────────────────────────────────────────────────────
def _scrub_request(request: vcr.request.Request) -> vcr.request.Request:
"""Remove credentials and add a stable request ID for matching."""
request.headers.pop("Authorization", None)
request.headers.pop("X-Request-Id", None)
return request
def _scrub_response(response: dict) -> dict:
"""Remove volatile response headers before saving cassette."""
volatile = {"Date", "X-Request-Id", "CF-Ray", "X-RateLimit-Remaining"}
for header in volatile:
response["headers"].pop(header, None)
return response
CLEAN_VCR = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode="none",
before_record_request=_scrub_request,
before_record_response=_scrub_response,
)
class TestCustomHooks:
@CLEAN_VCR.use_cassette("hooked_request.yaml")
def test_list_posts_with_hooks(self, api: PostApiClient) -> None:
posts = api.list_posts()
assert isinstance(posts, list)
# ─────────────────────────────────────────────────────────────────────────────
# 6. conftest.py — shared VCR config (copy to tests/conftest.py)
# ─────────────────────────────────────────────────────────────────────────────
# tests/conftest.py — enable pytest-recording with project defaults:
#
# import pytest
#
# @pytest.fixture(scope="module")
# def vcr_config():
# return {
# "filter_headers": ["Authorization", "Cookie", "X-API-Key"],
# "filter_post_data_parameters": ["password", "token"],
# "record_mode": "none",
# "match_on": ["uri", "method", "body"],
# "decode_compressed_response": True,
# }
#
# @pytest.fixture(scope="module")
# def vcr_cassette_dir(request):
# # Group cassettes by test module
# return f"tests/cassettes/{request.module.__name__}"
For the responses / respx mocking alternative — responses and respx require you to manually encode every API response as a dict in the test file, so when the real API changes its response shape your tests silently continue passing with the stale hand-crafted mock, while VCR.py records the actual server response on first run — the cassette YAML contains the real headers, status code, and body — and any schema change will break the next record_mode="once" run, immediately surfacing the drift. For the pytest fixtures with mocked responses alternative — per-test mock fixtures must be updated whenever the external service changes, while VCR.py cassettes can be refreshed on demand with record_mode="all" or re_record_interval="7 days" — run the suite in record mode weekly to keep cassettes fresh, then switch to record_mode="none" for CI so tests never hit the network and cassette drift is caught at refresh time. The Claude Skills 360 bundle includes VCR.py skill sets covering vcr.use_cassette decorator and context manager, record_mode none/once/new_episodes/all, filter_headers and filter_post_data_parameters for secret scrubbing, before_record_request and before_record_response transformation hooks, match_on configuration for request identity, pytest-recording @pytest.mark.vcr integration, conftest.py vcr_config and vcr_cassette_dir fixtures, cassette directory organization, and decode_compressed_response for gzip APIs. Start with the free tier to try HTTP record-replay testing code generation.