Введение

Я уже несколько лет работаю с backend-приложениями на Python, и очень часто эти сервисы работают не только с обычной базой данных. Практически всегда приложениям нужно отправлять запросы во внешних сервис.

Это может быть платежный сервис, пользовательский сервис, сервис доставки, сервис уведомлений или другой внутренний сервис.

Обычно при написании тестов для функционала, который использует внешние сервисы, используют mocker.patch. Таким образом когда в приложении есть метод, который отправляет запрос во внешний сервис, этот метод всегда можно заменить в тесте и он будет возвращать заранее подготовленные данные.

Данный подход достаточно легкий в использовании, но я не считаю, что он всегда должен быть подходом по умолчанию. Проблема в том, что иногда мы замещаем слишком много.

В этой статье я хочу показать другой подход – вместо того чтобы подменять ответ от внешнего сервиса, можно мокировать сам внешний HTTP-сервис.

В примере есть два клиента, с помощью которых осуществляются внешние запросы:

  • один клиент использует requests;

  • другой клиент использует httpx.

Поэтому я покажу два типа моков:

  • requests-mock для клиента на requests;

  • pytest-httpx для клиента на httpx.

Идея в обоих случаях одна и та же, отличается только инструмент.

Пример приложения

Рассмотрим приложение на FastAPI (но это может быть любой другой фреймворк). Допустим помимо простых CRUD методов, у нас есть более усложненные . Эти методы работают с локальной базой данных, а затем отправляют данные во внешний сервис.

Например, этот метод возвращает один item с дополнительными данными из внешнего сервиса:

@app.get(
    "/items/{client_type}/detailed/{item_id}",
    summary="Get an item with external service data",
    status_code=200,
    response_model=schemas.ItemDetailedSchema,
)
def get_item_detailed(
    client_type: schemas.ClientType,
    item_id: int,
) -> schemas.ItemDetailedSchema:
    if queries.get_item(item_id=item_id) is None:
        raise HTTPException(status_code=404, detail="Item not found")

    if client_type == schemas.ClientType.httpx:
        return external_httpx_client.get_item(item_id=item_id)
    else:
        return external_requests_client.get_item(item_id=item_id)

Метод выбирает нужный клиент по передоенному параметру client_type.

Также есть более интересные методы — это PATCH и DELETE, потому что они изменяют данные. Например, детальный PATCH метод обновляет данные в локальной базе, а затем отправляет полный объект item во внешний сервис:

@app.patch(
    "/items/{client_type}/detailed/{item_id}",
    summary="Update an item with external service call",
    status_code=200,
    response_model=schemas.ItemDetailedSchema,
)
def update_item_detailed(
    client_type: schemas.ClientType,
    item_id: int,
    update_data: schemas.ItemDetailedBaseSchema = Body(
        ...,
        embed=True,
    ),
) -> schemas.ItemDetailedSchema:
    if queries.get_item(item_id=item_id) is None:
        raise HTTPException(status_code=404, detail="Item not found")

    item = queries.update_item(
        item_id=item_id,
        update_data=schemas.ItemBaseSchema(
            name=update_data.name,
            number=update_data.number,
            is_valid=update_data.is_valid,
        ),
    )

    full_item_data = {
        **item,
        "description": update_data.description,
        "tags": update_data.tags,
        "external_id": update_data.external_id,
    }

    if client_type == schemas.ClientType.httpx:
        return external_httpx_client.update_item(item_id=item_id, item_data=full_item_data)
    else:
        return external_requests_client.update_item(item_id=item_id, item_data=full_item_data)

Детальный DELETE метод также состоит не из одного шага:

@app.delete(
    "/items/{client_type}/detailed/{item_id}",
    summary="Delete an item with external service call",
    status_code=204,
)
def delete_item_detailed(
    client_type: schemas.ClientType,
    item_id: int,
) -> None:
    if queries.get_item(item_id=item_id) is None:
        raise HTTPException(status_code=404, detail="Item not found")

    queries.delete_item(item_id=item_id)

    if client_type == schemas.ClientType.httpx:
        external_httpx_client.delete_item(item_id=item_id)
    else:
        external_requests_client.delete_item(item_id=item_id)

