VCR.py: записываем HTTP-обмены и проигрываем в тестах
В прошлом уроке мы вручную описывали ответы через responses.add(...) и respx.get(...).mock(...). Это работает, но имеет один большой недостаток: вы пишете JSON-payload руками. Если у GitHub в ответе 30 полей — вы либо копипастите 30 полей, либо упрощаете до пяти, и тест перестаёт быть похож на реальность.
VCR.py решает эту проблему по-другому. Идея украдена из Ruby-сообщества: один раз запускаем тест с реальным HTTP-вызовом, библиотека записывает ответ в YAML-файл (cassette), а во всех последующих запусках проигрывает этот файл вместо реального запроса. Тесты становятся детерминированными, не требуют сети, и при этом данные в них — настоящие.
В этом уроке: как работает VCR, формат cassette, фильтрация секретов, стратегии match, когда VCR подходит, а когда — нет.
Идея VCR: запись и воспроизведение
Имя VCR — отсылка к видеомагнитофонам. Аналогия точная: пишем кассету один раз, потом проигрываем сколько угодно. Каждый HTTP-запрос с ответом — одна «дорожка» на кассете.
Первый запуск пишет cassette, последующие -- воспроизводят
Запись делается один раз — обычно локально на машине разработчика, имеющей доступ к API. Cassette коммитится в репо. CI и все остальные разработчики гоняют тесты против cassette, без сети.
Mocking и patching в pytest: unittest.mock, MagicMock
Установка и минимальный пример
pip install vcrpy
# Версия 6.x на 2026 год
VCR работает поверх любого HTTP-клиента (requests, httpx, urllib3, aiohttp) — она перехватывает на уровне сокетов. Менять код клиента не нужно.
# myapp/github.py
import requests
def get_repo(owner: str, name: str) -> dict:
response = requests.get(
f"https://api.github.com/repos/{owner}/{name}",
timeout=10,
headers={"Accept": "application/vnd.github+json"},
)
response.raise_for_status()
return response.json()
# tests/test_github_vcr.py
import vcr
from myapp.github import get_repo
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode="once", # запиши если нет, иначе проигрывай
)
@my_vcr.use_cassette("get_repo_success.yaml")
def test_get_repo():
repo = get_repo("octocat", "Hello-World")
assert repo["name"] == "Hello-World"
assert repo["owner"]["login"] == "octocat"
При первом запуске VCR увидит, что tests/cassettes/get_repo_success.yaml не существует, пропустит реальный HTTP-вызов и запишет ответ. При втором — cassette уже на диске, реального вызова не будет.
Формат cassette
Cassette — обычный YAML. Это удобно: можно открыть в редакторе, увидеть, что было записано, при необходимости поправить руками.
# tests/cassettes/get_repo_success.yaml
interactions:
- request:
body: null
headers:
Accept:
- application/vnd.github+json
Accept-Encoding:
- gzip, deflate
User-Agent:
- python-requests/2.34.0
method: GET
uri: https://api.github.com/repos/octocat/Hello-World
response:
body:
string: '{"id":1296269,"name":"Hello-World","owner":{"login":"octocat","id":583231},"description":"My first repo"}'
headers:
Content-Type:
- application/json; charset=utf-8
X-RateLimit-Limit:
- '60'
X-RateLimit-Remaining:
- '59'
status:
code: 200
message: OK
version: 1
Каждый interaction — пара (request, response). VCR при следующем запуске:
- Перехватывает HTTP-вызов.
- Сериализует request в ту же структуру.
- Ищет совпадение в cassette (по match strategies — об этом ниже).
- Если нашёл — возвращает соответствующий response. Если нет — поведение зависит от
record_mode.
Cassette — это readable артефакт. Если тест неожиданно сломался, открыть YAML и глазами посмотреть, что в ответе — самый быстрый способ понять, что не так.
Record modes: когда писать, когда нет
record_mode определяет, что VCR делает, если cassette нет или нужный interaction не найден.
Разные режимы для разных стадий разработки
Типичный workflow:
- Локально:
record_mode="once". Первый прогон записывает, последующие проигрывают. - Поменялся API: удаляешь cassette, гоняешь тест -> пишет новую.
- CI:
record_mode="none". Если кто-то забыл закоммитить cassette — упадёт явно, а не пойдёт в интернет.
Фильтрация sensitive данных
Главная опасность VCR: записать в cassette секреты — токены, пароли, личные данные — и закоммитить в публичный репозиторий. Это случается часто. Защита — filter_headers и filter_query_parameters.
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode="once",
filter_headers=[
("Authorization", "REDACTED"),
("X-API-Key", "REDACTED"),
("Cookie", "REDACTED"),
("Set-Cookie", "REDACTED"),
],
filter_query_parameters=[
("api_key", "REDACTED"),
("token", "REDACTED"),
],
)
Теперь в cassette вместо реального токена будет:
headers:
Authorization:
- REDACTED
Перед первым коммитом cassette — обязательно открыть YAML и глазами проверить, что там нет секретов. filter_headers фильтрует только то, что вы указали. Если API использует кастомный header (например X-Auth-User-Token), а вы про него забыли — он попадёт в репо. Заведите check-list или pre-commit хук на grep по cassette на «Bearer », «api_key=», «token=».
Аналогично можно фильтровать поля в response body:
def filter_response_body(response):
import json
body = response["body"]["string"]
if isinstance(body, bytes):
body = body.decode("utf-8")
try:
data = json.loads(body)
if "user" in data:
data["user"]["email"] = "[email protected]"
response["body"]["string"] = json.dumps(data).encode("utf-8")
except (json.JSONDecodeError, KeyError):
pass
return response
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
before_record_response=filter_response_body,
)
Match strategies: как VCR находит нужный interaction
Когда тест делает HTTP-вызов, VCR ищет в cassette interaction, который «соответствует» запросу. Что считать соответствием — настраивается через match_on.
Доступные стратегии:
method— HTTP method (GET vs POST).scheme— http/https.host— example.com vs api.example.com.port— порт.path— путь URL.query— query parameters.body— тело запроса.headers— все headers (опасно: User-Agent может отличаться между запусками).
По умолчанию: ["method", "scheme", "host", "port", "path", "query"]. То есть тот же URL + тот же метод = тот же interaction.
my_vcr = vcr.VCR(
match_on=["method", "scheme", "host", "path", "query"],
)
Когда нужно матчить body — например, для POST с разными payload — добавляем "body":
my_vcr = vcr.VCR(
match_on=["method", "scheme", "host", "path", "body"],
)
Кастомный matcher
Иногда стандартных стратегий мало: например, в body есть timestamp, который меняется при каждом запуске. Можно написать свой matcher:
import json
def body_without_timestamp(r1, r2):
"""Сравниваем body, игнорируя поле timestamp."""
if not r1.body or not r2.body:
return r1.body == r2.body
b1 = json.loads(r1.body)
b2 = json.loads(r2.body)
b1.pop("timestamp", None)
b2.pop("timestamp", None)
return b1 == b2
my_vcr = vcr.VCR()
my_vcr.register_matcher("body_no_ts", body_without_timestamp)
my_vcr.match_on = ["method", "host", "path", "body_no_ts"]
Pytest-интеграция: фикстура и pytest-recording
VCR хорошо живёт без отдельной библиотеки, но есть удобный плагин pytest-recording, который интегрирует VCR в pytest через декоратор @pytest.mark.vcr.
pip install pytest-recording
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def vcr_config():
return {
"record_mode": "once",
"filter_headers": [
("Authorization", "REDACTED"),
("X-API-Key", "REDACTED"),
],
"match_on": ["method", "scheme", "host", "path", "query"],
}
# tests/test_github_vcr.py
import pytest
from myapp.github import get_repo
@pytest.mark.vcr
def test_get_repo():
repo = get_repo("octocat", "Hello-World")
assert repo["name"] == "Hello-World"
Cassette автоматически положится в tests/cassettes/test_get_repo.yaml — имя файла генерируется из имени теста.
CLI-флаги для управления:
# Перезаписать все cassettes (например, после смены API):
pytest --record-mode=rewrite tests/
# Запретить запись новых (CI):
pytest --record-mode=none tests/
# По умолчанию -- once
pytest tests/
Когда VCR подходит, когда нет
VCR — мощный инструмент, но не серебряная пуля. Есть случаи, когда он идеально ложится, и случаи, когда лучше брать responses/respx.
Не каждый API-клиент стоит тестировать через cassette
Практический критерий: если ответы от API сложные и стабильные -> VCR. Если нужно проверять обработку конкретных статусов и edge cases -> responses/respx с явным контролем.
Гибридный подход
Часто в реальном проекте оба подхода соседствуют:
# Happy path -- через VCR (реальные данные, всё детально)
@pytest.mark.vcr
def test_get_repo_happy_path():
repo = get_repo("octocat", "Hello-World")
assert repo["stargazers_count"] >= 0
assert "license" in repo
assert repo["default_branch"] in ("main", "master")
# Error cases -- через responses (явный контроль статуса)
def test_get_repo_404(responses_mock):
responses_mock.add(
responses.GET,
"https://api.github.com/repos/no/such",
json={"message": "Not Found"},
status=404,
)
with pytest.raises(RepoNotFoundError):
get_repo("no", "such")
def test_get_repo_503(responses_mock):
responses_mock.add(
responses.GET,
"https://api.github.com/repos/owner/repo",
status=503,
)
with pytest.raises(ServerError):
get_repo("owner", "repo")
Принцип: VCR для «вот так это выглядит на самом деле», явные моки для «а вот что должно произойти, если случится X».
Подводные камни
VCR имеет несколько граней, на которые легко наступить.
Cassette = part of test, не data fixture
Cassette — это часть теста, не источник тестовых данных. Не редактируйте cassette руками, чтобы «сделать ответ другим». Если нужен другой ответ — это другой тест с другой cassette. Иначе вы получите ситуацию: cassette говорит одно, а реальный API — другое, и вы об этом узнаете в проде.
Re-record нужен периодически
Даже стабильный API меняется. Раз в квартал/полгода полезно перезаписать cassettes (--record-mode=rewrite) и убедиться, что тесты всё ещё проходят. Если что-то отвалилось — лучше узнать сейчас, а не когда упадёт прод.
Параллельный запуск тестов
Если несколько тестов используют одну cassette одновременно (например через pytest-xdist), могут возникнуть гонки при первом запуске (запись). Решение: первый прогон с записью делать однопоточно, потом гонять параллельно.
Размер cassette
Если API возвращает мегабайтные JSON — cassette будут весить столько же. Репо разрастается. Стратегия:
- В cassette хранить минимально достаточный response (например, две страницы пагинации, а не двадцать).
- Использовать
decode_compressed_response=Trueчтобы хранить unzipped версии — лучше читаются и diff-ятся.
my_vcr = vcr.VCR(
decode_compressed_response=True,
)
Полный пример: тест с cassette + фильтр + кастомный matcher
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def vcr_config():
return {
"record_mode": "once",
"cassette_library_dir": "tests/cassettes",
"filter_headers": [
("Authorization", "REDACTED"),
("X-API-Key", "REDACTED"),
("Cookie", "REDACTED"),
],
"filter_query_parameters": [
("api_key", "REDACTED"),
("access_token", "REDACTED"),
],
"match_on": ["method", "scheme", "host", "path", "query"],
"decode_compressed_response": True,
}
# tests/test_pipeline.py
import pytest
from myapp.pipeline import fetch_user_repos
@pytest.mark.vcr
def test_fetch_user_repos_returns_list():
# Cassette: tests/cassettes/test_fetch_user_repos_returns_list.yaml
repos = fetch_user_repos(username="octocat", token="ghp_test")
assert len(repos) > 0
assert all("id" in r for r in repos)
assert all("name" in r for r in repos)
@pytest.mark.vcr
def test_fetch_user_repos_filters_forks():
repos = fetch_user_repos(username="octocat", token="ghp_test", include_forks=False)
assert all(r["fork"] is False for r in repos)
Теперь:
- Локально (с реальным токеном):
pytest tests/test_pipeline.py— запишет cassettes. - Открыть cassettes, проверить, что
Authorization: REDACTED. - Закоммитить cassettes в репо.
- CI:
pytest --record-mode=none tests/test_pipeline.py— гарантированно оффлайн.
Итог
VCR — способ получить детерминированные оффлайн-тесты с реальными данными. Идеально подходит для тестов на стабильные внешние API, плохо — для error cases и часто меняющихся API. Главные риски: утечка секретов в cassette (фильтруйте всё) и устаревание cassette (re-record периодически). В связке с responses/respx даёт сбалансированное покрытие: VCR для happy path, явные моки для статусных кодов и edge cases.
В следующем уроке — третья ветка тестирования API: contract testing через Pact, для случая когда у вас есть свой собственный сервис, и нужно гарантировать, что вы и потребители не разъедутся по схеме.