Создаем проксирующий мок на Python: эффективное автотестирование API

Введение

Тестирование API — неотъемлемая часть разработки современных веб-сервисов. Качественные автотесты помогают не только убедиться в правильности работы системы, но и быстро выявлять ошибки на этапе разработки. Однако создание и поддержка моков для автотестов часто становится трудоемким и ресурсозатратным процессом.

Как мы уже упоминали в других статьях, в нашей компании мы очень любим писать автотесты. Поэтому и эта статья не станет исключением для читателей нашего блога. Если вы автоматизатор тестирования API, то вам наверняка часто приходилось сталкиваться с написанием или поддержкой моков для какого-нибудь сервиса, и это зачастую отнимало много времени и ресурсов. Чтобы ускорить этот процесс, мы начали искать готовые решения, но не все так просто…

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

Основная часть

Изучив известные решения на GitHub, мы не нашли идеального инструмента для наших задач:

  • mitmproxy — поддерживает проксирование и манипуляцию трафиком, но слишком сложный в применении;

  • mockintosh — легковесный и простой в настройке, но требует настройки через конфигурационный файл и перестал поддерживаться в 2021 году;

  • mock-server — тоже простой и поддерживает различные протоколы, но имеет ограниченный функционал и написан на Java.

    Также для нас было важно иметь возможность адаптировать библиотеку под специфические требования наших API, такие как поддержка бинарных протоколов.

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

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

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

Схема тестового окружения с прокси-моком

Схема тестового окружения с прокси-моком

Исходный код доступен на GitHub:  proxy_mock.

Основной стек технологий:

  • Python 3.12 — основной язык программирования;

  • Flask 2.3.0 — фреймворк для обработки HTTP-запросов;

  • Requests 2.28.0 — библиотека для перенаправления и обработки запросов;

  • Gunicorn 20.1.0 — серверный фреймворк для запуска и управления приложением;

  • Poetry 1.8.2 — инструмент для управления зависимостями и виртуальными окружениями;

  • Docker — контейнеризация приложения для упрощения развертывания и масштабирования.

Установка и запуск

  1. Скачиваем проект к себе

    git clone https://github.com/ivi-ru/proxy_mock.git
  2. Запускаем прокси-мок с помощью Docker

    make docker_run