На первый взгляд этот код довольно простой. Но в нем все равно есть несколько мест, где может появиться ошибка:

  • приложение может отправить во внешний сервис неправильный объект;

  • приложение неверно обработало ответ от внешнего сервиса

  • клиент может вызвать неправильный URL;

  • клиент может использовать неправильный HTTP-метод;

  • клиент может неправильно обработать ситуацию, когда внешний сервис вернул 404.

Внешние клиенты

Наше приложение использует клиент, использующий библиотеку requests. Эти методы получают, обновляют и удаляют данные item во внешнем сервисе:

class ExternalRequestsClient:
    base_url = settings.EXTERNAL_SERVICE_URL

    def get_item(self, item_id: int) -> schemas.ItemDetailedSchema:
        response = requests.get(f"{self.base_url}/items/{item_id}/")
        if response.status_code == 404:
            raise HTTPException(status_code=404, detail="Item not found in external service")
        response.raise_for_status()
        return schemas.ItemDetailedSchema(**response.json())

    def update_item(self, item_id: int, item_data: dict) -> schemas.ItemDetailedSchema:
        response = requests.patch(
            f"{self.base_url}/items/{item_id}/",
            json=item_data,
        )
        if response.status_code == 404:
            raise HTTPException(status_code=404, detail="Item not found in external service")
        response.raise_for_status()
        return schemas.ItemDetailedSchema(**response.json())

    def delete_item(self, item_id: int) -> None:
        response = requests.delete(f"{self.base_url}/items/{item_id}/")
        if response.status_code == 404:
            raise HTTPException(status_code=404, detail="Item not found in external service")
        response.raise_for_status()

Клиент на httpx выполняет ту же задачу, но использует другую библиотеку:

class ExternalHttpxClient:
    base_url = settings.EXTERNAL_SERVICE_URL

    def get_item(self, item_id: int) -> schemas.ItemDetailedSchema:
        with httpx.Client() as client:
            response = client.get(f"{self.base_url}/items/{item_id}/")
        if response.status_code == 404:
            raise HTTPException(status_code=404, detail="Item not found in external service")
        response.raise_for_status()
        return schemas.ItemDetailedSchema(**response.json())

    def update_item(self, item_id: int, item_data: dict) -> schemas.ItemDetailedSchema:
        with httpx.Client() as client:
            response = client.patch(
                f"{self.base_url}/items/{item_id}/",
                json=item_data,
            )
        if response.status_code == 404:
            raise HTTPException(status_code=404, detail="Item not found in external service")
        response.raise_for_status()
        return schemas.ItemDetailedSchema(**response.json())

    def delete_item(self, item_id: int) -> None:
        with httpx.Client() as client:
            response = client.delete(f"{self.base_url}/items/{item_id}/")
        if response.status_code == 404:
            raise HTTPException(status_code=404, detail="Item not found in external service")
        response.raise_for_status()

В обоих клиентах кода немного, но он отвечает за несколько вещей:

  • построение URL;

  • выбор HTTP-метода;

  • отправку тела запроса;

  • обработку статусов ответа;

  • преобразование JSON в схему.

Если в тесте заменить целый метод целиком, эта логика не будет проверяться, что может привести к пробелам.

Обычный подход к тестированию через mocker.patch

Один из распространенных способов тестировать такие методы — напрямую подменять метод клиента.

Для клиента на requests тест может выглядеть так:

def test_patch_item_detailed_requests(
    fastapi_test_client,
    mocker,
):
    item = factories.models_factory.ItemModelFactory.create()
    update_data = factories.schemas_factory.ItemDetailedBaseSchemaFactory.create()
    expected = factories.schemas_factory.ItemDetailedSchemaFactory.create(id=item.id)

    mocker.patch.object(external_requests_client, "update_item", return_value=expected)

    response = fastapi_test_client.patch(
        f"/items/requests/detailed/{item.id}",
        data=json.dumps({"update_data": update_data.dict()}, default=str),
    )

    assert response.status_code == status.HTTP_200_OK
    assert response.json() == expected.dict()

Для клиента на httpx идея теста такая же:

def test_patch_item_detailed_httpx(
    fastapi_test_client,
    mocker,
):
    item = factories.models_factory.ItemModelFactory.create()
    update_data = factories.schemas_factory.ItemDetailedBaseSchemaFactory.create()
    expected = factories.schemas_factory.ItemDetailedSchemaFactory.create(id=item.id)

    mocker.patch.object(external_httpx_client, "update_item", return_value=expected)

    response = fastapi_test_client.patch(
        f"/items/httpx/detailed/{item.id}",
        data=json.dumps({"update_data": update_data.dict()}, default=str),
    )

    assert response.status_code == status.HTTP_200_OK
    assert response.json() == expected.dict()

