Привет! Мы в онлайн-кинотеатре Иви любим писать автотесты, особенно клиентские (Потому-что клиентские приложения - это первое, а иногда и единственное, что видят наши пользователи). У нас 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 развернутого рядом с автотестами.