Приложение будет по дефолту запущено на порту 5000 (http://localhost:5000)

Больше подробностей в документации преокта:  https://github.com/ivi-ru/proxy_mock/blob/main/README.md

Какие возможности открывает прокси-мок?

  1. Мокирование входящих запросов
    Настройка моков осуществляется через запрос на эндпоинт /configure. В теле запроса в формате JSON можно указать URL-путь, тело ответа, код ответа, заголовки ответа, задержку ответа и дополнительные данные. Если требуется обработка бинарных данных, используется эндпоинт /configure/binary, где формат тела запроса остается JSON, но само содержимое body может быть бинарным объектом. Это позволяет вернуть в ответе данные любого типа, сериализованные в бинарный формат.

  2. Проксирование запросов
    Проксирование выполняется через те же эндпоинты /configure и /configure/binary. Для этого в запросе указывается параметр proxy_host, определяющий внешний хост, на который необходимо перенаправить запрос. Прокси-мок отправляет запрос на указанный хост с теми же входными данными и возвращает полный ответ: тело, заголовки и код ответа. Если задан хост для проксирования, данные, замокированные в прокси-моке, игнорируются, и вместо них возвращается ответ с внешнего сервиса.

  3. История запросов
    Историю всех запросов, проходящих через прокси-мок, можно просматривать через эндпоинт /traffic. Это помогает отслеживать данные, с которыми тестируемый сервис отправляет запросы. Дополнительно в истории отображается информация о моках, если она была передана, что полезно для отладки и тестирования.

Логика работы приложения устроена так, что любой запрос, за исключением «служебных» (они описаны в документации), перехватывается прокси-моком, который пытается найти подходящий мок в своем внутреннем хранилище. Если соответствие не найдено, сервер вернет ответ с кодом 404. Для управления моками доступны вспомогательные эндпоинты: для просмотра замокированных ответов (/storage), очистки хранилища моков (/storage/clean) и удаления истории запросов (/traffic/clean).

Для более детального изучения рекомендую глянуть на документацию:  https://github.com/ivi-ru/proxy_mock/blob/main/README.md.

От теории к практике

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

  • конфигурация мок-запросов

  • получение хранящихся данных

  • получение исторических данных о входящих мок-запросах

  • удаление хранилищ с накопленными данными  

Часть базового клиента проксирующего мока

Часть базового клиента проксирующего мока

Импортируя данный класс в проекте с автотестами, мы можем подготовить моки для тестов максимально просто — для этого опишем обращение с клиентом в файле conftest.py:

import pytest
from .proxy_mock.client import ProxyMock


@pytest.fixture(scope="session")
def proxy_mock():
    proxy_mock = ProxyMock("http://localhost:5000")
    return proxy_mock.get_traffic()


@pytest.fixture(scope="session")
def prepare_environment(proxy_mock: ProxyMock):
    proxy_mock.configure_mock(
        path="/example",
        body={
            "name": "John",
        },
        status_code=201,
        proxy_host=
    )
    proxy_mock.configure_mock(
        path="/orders",
        body="ok",
        status_code=200,
    )
    yield
    proxy_mock.clean_storage()


@pytest.fixture(autouse=True)
def prepare_before_test(proxy_mock: ProxyMock):
    proxy_mock.clean_traffic()

Краткое объяснение написанного:

  • В фикстуре proxy_mock происходит инициализация клиента прокси-мока,  работающего на localhost на порту 5000.

  • В фикстуре prepare_environment мы сначала подготавливаем все необходимые моки до запуска тестовой сессии, а после ее завершения все подготовленные данные будут удалены при помощи метода .clean_storage.

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

А вот пример менее простой реализации, зато более читаемой. Именно такая и используется в наших проектах:

"""Конфигурация моков прокси мока."""
from .proxy_mock.client import ProxyMock


PROXY_MOCK_URL = "http://localhost:5000"


class ProxyMockClient(ProxyMock):
    orders_endpoint = '/orders'
    customers_endpoint = '/customers'
    teachers_endpoint = '/teachers'

    def configure_orders_request(
            self,
            request_body: dict | str | None = None,
            request_headers: dict | None = None,
            request_status_code: int | None = None,
            **kwargs,
    ):
        return super().configure_mock(
            path=self.orders_endpoint,
            body=request_body or "default response",
            headers=request_headers or {'Content-Type': 'application/json'},
            status_code=request_status_code or 200,
            **kwargs,
        )

    def configure_customers_request(
            self,
            request_body: dict | str | None = None,
            request_headers: dict | None = None,
            request_status_code: int | None = None,
            **kwargs,
    ):
        return super().configure_mock(
            path=self.customers_endpoint,
            body=request_body or "default response",
            headers=request_headers or {'Content-Type': 'application/json'},
            status_code=request_status_code or 200,
            **kwargs,
        )

    def configure_teachers_request(
            self,
            request_body: dict | str | None = None,
            request_headers: dict | None = None,
            request_status_code: int | None = None,
            **kwargs,
    ):
        return super().configure_mock(
            path=self.teachers_endpoint,
            body=request_body or "default response",
            headers=request_headers or {'Content-Type': 'application/json'},
            status_code=request_status_code or 200,
            **kwargs,
        )


proxy_mock_client = ProxyMockClient(PROXY_MOCK_URL)

В коде выше показано, как для каждого мок-запроса создается своя конфигурация с использованием дефолтных параметров через наследование прокси-клиента из библиотеки и повторное использование готовых методов настройки. В таком случае conftest.py будет выглядеть нагляднее и проще:

import pytest
from mock_config import proxy_mock_client


@pytest.fixture(scope="session")
def prepare_environment():
    proxy_mock_client.configure_customers_request()
    proxy_mock_client.configure_orders_request()
    proxy_mock_client.configure_teachers_request()
    yield
    proxy_mock_client.clean_storage()


@pytest.fixture(autouse=True)
def prepare_before_test():
    proxy_mock_client.clean_traffic()

Переходим к примеру автотестов

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

В данном тесте происходит следующее:

  • Мокирование ответа
    Сначала мокаем ответ на запрос /customers/.

  • Запрос к тестируемому сервису
    Затем выполняем запрос к нашему тестируемому сервису для получения списка покупок.

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

  • Анализ запросов
    На этом этапе также проверяем количество запросов и их содержимое (тело запроса, заголовки и прочее).

import requests
from mock_config import proxy_mock_client


class TestOurService:

    def test_get_orders(self):
        proxy_mock_client.configure_customers_request(
            request_body={
                'customer_id': 1,
                'name': 'Vasya',
            }
        )

        response = requests.get('http://our.service.dev.ru/orders')
        assert response.ok
        assert response['orders'][0]['customer'] == 'Vasya'

        input_requests = proxy_mock_client.get_traffic()
        assert len(input_requests) == 1
        assert input_requests[0]['path'] == '/customers'

А в случае, если ваш запрос от сервиса реализован по протоколу gRPC и у вас имеется некий класс, формирующий нужный ответ, например, OrdersResponse, то тогда пригодится встроенная функция configure_binary_mock .

def configure_binary_get_customers(
    customer_id: str,
    name: str,
    request_headers: dict | None = None,
    request_status_code: int | None = None,
    **kwargs,
):
    return ProxyMock(PROXY_MOCK_URL).configure_binary_mock(
        path='/grpc/ShopService/GetCustomers',
        body=(
            OrdersResponse(customer_id=customer_id, name=name) or
            OrdersResponse(customer_id=0, name="")
        ),
        headers=request_headers or {'Content-Type': 'application/protobuf'},
        status_code=request_status_code or 200,
        **kwargs,
    )

А сам тест почти не будет отличаться от предыдущего. Изменятся лишь переданные аргументы и ожидаемый эндпоинт.

class TestOurService:

    def test_get_orders(self):
        proxy_mock_client.configure_binary_orders_request(
            customer_id=1
            name='Vasya'
        )

        response = requests.get('http://our.service.dev.ru/orders')
        assert response.ok
        assert response['orders'][0]['customer'] == 'Vasya'

        input_requests = proxy_mock_client.get_traffic()
        assert len(input_requests) == 1
        assert input_requests[0]['path'] == '/grpc/ShopService/GetOrders'

Рассмотрим еще один пример. Допустим, вам нужно написать автотесты для сервиса, который возвращает список курсов. Этот сервис, согласно бизнес-логике, делает запрос к другому внешнему сервису для получения информации об учителях, но поддерживать моки для него у вас просто не хватает времени. На помощь приходит функция проксирования! Осталось лишь передать нужный адрес в метод /configure

import requests
from mock_config import proxy_mock_client


class TestOurService:

    def test_get_courses(self):
        proxy_mock_client.configure_teachers_request(
            proxy_host='http://other.fast-service.dev.ru',
        )

        response = requests.get('http://our.service.dev.ru/courses')

        assert response.ok
        assert response['courses'][0]['teacher'] == 'Vasya'

        input_requests = proxy_mock_client.get_traffic()
        assert len(input_requests) == 1
        assert input_requests[0]['path'] == '/teachers'

И вот уже во втором тесте мы мокаем запрос /teachers таким образом,  чтобы он проксировался на адрес 'http://other.fast-service.dev.ru'. Далее мы просто проверяем успешность получения данных по курсам и наличие в одном из них данных учителя. И дополнительно проверяем, что запрос был в прокси-моке (proxy_mock_client.get_traffic ()), а значит именно он спроксировал его на внешний хост.

Ручное тестирование

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

1) Конфигурим запрос /customers:

