Learning Platform
Глоссарий Troubleshooting
Урок 14.02 · 22 мин
Начальный
VCRvcrpycassettefixturestesting

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-запрос с ответом — одна «дорожка» на кассете.

Жизненный цикл VCR

Первый запуск пишет cassette, последующие -- воспроизводят

Запуск 1: cassette нетПервый прогон теста. VCR видит, что cassette файла нет, пропускает запрос в реальный HTTP-стек
real HTTP
GitHub APIРеальный сервер. Возвращает реальный ответ -- JSON, headers, статус
VCR пишет cassetteVCR сериализует request + response в YAML файл tests/cassettes/test_name.yaml. После этого тест проходит
cassette.yamlYAML файл с одним или несколькими interaction. Каждый interaction = пара (request, response). Хранится в репо
Запуск N: cassette естьVCR видит cassette, перехватывает HTTP-вызов, ищет совпадение запроса в cassette, возвращает ответ из неё
from cassette
instant responseНикакого реального HTTP. Микросекунды вместо сотен миллисекунд. Полностью оффлайн

Запись делается один раз — обычно локально на машине разработчика, имеющей доступ к 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 при следующем запуске:

  1. Перехватывает HTTP-вызов.
  2. Сериализует request в ту же структуру.
  3. Ищет совпадение в cassette (по match strategies — об этом ниже).
  4. Если нашёл — возвращает соответствующий response. Если нет — поведение зависит от record_mode.
TIP

Cassette — это readable артефакт. Если тест неожиданно сломался, открыть YAML и глазами посмотреть, что в ответе — самый быстрый способ понять, что не так.


Record modes: когда писать, когда нет

record_mode определяет, что VCR делает, если cassette нет или нужный interaction не найден.

VCR record modes

Разные режимы для разных стадий разработки

onceDefault. Если cassette есть -- только проигрывай (никаких реальных HTTP). Если нет -- запиши при первом прогоне. Самый частый режим для CI
new_episodesПроигрывай известные interactions, новые -- записывай. Полезно когда добавил новый вызов в код и хочешь дозаписать в существующую cassette
noneТолько проигрывание. Если interaction не найден -- VCR кидает CannotOverwriteExistingCassetteException. Используется в CI, чтобы гарантировать оффлайн
allИгнорировать существующую cassette, перезаписать всё с нуля. Используется когда API изменился и нужен полный re-record

Типичный workflow:

  1. Локально: record_mode="once". Первый прогон записывает, последующие проигрывают.
  2. Поменялся API: удаляешь cassette, гоняешь тест -> пишет новую.
  3. 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
DANGER

Перед первым коммитом 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.

Когда выбирать VCR

Не каждый API-клиент стоит тестировать через cassette

Подходит: внешние стабильные APIGitHub API, Stripe, AWS. Изменяются редко, schema устойчива. Cassette остаётся валидной месяцами
Подходит: сложные ответыAPI возвращает JSON с 50 полями. Описывать руками -- ад. VCR пишет один раз и всё
Подходит: интеграция со сложным auth flowOAuth2 с redirect, refresh token. Cassette ловит весь flow, включая 401-then-refresh-then-retry
Подходит: документация поведенияCassette = живая документация: вот что мы получили от API в такой-то момент. Полезно для onboarding
Не подходит: часто меняющийся APIЕсли API меняет схему каждую неделю -- cassette будут устаревать быстрее, чем вы их обновите. Лучше явные моки
Не подходит: тесты на ошибкиЧтобы записать 429 -- нужно реально упереться в rate limit. Чтобы записать 500 -- нужен баг в API. Это сложнее, чем responses.add(status=500)
Не подходит: данные пользователейЗаписывать в cassette персональные данные -- плохо. Даже с фильтрами легко забыть поле. Для PII -- генерируйте моки руками
Не подходит: тесты бизнес-логикиЕсли функция парсит ответ и считает что-то -- тест на чистую функцию без HTTP. VCR не нужен

Практический критерий: если ответы от 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)

Теперь:

  1. Локально (с реальным токеном): pytest tests/test_pipeline.py — запишет cassettes.
  2. Открыть cassettes, проверить, что Authorization: REDACTED.
  3. Закоммитить cassettes в репо.
  4. CI: pytest --record-mode=none tests/test_pipeline.py — гарантированно оффлайн.

Проверка знанийKnowledge check
Команда замечает, что тесты с VCR cassettes падают каждые две недели после релиза внешнего API. Cassettes начинают содержать новые поля, схема ответа меняется, тесты валятся на assert-ах. Какое решение наиболее правильное?
ОтветAnswer

Итог

VCR — способ получить детерминированные оффлайн-тесты с реальными данными. Идеально подходит для тестов на стабильные внешние API, плохо — для error cases и часто меняющихся API. Главные риски: утечка секретов в cassette (фильтруйте всё) и устаревание cassette (re-record периодически). В связке с responses/respx даёт сбалансированное покрытие: VCR для happy path, явные моки для статусных кодов и edge cases.

В следующем уроке — третья ветка тестирования API: contract testing через Pact, для случая когда у вас есть свой собственный сервис, и нужно гарантировать, что вы и потребители не разъедутся по схеме.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. Какой record_mode VCR следует выставить в CI, чтобы гарантировать, что тесты никогда не пойдут в реальный API?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3