Как мы прикрутили прокси к автотестам

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

Да, схема не очень красивая, и требует причесывания, но это не особо мешает, а самыми ходовыми методами являются — добавление мока и получение 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 развернутого рядом с автотестами.

© Habrahabr.ru