На первый взгляд такие тесты выглядят нормально так как они короткие и проверяют функциональность.

Но есть несколько проблем, которые такие тесты не проверяют:

  • какой URL был создан клиентом;

  • какой HTTP-метод был использован;

  • какое тело запроса было отправлено во внешний сервис;

  • как клиент обработал 404 от внешнего сервиса;

  • были ли внешние данные действительно обновлены или удалены.

Для PATCH и DELETE это особенно важно по причине того, что эти методы изменяют данные. Поэтому я предпочитаю использовать фейковый внешний сервис со своим состоянием и проверять, что это состояние действительно изменилось.

Мокируем внешний сервис, а не метод клиента

Чтобы избежать реального сетевого запроса, мок все равно нужен, но его можно поставить ниже уровнем.

Вместо того чтобы заменять:

external_requests_client.update_item(...)

или:

external_httpx_client.update_item(...)

можно перехватить HTTP-запрос, который отправляют эти методы.

В результате приложение все еще выполняет:

  • логику метода;

  • логику работы с базой данных;

  • метод внешнего клиента;

  • построение URL;

  • сериализацию тела запроса;

  • обработку статусов ответа;

  • парсинг ответа.

Подменяется только внешний HTTP-сервис. Для этого можно использовать библиотеки:

  • requests-mock для requests;

  • pytest-httpx для httpx.

Хранение состояния внешнего сервиса внутри моков

Есть еще одна важная часть. Мок не обязан быть просто заранее подготовленным ответом. Он может хранить структуры данных и использовать их во время теста.

В этом репозитории оба мока имеют внутреннее хранилище:

self.items: tp.Dict[int, dict] = defaultdict(dict)

Это выглядит просто, но делает тесты заметно лучше. fixture начинает вести себя как маленький внешний сервис.

Перед запросом тест может подготовить внешнее состояние:

external_service_requests_mock.add_item(
    item_id=item.id,
    item_data=expected,
)

После этого разные методы работают с одной и той же структурой:

  • GET читает данные из self.items;

  • PATCH обновляет данные внутри self.items;

  • DELETE удаляет данные из self.items;

  • отсутствующий item возвращает 404.

Благодаря этому тест может проверять не только ответ от нашего API. Он может также проверять, что произошло внутри замокированного внешнего сервиса.

Например, после PATCH можно проверить, что данные действительно изменились:

assert external_service_requests_mock.items[item.id] == expected

После DELETE можно проверить, что данные были удалены:

assert item.id not in external_service_requests_mock.items

Это полезно, когда у нас есть знания, как должен вести себя внешний сервис. Например, мок может также хранить:

self.items: dict[int, dict] = {}
self.deleted_items: set[int] = set()
self.requests_log: list[dict] = []

Тогда можно тестировать больше сценариев:

  • item нельзя обновить после удаления;

  • дублирующийся external id возвращает 409;

  • запрос должен содержать обязательный header;

  • статус можно менять только в определенном порядке;

  • тело запроса должно содержать обязательные поля.

Целью не является полностью копировать внешний сервис в тестах. Но небольшая структура внутри мока может быть очень полезна так как позволяет тестировать контракт, который наше приложение ожидает от внешнего сервиса.

Реализация requests-mock

Для клиента на requests я создал fixture, который работает как маленький внешний сервис. Он хранит данные item в памяти и регистрирует callback-функции для разных HTTP-методов.

class ExternalServiceRequestsMock:
    base_url = app_settings.EXTERNAL_SERVICE_URL
    get_items_rexp = re.compile(base_url + r"/items/?$")
    item_rexp = re.compile(base_url + r"/items/([^/]+)/?$")

    def __init__(self, requests_mock):
        self.requests_mock = requests_mock
        self.items: tp.Dict[int, dict] = defaultdict(dict)

        requests_mock.get(self.get_items_rexp, json=self.get_items)
        requests_mock.get(self.item_rexp, json=self.get_item)
        requests_mock.post(self.get_items_rexp, json=self.create_item)
        requests_mock.patch(self.item_rexp, json=self.update_item)
        requests_mock.delete(self.item_rexp, json=self.delete_item)

    def add_item(self, item_id: int, item_data: dict):
        self.items[item_id] = item_data

