Привет! Мы в онлайн-кинотеатре Иви любим писать автотесты, особенно клиентские (Потому-что клиентские приложения - это первое, а иногда и единственное, что видят наши пользователи). У нас 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. Она умеет все, что нам нужно:
Систему аддонов, внутри которой мы имеем полный контроль над жизненным циклом запроса, что собственно дает возможность обходиться без любого функционала, отсутсвующего из коробки.
Написано все это на питоне, в котором у команды есть экспертиза.
Возможность запускаться в неинтерактивном режиме и в целом отсутствие жесткой привязки каких-либо инструментов.
Чего нам не хватало для запуска:
Сетевой лог (наиболее очевидный формат - это 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
- метод для указания хостов, которым нужно вскрывать httpsPOST - добавить хост
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 развернутого рядом с автотестами.
jackhicks121
Great article on the intricacies of scaling and optimizing ML services with Kubernetes! The insights into GPU node management and the practical case studies are particularly valuable. As someone working in DevOps and ML, I found the detailed breakdown of setting up and configuring GPU nodes incredibly useful. The hands-on workshop section looks promising and is something I’ll definitely look into for a deeper dive into practical applications. Keep up the excellent work!