Прокси — один из основных инструментов в арсенале QA-инженера. Charles Proxy, Fiddler и Proxyman давно стали стандартом для анализа и изменения сетевого трафика в процессе ручного тестирования. Их принцип работы хорошо известен и подробно описан во множестве материалов.

Однако возникает вопрос: как использовать подобные возможности в UI-автотестах? Как перехватывать или мокать трафик в автоматизированных сценариях? Давайте разберёмся.

1. Зачем нужен прокси в UI автотестах

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

1.1 Сетевая слепота автотестов

Слепость UI автотестов к ошибкам, неправильным ответам API, событиям WebSocket, неверным данным backend

На деле это может стать причиной/выглядеть как:

  • flaky tests

  • непонятные падения

  • невозможность воспроизвести состояния

  • ложным падениям из-за проблем сети или backend

  • сложностям при диагностике причин ошибки

Какие функций и проверки можно реализовать:

  • Перехват запросов и ответов для коротких api тестов и проверки поведения приложения

  • Перехват токена при регистрации с дальнейшим использованием во фреймворке

  • Троттлинг

  • Логирование трафика

  • Проверку валидности запросов клиента

  • Отслеживание последовательности запросов клиента

  • Проверка количества запросов

  • Перехват и анализ WebSocket сообщений

  • Проверка корректности обработки ошибок

Таким образом доступ из автотеста к информации о работе сети добавляет им важное качество - network visibility. После падения автотеста уже на этапе отчета мы можем сузить круг возможных причин исключив сетевые проблемы или наоборот списать все на сеть)

1.2 Сложности с получением тестовых состояний

Незаменимая функция при тестировании поведения клиентских приложений когда получить тестовое состояние через DEV стенд долго/дорого и когда нет задачи тестировать бекенд!

Варианты использования:

  • Подмена статус кодов (200 → 4xx, 5xx для проверки поведения клиента)

  • Изменение параметров json/xml в запросах и ответах

  • Изменение параметров хедеров запросов и ответов

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

  • Инжектирование WS сообщений в канал (изменение баланса юзера, отправка нотификаций и другое)

  • Изменение WS сообщений на лету (изменение параметров в json внутри WS)

  • Другие специализированные функции

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

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

3. Поиск решения

Charles / Proxyman / Fiddler не подходят для автоматизации по понятным причинам - в них нет функционала управления извне кроме GUI. После долгих ресерчей остановились на  mitmproxy, ****который изначально проектировался как программируемый прокси с командной строкой и веб интерфейсом

  • Python API — позволяет писать скрипты, которые перехватывают и модифицируют запросы и ответы.

  • Headless-режим — прокси легко запускать в CI как без графического интерфейса так и с ним

  • Гибкое управление трафиком — можно логировать, изменять или мокать ответы сервера.

  • Поддержка WebSocket — важная возможность для современных мобильных приложений.

  • Мультиплатформенный open-source продукт - доступен для Windows, Linux, MacOs

По сути, mitmproxy превращается из инструмента анализа трафика в управляемый сетевой слой, который можно встроить прямо в инфраструктуру тестов.

4. Архитектура решения

После нехитрой установки нашей проксИ https://docs.mitmproxy.org/stable/overview/installation/

brew install mitmproxy

И получении сертификатов на устройства https://docs.mitmproxy.org/stable/concepts/certificates/

Мы получаем стандартный прокси-флоу:

Работа прокси настроена в режиме аналогичному для упомянутых Charles / Proxyman / Fiddler с тем отличием, что  mitm можно запустить в 3х режимах:

  • Mitmproxy предоставляет вам интерактивный интерфейс командной строки

  • Mitmweb предоставляет вам графический интерфейс на основе браузера

  • Mitmdump дает вам неинтерактивный терминальный выход

https://docs.mitmproxy.org/stable/overview/getting-started/

5. Настройка mitmproxy для автотестов

Первое что нужно понять, что запуск mitmproxy осуществляется не тестовым фреймворком, а отдельным процессом и сделать это можнo внутри экземпляра терминала в вашем IDE(я использую PyCharm на MacOs) или в отдельном окне терминала ОС.

И чтобы воспользоваться Python API и интегрировать прокси в автотесты необходимо создать модуль proxy_handler.py и реализовать в нем методы которые позволят работать с HTTP запросами и WS (код модуля HTTP разберем ниже)

Запуск прокси происходит через команд-лайн следующим образом или через bash-скрипт (можно посмотреть в репо):

mitmweb -s proxy_handler.py

В качестве механизма взаимодействия между тестовым фреймворком и прокси выступает config.json. Именно он позволяет запускать и останавливать все те функции которые мы будем реализовывать для прокси.

6. Реализация подмены статус кода

Давайте рассмотрим пример реализации подмены СК для конкретного API. Это наиболее простая операция с точки зрения реализации кода.

6.1 Config.json

Предназначен для настройки прокси на выполнение той или иной операции.

В файле config.json создаем пару ключ-значение:

{
    "status":{}
}

В значении оставим пустой объект как признак того что ничего изменять не нужно.

Для инициации подмены СК вместо пустого объекта запишем:

{
    "status": {"api/v1/user": 404, "api/v2/settings": 500}
}

Парсинг нужных API реализован с применением модуля RE поэтому прокси найдет любые вхождения в URI, включая query-параметры, что очень удобно и значительно расширяет возможности. Подмена в таком формате записи совершится 1 раз для каждого ключа.

Для продолжительной подмены нужно использовать форму записи:

{
    "status": {"/api/v1/user": [404], "api/v2/settings": [500]}
}