Главная часть здесь — self.items. Эта fixture не является статической по причине наличия состояния. Она может имитировать GETPOSTPATCH и DELETE запросы.

URL сопоставляются через регулярные выражения:

get_items_rexp = re.compile(base_url + r"/items/?$")
item_rexp = re.compile(base_url + r"/items/([^/]+)/?$")

Первое регулярное выражение соответствует URL коллекции:

  • /items;

  • /items/.

Второе регулярное выражение соответствует одному item:

  • /items/1;

  • /items/1/;

  • /items/25;

  • /items/25/.

Самая важная часть здесь:

([^/]+)

Она означает:

  • [] описывает допустимые символы;

  • ^/ внутри [] означает любой символ, кроме /;

  • + означает один или больше символов;

  • () создает захватываемую группу.

То есть для такого URL:

/items/12/

это регулярное выражение захватит:

12

Так мок получает item_id из URL.

Например, callback для PATCH делает так:

(item_id,) = self.item_rexp.match(request.url).groups()

.groups() возвращает все захваченные группы в виде tuple. В нашем случае захваченная группа только одна, поэтому результат выглядит так:

("12",)

Именно поэтому в коде используется:

(item_id,) = ...

Затем item_id преобразуется в int и используется как ключ в self.items.

Роутинг создается этими строками:

requests_mock.get(self.get_items_rexp, json=self.get_items)
requests_mock.get(self.item_rexp, json=self.get_item)
requests_mock.post(self.get_items_rexp, json=self.create_item)
requests_mock.patch(self.item_rexp, json=self.update_item)
requests_mock.delete(self.item_rexp, json=self.delete_item)

Эти строки не вызывают callback-функции сразу, а регистрируют правила.

Например:

requests_mock.patch(self.item_rexp, json=self.update_item)

означает:

  • если метод запроса — PATCH;

  • и URL подходит под self.item_rexp;

  • тогда нужно вызвать self.update_item;

  • а возвращенное значение использовать как JSON-ответ.

В результате fixture работает как маленький router:

  • GET /items/ вызывает get_items;

  • GET /items/{item_id}/ вызывает get_item;

  • POST /items/ вызывает create_item;

  • PATCH /items/{item_id}/ вызывает update_item;

  • DELETE /items/{item_id}/ вызывает delete_item.

Callback для PATCH содержит реальную логику изменения состояния:

    def update_item(self, request, context):
        (item_id,) = self.item_rexp.match(request.url).groups()
        item_id = int(item_id)
        if item_id not in self.items:
            context.status_code = 404
            return {"detail": f"Item {item_id} does not exist"}

        self.items[item_id].update(json.loads(request.body))
        context.status_code = 200
        return self.items[item_id]

Callback для DELETE тоже изменяет состояние:

    def delete_item(self, request, context):
        (item_id,) = self.item_rexp.match(request.url).groups()
        item_id = int(item_id)
        if item_id not in self.items:
            context.status_code = 404
            return {"detail": f"Item {item_id} does not exist"}

        self.items.pop(item_id)
        context.status_code = 200
        return {"status": "success"}

ExternalServiceRequestsMock можно получить обычным способом определив его как fixture для дальнейшего использования в тестах:

@pytest.fixture
def external_service_requests_mock(requests_mock):
    return ExternalServiceRequestsMock(requests_mock=requests_mock)

Тестирование PATCH с requests-mock

Теперь можно протестировать детальный PATCH метод без подмены метода клиента.

def test_patch_item_detailed_requests(
    fastapi_test_client,
    external_service_requests_mock,
):
    item = factories.models_factory.ItemModelFactory.create()
    external_service_requests_mock.add_item(item_id=item.id, item_data={"id": item.id})

    update_data = factories.schemas_factory.ItemDetailedBaseSchemaFactory.create()

    response = fastapi_test_client.patch(
        f"/items/requests/detailed/{item.id}",
        data=json.dumps({"update_data": update_data.dict()}, default=str),
    )

    assert response.status_code == status.HTTP_200_OK

    expected = {
        "id": item.id,
        "name": update_data.name,
        "number": update_data.number,
        "is_valid": update_data.is_valid,
        "description": update_data.description,
        "tags": update_data.tags,
        "external_id": update_data.external_id,
    }

    assert external_service_requests_mock.items[item.id] == expected
    assert response.json() == expected