curl --location 'http://localhost:5000/configure_mock' \
--header 'Content-Type: application/json' \
--data '{
    "path": "/customers",
    "extra_info": {
        "service": "our"
    },
    "mock_data": {
        "headers": {
            "OP": "header"
        },
        "body": {
                "customer_id": 1,
                "name": "Vasya"
            }
    }
}'

2) Опционально можем проверить наличие и данные мока запросом  /storage

curl --location 'http://localhost:5000/storage'

Примерный формат ответа:

{
    "data": {
        "customers": {
            "extra_info": {
                "service": "our"
            },
            "mock_data": {
                "body": {
                    "customer_id": 1,
                    "name": "Vasya"
                },
                "headers": {
                    "OP": "header"
                },
                "status_code": 200
            },
            "proxy_host": null,
            "timeout": 0.0
        }
    },
    "success": true
}

3) Выполняем запрос к тестируемому сервису:

curl --location 'http://our.service.dev.ru/orders'

4) Проверяем поход тестируемого сервиса на наш проксирующий мок

curl --location 'http://localhost:5000/traffic'

Примерный ответ:

{
    "data": [
        {
            "extra_info": {
                "service": "our"
            },
            "request_body": {
                "customer_id": 123
            },
            "request_headers": {
                "Accept": "*/*",
                "Accept-Encoding": "gzip, deflate, br",
                "App": "our-server",
                "Cache-Control": "no-cache",
                "Connection": "keep-alive",
                "Content-Length": "20",
                "Content-Type": "application/json",
                "Host": "localhost:5000",
                "User-Agent": "PostmanRuntime/7.37.3",
                "X-Opa": "123"
            },
            "request_path": "/customers"
        },
        {
            "extra_info": {
                "service": "our"
            },
            "request_body": {
                "customer_id": 123
            },
            "request_headers": {
                "Accept": "*/*",
                "Accept-Encoding": "gzip, deflate, br",
                "App": "our-server",
                "Cache-Control": "no-cache",
                "Connection": "keep-alive",
                "Content-Length": "20",
                "Content-Type": "application/json",
                "Host": "localhost:5000",
                "User-Agent": "PostmanRuntime/7.37.3",
                "X-Opa": "123"
            },
            "request_path": "/customers"
        }
    ],
    "success": true
}

Таким образом, выполнив ряд несложных операций, мы имеем легковесный и мощный инструмент для удобного дебага нашего сервиса.

Дополнительная информация

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

логи прокси-мока

логи прокси-мока

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

результаты покрытия юнит-тестами

результаты покрытия юнит-тестами

Хоть прокси-мок и успешно справляется с нашими текущими задачами, у него есть одно ограничение — это система хранения моков. В данный момент для хранения используется словарь в оперативной памяти, что может ограничить масштабируемость и стабильность при большом объеме данных или высоких нагрузках. Это может привести к увеличению времени отклика или даже потере данных. Мы осознаем эту проблему и планируем её решить в будущем, когда нагрузка на инструмент возрастет. Скорее всего, для более надежного хранения моков мы перейдем на использование Redis, что позволит улучшить производительность и устойчивость системы.

Заключение

В этой статье мы раскрыли внутреннее устройство нашего прокси-мока, показали его ключевые функции и объяснили, как его можно использовать в автотестировании. Этот инструмент уже помогает нам в работе над бэкенд-проектами, и теперь любой желающий может интегрировать его в свои проекты. Мы стремимся сделать тестирование проще и гибче, поэтому будем рады, если наш прокси-мок окажется полезным и в вашем рабочем процессе.

На данный момент прокси-мок полностью удовлетворяет наши текущие потребности в автотестировании бэкенд-проектов, обеспечивая гибкость и удобство в работе. Однако, проект продолжает развиваться — при возникновении новых требований или задач, команды активно вносят улучшения и добавляют новый функционал. Мы также будем рады услышать ваши идеи и предложения. Оставляйте комментарии ниже или делитесь своим мнением на GitHub — любой вклад приветствуется!

© Habrahabr.ru