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