В этом тесте item создается в двух местах

  • в локальной тестовой базе данных;

  • в моке внешнего сервиса.

После этого вызывается метод и во время этого запроса:

  • метод обновляет локальный item;

  • метод создает full_item_data;

  • метод вызывает ExternalRequestsClient.update_item;

  • клиент отправляет PATCH запрос;

  • requests-mock перехватывает этот запрос;

  • callback обновляет external_service_requests_mock.items.

Именно это и полезно, что тест проверяет не только ответ метода, но и состояние фейкового внешнего сервиса.

Тестирование DELETE с requests-mock

Та же идея работает и для DELETE.

def test_delete_item_detailed_requests(
    fastapi_test_client,
    external_service_requests_mock,
):
    item = factories.models_factory.ItemModelFactory.create()
    external_service_requests_mock.add_item(item_id=item.id, item_data={"id": item.id})

    response = fastapi_test_client.delete(f"/items/requests/detailed/{item.id}")

    assert response.status_code == status.HTTP_204_NO_CONTENT
    assert item.id not in external_service_requests_mock.items

Здесь проверка простая:

  • до запроса item существует в моке внешнего сервиса;

  • после запроса его там быть не должно.

С mocker.patch.object(external_requests_client, "delete_item", return_value=None) эта часть не проверялась бы.

Тестирование внешнего 404 с requests-mock

Еще один полезный сценарий — ситуация, когда локальный item существует, но во внешнем сервисе связанных данных нет.

def test_patch_item_detailed_requests_external_not_found(
    fastapi_test_client,
    external_service_requests_mock,
):
    item = factories.models_factory.ItemModelFactory.create()
    update_data = factories.schemas_factory.ItemDetailedBaseSchemaFactory.create()

    response = fastapi_test_client.patch(
        f"/items/requests/detailed/{item.id}",
        data=json.dumps({"update_data": update_data.dict()}, default=str),
    )

    assert response.status_code == status.HTTP_404_NOT_FOUND

В этом тесте:

  • item создается в локальной базе данных;

  • item не создается в моке внешнего сервиса;

  • callback возвращает 404;

  • настоящий клиент на requests получает этот ответ;

  • клиент выбрасывает HTTPException.

Это лучше, чем вручную задавать side_effect у подмененного метода, потому что используется тот же путь обработки ошибки, что и в настоящем клиенте.

Реализация pytest-httpx

Для клиента на httpx идея такая же, но реализация отличается по причине другого API у библиотеки.

class ExternalServiceHttpxMock:
    base_url = app_settings.EXTERNAL_SERVICE_URL
    get_items_rexp = re.compile(base_url + r"/items/?$")
    item_rexp = re.compile(base_url + r"/items/([^/]+)/?$")

    def __init__(self, httpx_mock):
        self.requests_mock = httpx_mock
        self.items: tp.Dict[int, dict] = defaultdict(dict)

        httpx_mock.add_callback(
            callback=self.get_items,
            method="GET",
            url=self.get_items_rexp,
            is_optional=True,
        )
        httpx_mock.add_callback(
            callback=self.get_item,
            method="GET",
            url=self.item_rexp,
            is_optional=True,
        )
        httpx_mock.add_callback(
            callback=self.create_item,
            method="POST",
            url=self.get_items_rexp,
            is_optional=True,
        )
        httpx_mock.add_callback(
            callback=self.update_item,
            method="PATCH",
            url=self.item_rexp,
            is_optional=True,
        )
        httpx_mock.add_callback(
            callback=self.delete_item,
            method="DELETE",
            url=self.item_rexp,
            is_optional=True,
        )

Используются те же два регулярных выражения:

get_items_rexp = re.compile(base_url + r"/items/?$")
item_rexp = re.compile(base_url + r"/items/([^/]+)/?$")

То есть логика сопоставления URL такая же, как в fixture для requests-mock. Разница только в регистрации callback-функций.

Для requests-mock код выглядит так:

requests_mock.patch(self.item_rexp, json=self.update_item)

Для pytest-httpx код выглядит так:

httpx_mock.add_callback(
    callback=self.update_item,
    method="PATCH",
    url=self.item_rexp,
    is_optional=True,
)

Обе строки означают почти одно и то же:

  • сопоставить HTTP-метод;

  • сопоставить URL через regex;

  • вызвать callback;

  • вернуть ответ из callback.

Callback для PATCH в httpx выглядит так:

    def update_item(self, request):
        (item_id,) = self.item_rexp.match(str(request.url)).groups()
        item_id = int(item_id)
        if item_id not in self.items:
            return httpx.Response(
                status_code=404,
                json={"detail": f"Item {item_id} does not exist"},
            )

        self.items[item_id].update(json.loads(request.content))
        return httpx.Response(status_code=200, json=self.items[item_id])

Здесь есть два небольших отличия от версии с requests-mock.

Первое отличие — тип URL. В requests-mock request.url уже является строкой. В httpx request.url — это объект httpx.URL. Regex работает со строками, поэтому используется:

str(request.url)

Именно поэтому строка выглядит так:

(item_id,) = self.item_rexp.match(str(request.url)).groups()

Второе отличие — тело запроса. В requests-mock тело читается из:

request.body

В httpx тело читается из:

request.content

Поэтому метод обновления использует:

json.loads(request.content)

Callback для DELETE использует такое же сопоставление URL:

    def delete_item(self, request):
        (item_id,) = self.item_rexp.match(str(request.url)).groups()
        item_id = int(item_id)
        if item_id not in self.items:
            return httpx.Response(
                status_code=404,
                json={"detail": f"Item {item_id} does not exist"},
            )

        self.items.pop(item_id)
        return httpx.Response(status_code=200, json={"status": "success"})

И fixture возвращается из pytest:

@pytest.fixture
def external_service_httpx_mock(httpx_mock):
    return ExternalServiceHttpxMock(httpx_mock=httpx_mock)

Тестирование PATCH с pytest-httpx

Тест для httpx почти такой же, как тест для requests. Отличаются только путь URL и fixture.

def test_patch_item_detailed_httpx(
    fastapi_test_client,
    external_service_httpx_mock,
):
    item = factories.models_factory.ItemModelFactory.create()
    external_service_httpx_mock.add_item(item_id=item.id, item_data={"id": item.id})

    update_data = factories.schemas_factory.ItemDetailedBaseSchemaFactory.create()

    response = fastapi_test_client.patch(
        f"/items/httpx/detailed/{item.id}",
        data=json.dumps({"update_data": update_data.dict()}, default=str),
    )

    assert response.status_code == status.HTTP_200_OK

    expected = {
        "id": item.id,
        "name": update_data.name,
        "number": update_data.number,
        "is_valid": update_data.is_valid,
        "description": update_data.description,
        "tags": update_data.tags,
        "external_id": update_data.external_id,
    }

    assert external_service_httpx_mock.items[item.id] == expected
    assert response.json() == expected

В этом тесте:

  • метод вызывает ExternalHttpxClient.update_item;

  • клиент отправляет PATCH запрос через httpx;

  • pytest-httpx сопоставляет запрос по методу и URL;

  • callback обновляет external_service_httpx_mock.items;

  • тест проверяет ответ и состояние фейкового внешнего сервиса.

То есть идея такая же, как с requests-mock. Отличается только библиотека для мокирования.

Тестирование DELETE с pytest-httpx

Тест для DELETE тоже такой же по идее:

def test_delete_item_detailed_httpx(
    fastapi_test_client,
    external_service_httpx_mock,
):
    item = factories.models_factory.ItemModelFactory.create()
    external_service_httpx_mock.add_item(item_id=item.id, item_data={"id": item.id})

    response = fastapi_test_client.delete(f"/items/httpx/detailed/{item.id}")

    assert response.status_code == status.HTTP_204_NO_CONTENT
    assert item.id not in external_service_httpx_mock.items

Последний assert важная часть потому что проверяет, что состояние фейкового внешнего сервиса изменилось.

Тестирование внешего 404 с pytest-httpx

Тот же сценарий с 404 можно протестировать и для httpx:

def test_patch_item_detailed_httpx_external_not_found(
    fastapi_test_client,
    external_service_httpx_mock,
):
    item = factories.models_factory.ItemModelFactory.create()
    update_data = factories.schemas_factory.ItemDetailedBaseSchemaFactory.create()

    response = fastapi_test_client.patch(
        f"/items/httpx/detailed/{item.id}",
        data=json.dumps({"update_data": update_data.dict()}, default=str),
    )

    assert response.status_code == status.HTTP_404_NOT_FOUND