Где СК является 0 элементом списка, что логически бессмысленно, но в таком формате подмена будет совершаться до конца проверки или текущего тест-кейса. Такой формат был выбран для исключения ввода еще одного параметра в config.json и удачно себя зарекомендовал. Отключение подмены возможно со стороны фреймворка изменением значения “status” на {} или на другие параметры.

6.2 Модуль Proxy_handler.py

К самому интересному - в данном файле будем писать методы которые будут читать config.json, выполнять подмены CК и изменять config.json чтобы остановить подмены. Сделаем упор на производительность, чтобы не тормозить работу прокси ненужными проверками или кривым кодом, в меру своих возможностей конечно)

import re
import mitmproxy.ctx as ctx
from mitmproxy import http
from file_worker import FileWorker

file_worker = FileWorker()

def response(flow: http.HTTPFlow) -> None:
    url = flow.request.url
    if not flow.response.content: return
    cfg = file_worker.get_proxy_params()

    "STATUS CODE override"
    cfg_status = cfg.get("status")
    for api, sc in list(cfg_status.items()):
        "Compare 'api' with URI (compiling with 're')"
        if bool(re.compile(api).search(url)):
            "Changing the STATUS CODE"
            flow.response.status_code = int(sc[0] if isinstance(sc, list) else sc)
            ctx.log.info(f"Status code was mocked '{api}' -> {sc}")
            if not isinstance(sc, list):
                del cfg_status[api]
                "Updating a 'config.json'"
                file_worker.set_proxy_param("status", cfg_status)
                cfg["status"] = cfg_status
            break

В коде есть короткие пояснения про то как происходит обработка config.json и подмена СК. Вот основные тезисы на которые стоит обратить внимание:

  1. Начинаем подмену только когда в поле “status” is not {}

     for api, sc in list(cfg_status.items()):
  2. Используя пакет RE сравниваем URI и ключи внутри "status": {"api/v1/user": … Так, можно использовать регулярные выражения для тонкой настройки API для подмены, например условия с END - “(?=.*api/v3/banner/list)(?=.*position=promotion)” или NOT - ”^(?!config\.examplepis\.com(?::\d+)?$)(?:.+\.)?examplepis\.com(?::\d+)?$”

     if bool(re.compile(api).search(url)):
  3. Заменяем статус код обрабатывая оба варианты с разовой заменой и бесконечной

     flow.response.status_code = int(sc[0] if isinstance(sc, list) else sc)
  4. Пишем лог в консоль и прекращаем подмену если необходимо и прерываем цикл для данного запроса

    if not isinstance(sc, list):
    	  del cfg_status[api]
    	  "Updating a 'config.json'"
    	  file_worker.set_proxy_param("status", cfg_status)
    	  cfg["status"] = cfg_status

Флоу работы с файлами вынесен в отдельный модуль - скорее всего в вашем фреймворке будет реализация этих функций и вы можете взять готовые методы. Вот мой пример:

class FileWorker:
    def __init__(self):
        self.current_directory = os.path.abspath(os.path.join(os.environ["VIRTUAL_ENV"], ".."))
        self.__proxy_proxy_data = os.path.join(self.current_directory, "config.json")

		@staticmethod
    def __file_json_to_dict(file_path: str) -> dict:
        with open(file_path, encoding='utf-8-sig') as filestream:
            text_from_file = filestream.read()
            temp_dict = json.loads(text_from_file)
        return temp_dict

		def get_proxy_params(self) -> dict:
		     return self.__file_json_to_dict(self.__proxy_proxy_data)

Далее по аналогии можно реализовать подмену (Mocking) параметров в ответе:

   "MOCK params"
    cfg_mock: dict = cfg.get("mock")
    if cfg_mock:
        for mock_api, params in list(cfg_mock.items()):
            if bool(re.compile(mock_api).search(url)):
                data = flow.response.content.decode()
                modified = file_worker.mock(params[0] if isinstance(params, list) else params, data)
                if modified:
                    flow.response.content = modified.encode()
                    ctx.log.info(f"Param {params} were mocked for '{mock_api}'")
                else:
                    ctx.log.info(f"No matches found for {params}")
                if not isinstance(params, list):
                    del cfg_mock[mock_api]
                    file_worker.set_proxy_param("mock", cfg_mock)
                    cfg["mock"] = cfg_mock
                break

Или сохранение ответа сервера в файл для дальнейшего разбора в ходе проверок:

 "SAVE RESPONSE to file"
    cfg_response: list = cfg.get("get_response")
    if cfg_response:
        for i, resp_api in enumerate(list(cfg_response)):
            if bool(re.compile(resp_api[0] if isinstance(resp_api, list) else resp_api).search(url)):
                ctx.log.info(f"Response '{url}' was saved with #{i}")
                file_worker.set_proxy_temp_file(f"response_{i}.json", flow.response.get_text())
                if not isinstance(resp_api, list):
                    cfg_response.pop(i)
                    file_worker.set_proxy_param("get_response", cfg_response)
                    cfg["get_response"] = resp_api
                break

7. Применение в тестовом фреймворке

Mitmproxy и тот addon который мы реализовали довольно просто встроить в тестовый фреймворк. Достаточно реализовать метод который будет менять config.json и работать с полученными файлами.

В итоге в логе прокси мы видим короткий отчет о проделанных подменах.

Здесь реализованы подмена статус-кода, параметров в ответе в формате json и сохранение json в файл для дальнейшей работы с ним (например, ассерты UI согласно полученному ответу).

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

Кому зашло, ставьте лайк, продолжим разборы более сложных материй :-)

Всем добра, и не забывайте тестить бекенд :-)

https://github.com/kazeboba/automation-proxy.git