Тесты для кода с сетевыми вызовами в Python: приёмы и цена
Тест, который дёргает реальный API, падает от чужого таймаута и медленный. Разбираем четыре способа изолировать сеть в pytest: monkeypatch, responses, respx и запись реальных ответов через VCR.

Тест, который делает настоящий requests.get к чужому API, ломается по причинам, к вашему коду не относящимся: у них лёг сервер, сменилась схема ответа, сработал rate limit, отвалился DNS в CI. Такой тест либо мигает (flaky), либо тормозит весь прогон на секунды ожидания. Задача — проверять свою логику разбора ответа и обработки ошибок, а сеть при этом не трогать. Ниже — четыре подхода на Python 3.11+, для каждого своя цена.
Почему нельзя просто ходить в сеть
Кажется логичным: тест проверяет функцию, функция ходит в API — пусть ходит по-настоящему. Проблемы вылезают сразу:
- Недетерминизм. Ответ внешнего сервиса меняется. Сегодня список из 3 элементов, завтра из 5 — тест на длину падает.
- Скорость. Сотня тестов по 200 мс сетевой задержки — это 20 секунд на ровном месте.
- Хрупкость CI. В пайплайне сети до внешних адресов может не быть вовсе, или она за прокси.
- Побочные эффекты. POST/DELETE в чужую систему из теста — прямой путь к порче данных.
Значит, HTTP-слой нужно подменить. Вопрос — на каком уровне и чем.
Что будем тестировать
Возьмём типичную функцию-клиент на библиотеке requests (актуальная ветка 2.x):
# client.py
import requests
API_URL = "https://api.example.com/users"
def get_active_users(session: requests.Session) -> list[str]:
"""Возвращает логины активных пользователей."""
response = session.get(API_URL, timeout=5)
response.raise_for_status() # выбросит HTTPError на 4xx/5xx
data = response.json()
return [u["login"] for u in data["users"] if u["active"]]
Нам важны три сценария: успешный ответ разбирается правильно, 500 приводит к исключению, битый JSON не роняет всё молча. Сеть при этом не должна включаться ни разу.
Подход 1. monkeypatch — подменить метод вручную
Встроенная фикстура pytest. Вы заменяете Session.get своей функцией, возвращающей заранее собранный объект-ответ.
# test_client_monkeypatch.py
import json
import pytest
import requests
from client import get_active_users
class FakeResponse:
def __init__(self, payload: dict, status: int = 200):
self._payload = payload
self.status_code = status
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError(f"status {self.status_code}")
def test_returns_only_active(monkeypatch):
payload = {"users": [
{"login": "anna", "active": True},
{"login": "boris", "active": False},
]}
monkeypatch.setattr(
requests.Session, "get",
lambda self, url, **kw: FakeResponse(payload),
)
session = requests.Session()
assert get_active_users(session) == ["anna"]
Цена. Работает без единой зависимости и мгновенно. Но FakeResponse — это ваша выдумка про то, как устроен ответ requests. Если реальный Response ведёт себя иначе (например, вы забыли, что raise_for_status смотрит на диапазон), тест зелёный, а прод падает. Чем сложнее клиент, тем больше вы дублируете внутренности библиотеки в заглушке.
Подход 2. responses — мок на уровне HTTP requests
Библиотека responses перехватывает вызовы внутри requests и отдаёт настоящий Response, собранный из вашего тела и статуса. Заглушка исчезает — вы описываете HTTP, а не питоновские объекты.
# pip install responses
# test_client_responses.py
import responses
import requests
import pytest
from client import get_active_users, API_URL
@responses.activate
def test_active_users_parsed():
responses.add(
responses.GET, API_URL,
json={"users": [{"login": "anna", "active": True}]},
status=200,
)
assert get_active_users(requests.Session()) == ["anna"]
@responses.activate
def test_server_error_raises():
responses.add(responses.GET, API_URL, status=500)
with pytest.raises(requests.HTTPError):
get_active_users(requests.Session())
Здесь response.json(), status_code, raise_for_status — настоящие методы requests. Вы проверяете код так, как он работает в проде, минус транспорт. Плюс можно проверить, что запрос вообще был сделан и с нужными параметрами — через responses.calls.
Хорошая заглушка имитирует протокол, а не библиотеку. Как только вы описываете в тесте, что «у ответа есть метод json», вы тестируете свою модель requests, а не свой код.
Цена. Ещё одна зависимость и привязка к requests: для httpx или aiohttp responses не подходит.
Подход 3. respx — для httpx и async
Если код на httpx (частый выбор для async), берите respx. Он перехватывает транспорт httpx и работает и с синхронным, и с асинхронным клиентом.
# pip install respx
# клиент на httpx 0.27.x
import httpx
async def fetch_status(client: httpx.AsyncClient, url: str) -> int:
r = await client.get(url, timeout=5)
return r.status_code
# test_httpx.py
import httpx
import respx
import pytest
from myasync import fetch_status
@pytest.mark.asyncio
@respx.mock
async def test_fetch_status():
respx.get("https://api.example.com/ping").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient() as client:
assert await fetch_status(client, "https://api.example.com/ping") == 204
Для async-теста нужен плагин (pytest-asyncio или anyio) и маркер — детали настройки смотрите в документации выбранного плагина, синтаксис маркера между версиями pytest-asyncio менялся.
Подход 4. VCR — записать реальный ответ один раз
Библиотека vcrpy при первом прогоне делает настоящий запрос и сохраняет пару «запрос-ответ» в YAML-файл (кассету). При следующих прогонах сеть уже не трогается — ответ берётся из файла.
# pip install vcrpy
import vcr
from client import get_active_users
import requests
my_vcr = vcr.VCR(cassette_library_dir="tests/cassettes", record_mode="once")
@my_vcr.use_cassette("active_users.yaml")
def test_with_recorded_response():
result = get_active_users(requests.Session())
assert isinstance(result, list)
Плюс — вы получаете правдоподобный ответ реального API без ручного набора JSON. Минус и главная ловушка: в кассету попадают заголовки, включая Authorization и куки. Их обязательно вычищают через filter_headers, иначе токен уедет в git.
Кассета VCR — это снимок чужого API на конкретную дату. Когда API поменяет схему, ваш тест останется зелёным на старом снимке, а прод сломается. Кассеты надо перезаписывать осознанно, а не забывать про них на год.
Когда что выбирать
| Подход | Транспорт | Реалистичность | Когда брать |
|---|---|---|---|
| monkeypatch | любой | низкая | простой клиент, не хочется зависимостей |
| responses | requests | высокая | основной выбор для requests |
| respx | httpx | высокая | httpx, async-код |
| vcrpy | requests, httpx и др. | максимальная | сложные ответы, интеграционные проверки |
Практический критерий: для юнит-тестов бизнес-логики берите responses/respx — они дают настоящий объект ответа без магии кассет. VCR держите для отдельного набора интеграционных тестов, которые вы сознательно перезаписываете при обновлении API. monkeypatch — когда клиент в одну строку и тянуть библиотеку жаль.
Что ломается
- Незамоканный запрос уходит в сеть.
responsesпо умолчанию броситConnectionErrorна любой URL без заглушки — это фича, не баг. Так вы ловите забытые эндпоинты. - Патчите не там, где импортировано. Классическая ошибка monkeypatch: подменять надо объект в том модуле, где он используется, а не там, где определён.
- Таймауты не тестируются. Ни один из моков сам по себе не проверит поведение при медленном ответе. Для этого мок должен явно поднять
requests.Timeout/httpx.TimeoutException. - Секреты в кассетах VCR. Проверьте YAML перед первым коммитом глазами, не полагаясь только на фильтры.
Что делать дальше
Добавьте в CI запрет на реальные исходящие соединения из тестов — например, отдельным плагином, который блокирует сокеты, чтобы забытый мок падал сразу, а не мигал. Отдельно опишите тесты на сценарии ошибок: 429, 500, обрыв соединения, битый JSON — именно они всплывают в проде, а на них тестов чаще всего нет. Тесты ретраев и backoff — отдельная тема, здесь мы её не разворачивали: там нужен ещё и контроль времени через мок таймера.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: responses — документация на GitHub, respx — Mock HTTPX
Частые вопросы
А если у меня код на aiohttp, а не requests или httpx?
Тест мигает: то зелёный, то падает с ConnectionError. Что не так?
monkeypatch не срабатывает, вызывается настоящий метод. Почему?
Нужно ли вообще писать интеграционные тесты с реальной сетью?
Как проверить, что мой код правильно обрабатывает таймаут?
VCR-кассета устарела после обновления API. Как перезаписать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.