В этом случае:

  • локальный item существует;

  • во внешнем моке этого item нет;

  • callback возвращает httpx.Response(status_code=404, ...);

  • настоящий клиент на httpx получает этот ответ;

  • метод возвращает 404.

Сравнение requests-mock и pytest-httpx

Если сравнить обе реализации, идея у них одна и та же.

Для requests-mock роутинг регистрируется так:

requests_mock.patch(self.item_rexp, json=self.update_item)
requests_mock.delete(self.item_rexp, json=self.delete_item)

Для pytest-httpx роутинг регистрируется так:

httpx_mock.add_callback(
    callback=self.update_item,
    method="PATCH",
    url=self.item_rexp,
    is_optional=True,
)
httpx_mock.add_callback(
    callback=self.delete_item,
    method="DELETE",
    url=self.item_rexp,
    is_optional=True,
)

В requests-mock callback возвращает словарь и меняет context.status_code:

context.status_code = 200
return self.items[item_id]

В pytest-httpx callback возвращает httpx.Response:

return httpx.Response(status_code=200, json=self.items[item_id])

Но с точки зрения теста оба варианта позволяют делать одни и те же полезные проверки:

  • метод вызвал настоящий метод клиента;

  • HTTP-запрос был сопоставлен по методу и URL;

  • тело запроса было использовано мок-сервисом;

  • PATCH изменил фейковое внешнее состояние;

  • DELETE удалил данные из фейкового внешнего состояния;

  • 404 прошел через настоящую обработку ошибок клиента.

Почему этот подход полезен

Главное преимущество в том, что тесты не заменяют весь метод клиента, а заменяют только внешний HTTP-сервис.

Благодаря этому тесты все еще покрывают код, который подготавливает и отправляет запрос. Это полезно для обоих типов клиентов:

  • для requests мы проверяем код внутри ExternalRequestsClient;

  • для httpx мы проверяем код внутри ExternalHttpxClient.

Этот подход особенно полезен для методов, которые изменяют данные:

  • PATCH должен отправить ожидаемые данные;

  • DELETE должен удалить внешние данные;

  • 404 должен быть обработан настоящим клиентским кодом.

Тесты при этом остаются быстрыми:

  • нет реального сетевого запроса;

  • нет зависимости от настоящей внешней системы;

  • внешний сервис представлен fixture;

  • fixture ведет себя достаточно близко к контракту внешнего сервиса.

Итоговая структура тестов

Итоговая структура выглядят так:

tests/
├── api/
│   ├── test_detailed_httpx.py
│   ├── test_detailed_requests.py
│   ├── test_detailed_httpx_mocker.py
│   └── test_detailed_requests_mocker.py
├── fixtures/
│   ├── external_service_httpx.py
│   └── external_service_requests.py
└── conftest.py

Тесты с mocker.patch все еще могут существовать как пример более простого подхода. Но тесты с requests-mock и pytest-httpx проверяют больше, потому что выполняют больше настоящего кода приложения.

Заключение

В этой статье я хотел показать, как тестировать код, который отправляет HTTP-запросы во внешние сервисы.

Самый простой способ — заменить метод клиента через mocker.patch. Это может быть полезно, но у такого подхода есть недостаток. Если заменить весь метод целиком, тест не проверяет:

  • построение URL;

  • HTTP-метод;

  • тело запроса;

  • обработку статусов ответа;

  • парсинг ответа.

Другой подход — мокировать внешний HTTP-сервис. Для requests можно использовать requests-mock. Для httpx можно использовать pytest-httpx.

Этот подход особенно полезен для PATCH и DELETE запросов. Такие запросы изменяют данные, поэтому лучше проверять не только статус ответа, но и состояние замокированного внешнего сервиса.

Основные идеи которыми я хотел поделиться это:

  • Не стоит всегда начинать с подмены собственного метода клиента.

  • Для HTTP-клиентов часто лучше мокировать HTTP-слой.

  • Проверяйте данные ответа, а не только статус-код.

  • Для PATCH и DELETE лучше еще проверять что состояние мокированного сервиса действительно изменилось.

Надеюсь, статья была полезной. Пример репозитория можно посмотреть здесь: fastapi-project-blueprint.

Комментарии (0)