
Введение
Я уже несколько лет работаю с 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 не является статической по причине наличия состояния. Она может имитировать GET, POST, PATCH и 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.