Как тестировать HTTP-запросы к внешним сервисам в Python без живого API
Почему внешние HTTP-вызовы ломают тесты
Представьте: ваш тест проходит локально, но падает в CI. Причина — платёжный шлюз недоступен из GitHub Actions, или CRM вернула 503 именно в момент прогона. Внешние HTTP-запросы превращают детерминированные тесты в лотерею: зависимость от сети, от квот API, от стоимости каждого вызова к AI-сервису.
Проблема не только в надёжности. Тест, который делает реальный запрос к платёжному API, стоит денег. Тест, который дёргает AI-сервис, расходует токены. Тест, который ждёт ответа от медленного CRM, превращает пятиминутный прогон в двадцатиминутный. А в CI ещё добавьте latency корпоративного VPN или ограничения фаервола — и получите тест-сюит, который «иногда зелёный».
Решение — мокировать HTTP-уровень. Не бизнес-логику, не интерфейс клиента, а именно транспортный слой. Тогда тест проверяет ваш код, а не чужой API. И делает это предсказуемо, быстро, без сети.
Эта статья — практическая схема: какой инструмент выбрать под ваш стек, минимальные рабочие примеры для трёх сценариев, типичные ошибки и чек-лист для небольшого проекта.
Правило выбора инструмента
Выбор инструмента определяется одним вопросом: какой HTTP-клиент использует ваш код — не тест, а само приложение.
- requests → используйте requests-mock
- httpx → используйте pytest-httpx
- FastAPI endpoint → используйте TestClient (встроен в FastAPI/Starlette) плюс при необходимости мок HTTP-клиента внутри endpoint
Попытка применить requests-mock к httpx-коду не сработает — они перехватывают разные транспортные уровни. Это не вопрос вкуса, это архитектурное ограничение. Точно так же pytest-httpx не поможет, если продакшн-код использует синхронный requests.
Если в проекте смешаны оба клиента — нужны оба инструмента. Это сигнал к тому, чтобы стандартизировать HTTP-клиент в кодовой базе, но до этого момента — два инструмента, два набора тестов.
requests-mock: минимальный пример
Установка:
pip install requests-mock
Допустим, у вас есть функция, которая вызывает платёжное API и проверяет статус транзакции:
import requests
def check_payment_status(transaction_id: str) -> dict:
url = f"https://payments.example.com/transactions/{transaction_id}"
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
Тест с requests-mock через контекстный менеджер:
import requests_mock as req_mock
from myapp.payments import check_payment_status
def test_payment_success():
with req_mock.Mocker() as m:
m.get(
"https://payments.example.com/transactions/txn_123",
json={"status": "completed", "amount": 1500},
status_code=200,
)
result = check_payment_status("txn_123")
assert result["status"] == "completed"
Тот же тест через декоратор — удобно, когда несколько тестов работают с одним набором моков:
import requests_mock as req_mock
from myapp.payments import check_payment_status
@req_mock.Mocker()
def test_payment_success(m):
m.get(
"https://payments.example.com/transactions/txn_123",
json={"status": "completed", "amount": 1500},
)
result = check_payment_status("txn_123")
assert result["status"] == "completed"
Важный момент: requests-mock перехватывает запросы на уровне транспортного адаптера библиотеки requests. Внутри контекста Mocker() любой запрос к URL, который не зарегистрирован, вызовет ConnectionError. Это защита от случайных реальных запросов в тестах — если вы не зарегистрировали мок, тест упадёт с понятной ошибкой, а не с непредсказуемым ответом внешнего сервиса.
Также стоит знать: requests-mock ведёт историю всех перехваченных запросов в m.request_history. Это позволяет проверить не только результат, но и факт самого запроса — сколько раз был вызван endpoint, какие параметры были переданы, какой метод использовался.
pytest-httpx: пример для httpx-кода
Установка:
pip install pytest-httpx
Допустим, ваш сервис общается с CRM через httpx:
import httpx
def get_contact(contact_id: str) -> dict:
with httpx.Client(timeout=10) as client:
resp = client.get(f"https://crm.example.com/contacts/{contact_id}")
resp.raise_for_status()
return resp.json()
Тест через фикстуру pytest-httpx (фикстура httpx_mock подключается автоматически):
from myapp.crm import get_contact
def test_get_contact(httpx_mock):
httpx_mock.add_response(
url="https://crm.example.com/contacts/c_42",
json={"id": "c_42", "name": "Иван Петров"},
status_code=200,
)
result = get_contact("c_42")
assert result["name"] == "Иван Петров"
pytest-httpx работает похожим образом: незарегистрированные запросы вызывают ошибку, все зарегистрированные отдают заданный ответ. Интерфейс немного отличается от requests-mock — вместо метода на объекте-менеджере здесь фикстура pytest, которая прозрачно подключается через механизм зависимостей pytest. Дополнительного конфига не нужно, достаточно добавить httpx_mock в сигнатуру тест-функции.
Важное отличие от requests-mock: pytest-httpx поддерживает как синхронный httpx.Client, так и асинхронный httpx.AsyncClient. Если ваш FastAPI endpoint использует асинхронный клиент, это правильный выбор.
FastAPI: TestClient и dependency override
Если у вас FastAPI-приложение, и endpoint сам вызывает внешний сервис, есть два подхода.
Подход 1 — dependency override. Вынесите HTTP-клиент в зависимость и подменяйте её в тестах:
# app/dependencies.py
import httpx
def get_http_client():
return httpx.Client()
# app/main.py
from fastapi import FastAPI, Depends
from app.dependencies import get_http_client
app = FastAPI()
@app.get("/weather")
def weather(city: str, client: httpx.Client = Depends(get_http_client)):
resp = client.get(f"https://weather.example.com/api?city={city}")
return resp.json()
Тест с подменой зависимости:
from fastapi.testclient import TestClient
from unittest.mock import MagicMock
from app.main import app
from app.dependencies import get_http_client
def test_weather_endpoint():
mock_client = MagicMock()
mock_client.get.return_value.json.return_value = {"temp": 22, "city": "Moscow"}
mock_client.get.return_value.status_code = 200
app.dependency_overrides[get_http_client] = lambda: mock_client
client = TestClient(app)
response = client.get("/weather?city=Moscow")
assert response.status_code == 200
assert response.json()["temp"] == 22
app.dependency_overrides.clear()
Подход 2 — pytest-httpx внутри TestClient. Если клиент создаётся внутри endpoint, используйте httpx_mock совместно с TestClient: фикстура перехватит запросы на уровне httpx-транспорта. Это полезно, когда рефакторить зависимости нет времени, а тест нужен прямо сейчас.
Оба подхода допустимы. Dependency override предпочтительнее: он явный, легко читается и не зависит от деталей реализации HTTP-клиента. Второй подход — когда нет возможности изменить структуру кода.
Что тестировать кроме 200
Самая распространённая ошибка — мокировать только успешный сценарий и считать, что тесты покрыты. Реальные системы падают по-разному, и ваши тесты должны это отражать. Хороший набор тестов проверяет не только «что происходит, когда всё хорошо», но и «что делает наш код, когда что-то идёт не так».
Три сценария, которые встречаются в каждом продакшн-проекте:
Сценарий: платёжный API вернул 500
def test_payment_server_error():
with req_mock.Mocker() as m:
m.post(
"https://payments.example.com/charge",
status_code=500,
json={"error": "internal_server_error"},
)
with pytest.raises(requests.HTTPError):
charge_card("card_123", amount=1000)
Сценарий: CRM медленно отвечает (таймаут)
import requests.exceptions
def test_crm_timeout():
with req_mock.Mocker() as m:
m.get(
"https://crm.example.com/contacts/c_1",
exc=requests.exceptions.Timeout,
)
with pytest.raises(requests.exceptions.Timeout):
get_contact("c_1")
Сценарий: AI-сервис исчерпал лимит (429)
def test_ai_rate_limit():
with req_mock.Mocker() as m:
m.post(
"https://api.openai.com/v1/chat/completions",
status_code=429,
json={"error": {"type": "rate_limit_exceeded"}},
headers={"Retry-After": "30"},
)
result = call_ai_with_retry(prompt="Hello")
# проверяем, что наш код корректно обработал 429
assert result is None # или что там ожидается
Обратите внимание: в последнем примере передаются и заголовки. Если ваш код читает Retry-After из ответа — тест должен это отражать. Мок без нужного заголовка проверяет не тот сценарий, который будет в продакшне. Забытые заголовки — вторая по частоте ошибка при написании моков, после игнорирования ошибочных статусов.
Третья типичная ошибка — привязка мока к полному жёстко заданному URL вместо конфигурируемого base URL. Когда вы переходите с тестового окружения на продакшн или меняете версию API, тест начинает тестировать несуществующий endpoint. Используйте те же константы для URL в тестах и в приложении.
Границы моков: где они перестают помогать
Моки — не серебряная пуля. Важно понимать, где они заканчиваются и начинается реальная проверка.
- Моки не проверяют контракт API. Если внешний сервис изменил структуру ответа, ваши тесты с захардкоженным JSON этого не заметят. Для этого нужны contract tests (Pact) или периодические интеграционные прогоны.
- Полный URL имеет значение. Распространённая ошибка — зарегистрировать мок для
https://api.example.com/v1/users, а в продакшн-коде использовать/v2/users. Тест пройдёт, продакшн упадёт. Выносите base URL в конфиг и используйте его и в коде, и в тестах. - Моки не тестируют сетевые особенности. Реальные редиректы, TLS-ошибки, chunked transfer encoding — всё это за пределами моков.
- Не мокируйте то, чем не управляете. Если вы мокируете внутренние модули вашего же кода — это признак проблем с архитектурой, а не с тестами.
Чек-лист для малого проекта
- Определить HTTP-клиент — requests или httpx. Выбрать соответствующую библиотеку для моков.
- Изолировать HTTP-клиент — вынести в зависимость или фабричный метод, чтобы можно было подменять.
- Тестировать 200 + ошибки — минимальный набор: успех, 4xx (клиентская ошибка), 5xx (серверная ошибка), таймаут.
- Включать заголовки, если код их читает — если ваш retry-механизм смотрит на
Retry-After, мок должен его возвращать. - Не хардкодить URL в тестах — использовать ту же константу/конфиг, что и в приложении.
- Проверять историю запросов — requests-mock сохраняет все перехваченные запросы в
m.request_history. Убедитесь, что ваш код действительно сделал POST, а не GET. - Запускать интеграционные тесты отдельно — один раз в сутки против реального API, с настоящими credentials. Это поймает дрейф контракта.
- Не мокировать в продакшн-коде — условия
if testing:в бизнес-логике — антипаттерн. Всё через dependency injection или конфигурацию.
Источник: requests-mock — документация