Как тестировать 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 — документация

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *