Разделы портала

Онлайн-тренинги

.
Как мы прикрутили прокси к автотестам
28.10.2024 00:00

Автор: Пронин Дмитрий, Иви (AQA-lead клиентского тестирования)

Привет! Мы в онлайн-кинотеатре Иви любим писать автотесты, особенно клиентские (Потому-что клиентские приложения - это первое, а иногда и единственное, что видят наши пользователи). У нас 4 основных платформы - Android, Web, Smarttv, iOS (Android и iOS - еще подразделяются на мобильную и tv версии).

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

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

  • во первых - количество запросов в одном сценарии может переваливать за сотню;

  • во вторых - частенько 1 проверка может отличаться от другой всего 1-2 параметрами, и тут начинается занимательная эквилибристика с тем, как же разрулить подстановку всех этих бесконечных json-ин и сформировать из них правильный набор;

  • в третьих - если мы проверяем что-то, за что отвечает только часть ответа какого-нибудь метода api, нам совсем не хочется держать в коде и поддерживать огромную портянку и обновлять ее синхронно с бэком;

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

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

Статистика - это большое количество запросов, которые приложение шлет во время работы, и самая большая засада в том, что проверить то, что они отправлены корректно во время работы теста на стороне бэка мы никак не можем, или это очень трудозатратно. Таким образом - все проверки сводятся к тому, что нам нужно слазить в сетевой лог и посмотреть, что же отправило приложение, и в 99% случаев важен не только факт отправки, но и данные, которые были посланы. А отказаться от этих проверок мы не можем, так как:

  • от них зависит большое количество бизнес-метрик, а поэтому их нужно проверять как можно чаще и полнее;

  • проверять их в ручном режиме невероятно трудно и, что самое главное, долго.

Первая итерация

Итак, имея перед собой весь этот багаж проблем, мы начали искать решение. Для web платформ (web и smarttv) можно попробовать манипулировать сетевыми запросами через devtools. А для мобильных платформ такого инструмента найти не удалось. Значит придется внедрять что-то стороннее. Какие у нас требования:

  • Независимость от стэка ( встраиваемые в процесс с тестами моки и прокси нам уже не подходят ).

  • Возможность не только что-то мокать, но и проксировать запросы, если с ними ничего не надо делать.

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

  • Возможность производить https spoofing только для избранных доменов. Чтобы не вмешиваться в работу сторонних ресурсов, на которые может ходить девайс во время теста.

  • Возможность работы в headless режиме (чтобы не мучаться с ci).

Из всего многообразия инструментов, одним из самых популярных является mitmproxy. Она умеет все, что нам нужно:

  • Избирательная работа с доменами по https.

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

  • Написано все это на питоне, в котором у команды есть экспертиза.

  • Возможность запускаться в неинтерактивном режиме и в целом отсутствие жесткой привязки каких-либо инструментов.

Чего нам не хватало для запуска:

  • Сетевой лог (наиболее очевидный формат - это har). На момент начала разработки нативно он не поддерживался (в последних версиях уже есть стандартная поддержка импорта и экспорта).

  • Частичные моки. Нужно было реализовать:

    • протокол для матчинга запросов

    • протокол для изменения запросов

  • И самое интересное - придумать, как с этим всем взаимодействовать из тестов.

Допиливаем mitmproxy

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

Значит, наша первоочередная задача - собрать минимальную рабочую сборку из "родных" и самописных аддонов и научиться всем этим управлять удаленно.

Частично вдохновившись принципами работы mountebank и WireMock мы решили, что самое простое и эффективное решение, это прикрутить api к проксе и дальше уже общаться с ним.

Что должно уметь API:

  • "Заряжать" и удалять моки для определенных запросов.

  • Управлять тем, какие хосты "вскрывать" а какие - оставлять без изменений.

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

  • Получать данные о запросах в формате har.

В итоге после нескольких кругов ада разработки и добавляющихся требований получился примерно вот такой список.




API
  • /api/v1/mock

    • POST - задать мок

    • DELETE - удалить мок

  • /api/v1/mock/clear

    • POST - почистить моки

  • /api/v1/log/har

    • GET - получаем логи в формате har

  • /api/v1/(?P<host>[.0-9a-z-]+)/track - метод для указания хостов, которым нужно вскрывать https

    • POST - добавить хост

    • DELETE - удалить

  • /api/v1/redirect

    • POST - добавить редирект по хосту. ( например api.contoso.com -> api.test.contoso.com)

  • /api/v1/redirect_by_path

    • POST - более сложный редирект, когда у какого-то сервиса, или стороннего инструмента отличается еще и url

      например
      {
        "from_path": "/mad/vast/",
        "to_host": "api.smarttv.contoso.com",
        "to_path": "/vast/test/test.xml"
      }

  • /api/v1/kill_by_host

    • POST - Убивать запросы, идущие на конкретный хост

  • /api/v1/reset

    • POST - полностью очистить все данные из прокси

  • /api/v1/headers

    • POST - метод для проведения манипуляций с заголовками. (Есть определенные сценарии, в которых нужно добавлять или удалять заголовки)

      {
        "request": [
        {
          "action": "PUT",
          "key": "X-Custom-Header",
          "value": "custom-value"
        }],
          "response": [
        {
          "action": "PUT",
          "key": "X-Custom-Header",
          "value": "custom-value"
        }]
      }





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

Получившуюся конструкцию мы назвали mitm_api (креативно, оригинально) и принялись прикручивать к тестам.

Причем тут WebSocket

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

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

Как можно решить данную проблему - каким-то образом добавить поток нотификаций. Самое простое и обкатанное решение - WebSocket. Для нас у него куча плюсов:

  • Есть клиенты на всех используемых стэках.

  • Не нужно разворачивать и обслуживать дополнительные сущности (если вдруг захочется построить что-то на какой-нибудь очереди).

  • Реализации серверов тоже есть, под нужный нам стэк.

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

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

Портим трафик

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

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

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

В функционале mitmproxy напрямую мы таких возможностей не нашли (да и реализовывать их было не настолько удобно - пришлось бы лезть глубже в ядро, а этого не хотелось). Зато нашелся отличный инструмент от Shopify - toxiproxy, вот он как-раз позволяет "честно" различными способами подпортить сетевое соединение, что дает искомый результат.

Но как подружить это все вместе? Ответ простой - нужно отступится от красивого решения "1 контейнер - 1 процесс" и запускать как корневой процесс supervisor , а в нем уже toxyproxy и mitm_api. Таким образом количество торчащих из контейнера ручек еще увеличилось (еще и api для toxyproxy торчит, его мы оставили как есть). А схема теперь выглядит так - клиент в качестве прокси использует адрес toxyproxy, которая в свою очередь ретранслирует это все в mitm_api. Была идея toxyproxy перед бэкендом, но от нее мы отказались - есть шанс, что если затормозить сеть перед mitm, то в части сценариeв оно просто будет буферизовать ответ, а потом отдавать и мы вернемся к тому, от чего пытались уйти.

примерная схема взаимодействия с проксей




Теперь поговорим о том, как нам этой проксей управлять. Для этого подумаем, что нам нужно:

  • Вычленять определенный запрос по его урлу, методу, и параметрам.

  • Точечно вносить изменения в ответ. Почему точечно? Потому что для одного запроса мы хотим иметь возможность составлять мок динамически. Например: у нас есть запрос с данными о контенте, и в тесте нам нужно поменять только название контента, или тэги, или оба параметра сразу. При этом в коде хочется иметь одну сущность, отвечающую за запрос. Изначальная реализация могла подменять только все тело целиком, но с ростом количества тестов стало понятно - мыо брастем либо кучей json-ин, либо своими механизмами для модификации json на каждом клиенте. В любом случае - синхронизация между платформами и поддержка будут затруднены. 

  • Заменить все тело ответа (в разрез к предыдущему пункту такое тоже иногда надо).

  • Менять заголовки для запроса и ответа.

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

Данные требования добавлялись постепенно и у нас получилась вот такая модель:




Код
dataclass
class ApplicableForRequests:
before_index: Optional[int] = None
after_index: Optional[int] = None
with_index: Optional[list[int]] = None


@dataclass
class Predicates:
"""
Описание запросов, к которым должен применяться мок
Если есть несколько подходящих моков - будет выбран мок с наибольшим числом совпадений по params и json_params

host: хост запроса
command: путь в url запроса
method: HTTP метод
params: если ключ-значение есть в query или form_data - число совпадений повысится
json_params: число совпадений повысится если по jsonpath ключу совпадет значение
excluded_params: если query или form_data есть хотя бы один из этих параметров - мок не применится
"""
host: Optional[str]
command: Optional[str]
method: str
params: Dict[str, Any] = field(default_factory=dict)
json_params: Dict[str, Any] = field(default_factory=dict)
excluded_params: List[str] = field(default_factory=list)
applicable_for_requests: Optional[ApplicableForRequests] = None


@dataclass
class Modification:
"""
Атомарная модификация части запроса или ответа

selector: в зависимости от типа - jsonpath или ключ
type: KEY или JSONPATH
action: PUT или DELETE
value: значение для PUT
"""
selector: str
type: str
action: str
value: Optional[Any]


@dataclass
class HeaderModification:
"""
Модификация заголовков

action: PUT или DELETE
key: заголовок
value: значение заголовка для PUT
"""
action: str
key: str
value: Optional[Any]


@dataclass
class Request:
"""
Модификации пересылаемого запроса

headers: заголовки запроса
modify_query: модификация по ключу
modify_form: модификация по ключу
modify_json: модификация по jsonpath
"""
headers: Optional[List[HeaderModification]] = field(default_factory=list)
modify_query: List[Modification] = field(default_factory=list)
modify_form: List[Modification] = field(default_factory=list)
modify_json: List[Modification] = field(default_factory=list)


@dataclass
class ResponseContent:
"""
Модификация контента

text: полностью заменить text
json: полностью заменить json
"""
text: Optional[str] = None
json: Optional[dict] = None


@dataclass
class Response:
"""
Модификации пересылаемого ответа

response: если не null, то modify не применится
modify: модификация по jsonpath
delay_sec: задержка ответа
headers: заголовки ответа
status: статус-код ответа
"""
response: Optional[ResponseContent]
modify: Optional[List[Modification]]
delay_sec: Optional[int]
headers: Optional[List[HeaderModification]] = field(default_factory=list)
status: Optional[int] = None





Прикручивание колеса к велосипеду

С проксей более-менее разобрались (допилили аддоны, сделали дополнительный мастер на основе WebMaster (там уже прикручен tornado, поэтому не надо сильно выдумывать с вебсервером),  теперь нужно как-то сдружить все это с тестами.

При первом подходе было решено сделать так - в аддонах к проксе ввести понятие "сессия" и каким-то образом (уже надежно и продуманно) передавать эту сессию через клиента. На веб клиентах все прошло относительно прилично (с помощью нехитрых манипуляций с nginx и заголовками referrer можно получить тролейбус можно донести до прокси какую-то информацию не меняя код приложения (чего делать отчаянно не хочется)), а вот на мобилках мы сразу споткнулись, упали и решили, что так больше не хотим. Да и код с поддержкой сессий внутри прокси был не очень прост для поддержки (какое-то количество клочков еще торчит в коде).

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

Скейлим колеса

Пожив какое-то время с такой схемой мы поняли, что:

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

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

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

Вот так мы жили на первой итерации




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

А что нам нужно от этого сервиса:

  • Уметь через метод выдать прокси. То есть под капотом запустить контейнер с ней, дождаться пока прокси поднимется и выдать хост и список портов, на котором оно крутится.

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

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

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

В итоге родился еще один проект proxy-hive, который может запускаться в 2 режимах - хостовом (через апи докера запускает и убивает контейнеры) и режиме балансира (Round-robin выбирает хост из списка и проксирует на него запрос, добавляя дополнительные данные, чтобы при следующем обращении понять, на какую тачку проксировать).

Данные о хосте и прокси сводятся к тому, что в режиме хоста каждой проксе выдается рандомный guid, по которому можно определить в каком "слоте" (наборе портов) данная прокси запущена и вытащить id контейнера. А в режиме балансира - имена хостов кодируются в SHA1 (Version 5) UUID информация и все это конкатенируется в 1 строковый id (клиенту парсить это все не надо, а мы получаем простую в реализации и понимании систему).

Следует отметить, что к проксям мы ходим напрямую (в отличии, например, от селеноида) т.к. реализация tcp проксирования:

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

  • может стать точкой отказа, так-как на данном этапе через весть кластер с проксями в пике проходит около 150 мегабит (не самая большая но и не самая маленькая нагрузка);

  • отлаживать самописную tcp прокси может быть затруднительно.

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

Схема получилась достаточно удачная (на наш взгляд), а об успехе свидетельствует тот факт, что иногда новички, или те, кто просто хочет начать заниматься автотестами не обращают внимания на то, как устроена работа с сетью. У них просто есть набор методов для получения запросов и установки моков.

Вот так мы живем сейчас




Следующие шаги

Все ли мы реализовали, что хотели? Нет! Основное желание - научиться записывать и воспроизводить трафик для каждого теста в отдельности (хочется, чтобы была возможность отказаться от необходимости обращаться к бэку, или, как минимум, свести обращения к минимуму во время некоторых прогонов). Частично mitmproxy умеет записывать и воспроизводить дампы, но есть определенный набор проблем, которые мы сейчас решаем:

  • где хранить данные (на данный момент реализовали хранение в S3);

  • что делать если тест с дампом не прошел в первый раз;

  • как правильно избавляться от данных завязанных на текущую дату и время;

  • как реализовать работу с версиями приложения.

На данный момент 1 из клиентов гоняет дампы в тестовом режиме и имеет success rate порядка 80% против 98-99%% если использовать настоящий бэкенд.

Заключение

Помогает ли нам данная конструкция - безусловно. Благодаря ей мы:

  • Можем автоматизировать пласты труднопроходимых для человека сценариев. Например, та же самая статистика, для проверки которой нужно отсматривать контент, рекламу, и прочие видео в разных комбинациях одновременно производя действия с приложением и сверяя то, что налетело в сетевой лог (а налетает туда не мало).

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

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

Всем ли проектам автотестов нужны такие сложные и затратные в поддержке и настройки инфраструктуры решения - нет. Если тестов не слишком много, и половина запросов не является fire-and-forget, не приходится проверять запросы от сторонних библиотек, которые не поддаются настройке (всегда ходят в зашитый url), то в целом хватит и wiremock развернутого рядом с автотестами.

Обсудить в форуме