Привет! Меня зовут Ринат, я iOS‑разработчик в Naumen. В компании я занимаюсь разработкой нескольких iOS‑продуктов: клиента для SMP‑сервера (Service Management Platform) и SDK чата.

Ринат Абидуллин

iOS-разработчик Naumen

В этой статье расскажу, как мы используем связку Proxyman + HAR, чтобы готовить mock‑данные сетевых запросов для интеграционных UI‑тестов одного из iOS‑приложений. Такой подход выручает, когда для тестов нет возможности поднять сервер с нужным наполнением или сервер не предоставляет дополнительных методов API для имитации определенного сценария — например, переписка в чате с собеседником, обновление статуса заказа.

Мы перехватываем сетевой трафик приложения, сохраняем его и превращаем в готовые моки сетевых запросов. Экспорт в HAR и вспомогательная консольная утилита позволяют автоматически преобразовывать трафик в моки для тестов. Это экономит часы ручной работы и делает интеграционные тесты стабильными и предсказуемыми.

Содержание статьи

Почему UI‑тесты в iOS нестабильны

Представьте, что вы пишете iOS‑приложение, которое без бэкенда работать не может. Вы добавляете новые фичи, исправляете баги, проводите рефакторинг. А еще первое время вручную проверяете, что основные сценарии работают корректно. Например, как и до правок кода, получается успешно авторизоваться в приложении.

В какой‑то момент вы решаете автоматизировать свои действия и пишете UI‑тесты, которые проверяют разные сценарии работы с приложением — другими словами, golden‑кейсы.

Сначала все идет хорошо: вы вносите изменения в код, UI‑тесты успешно проходят. Но в один день все UI‑тесты внезапно падают.

Почему так произошло?

Это проблема тестирования клиент‑серверного приложения. Мы, как разработчики мобильного приложения, контролируем только его, но не можем контролировать бэкенд. Наши UI‑тесты завязаны на состоянии бэкенда или на его доступности в сети. 

Представьте, что кто‑то вручную или другим тестом поменял данные, на которые вы опирались: изменили пароль тестового пользователя или просто отвалился Wi‑Fi, упал сервер и так далее.

Как можно решить проблему нестабильности UI-тестов?

Вариант 1: на каждый UI-тест разворачивать собственный экземпляр сервера с заранее предопределенным наполнением

Плюс такого метода в том, что тесты станут менее зависимы от общего тестового стенда.

Но минусов больше:

  • Время прохождения тестов сильно возрастет. 

  • Стабильность все равно не гарантирована — очередное обновление кодовой базы бэкенда может сломать поднятие сервера на локальной машине.

  • Некоторые тесты могут потребовать интерактивного взаимодействия с сервером, например, когда оператор в браузере отвечает на сообщение клиента.

Последний пункт может стать невыполнимой задачей, если у бэкенда нет «тестовых» ручек — методов API, которые недоступны в релизной сборке и предназначены для тестирования функциональности сервера.

Вариант 2: имитировать сервер, то есть замокировать сетевое взаимодействие клиента и сервера

Для каждого конкретного UI‑теста мы можем подготовить уникальные моки ответов сервера. 

Критично ли отсутствие реального сервера? Нет, если мы хотим проверить работоспособность только своего iOS‑приложения. Для нас главное — соблюдение контракта общения между клиентом и сервером, то есть API‑спецификации. 

У решения с мокированием сети есть и дополнительные плюсы: 

  • Можно проверять работу приложения на разных версиях API (на реальном сервере могут быть просто удалены старые версии API как неподдерживаемые — это может быть критично, если мы распространяем self‑hosted продукт). 

  • Легко имитировать серверные ошибки.

  • Можно писать тесты на еще невыпущенную, но уже согласованную версию API.

Мокирование сетевого взаимодействия выглядит многообещающе. Далее в статье пойдет речь про мокирование ответов сервера только внутренними средствами iOS‑приложений, без необходимости поднимать локальный мок‑сервер. Но прежде чем погружаться в детали реализации мокирования в iOS‑проекте, нужно кое‑что прояснить о UI‑тестах в Xcode.

Как устроены UI-тесты в Xcode

Чтобы понять, как эффективно внедрять моки, нужно разобраться в архитектуре UI‑тестов в Xcode. И здесь есть фундаментальное отличие от unit‑тестов.

Когда вы запускаете unit‑тесты, ваш тестовый код и код приложения выполняются в одном процессе. Это дает вам полную свободу: вы можете создавать экземпляры своих классов и структур, подменять зависимости в DI‑контейнере и вызывать нужные методы.

С UI‑тестами так не получится. UI‑тесты в Xcode работают в двух процессах:

  1. Test Runner Process — процесс, где исполняется ваш тестовый код (класс, унаследованный от XCTestCase). Он управляет XCUIApplication — прокси‑объектом вашего приложения.

  2. Application Process — ваше приложение, запущенное в симуляторе или на устройстве. Оно живет своей жизнью.

Test Runner Process не имеет прямого доступа к памяти и объектам внутри вашего приложения. Он взаимодействует с ним «снаружи», как пользователь: находит элементы по accessibility‑идентификаторам и выполняет действия (тапы, свайпы). 

Из‑за этого мы не можем в коде UI‑теста создать мок какого‑либо сервиса и подменить его в DI‑контейнере приложения. Приложение должно само при запуске понять, для какого тестового сценария оно запущено, и настроить свой DI‑контейнер соответствующим образом. 

Как именно мы этого добьемся, разберем чуть ниже. А пока посмотрим, почему нужно мокировать именно сетевой слой, а не репозитории.

Почему мокируем сеть, а не слой данных

Хороший вопрос. Можно было бы подменять в DI‑контейнере репозитории на их моки, как мы стандартно делаем это в unit‑тестах. Это так называемый «white box» подход. 

Главный недостаток такого подхода — мокирования слоя данных — в том, что тестируется не весь стек приложения: мы полностью исключаем из тестов сетевой слой — парсинг JSON, обработку кодов ответа сервера и ошибок сети. 

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

В UI‑тестах нам нужно использовать другой подход — «black box». Нам не важно, как приложение устроено внутри, сколько там репозиториев, сервисов и менеджеров. Нам важно только одно: какие сетевые запросы оно отправляет наружу и как реагирует на ответы (API‑спецификация). 

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

Именно поэтому в UI‑тестах предпочтительно мокировать сетевые запросы, а не слой данных.

С каким инструментарием мы работаем

Переходим к деталям реализации. Мы задействуем:

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

  • HAR (HTTP Archive) как универсальный формат для сохранения записанных запросов и ответов.

  • URLProtocol для подмены сетевого слоя в приложении во время тестов.

  • XCUIApplication().launchArguments для конфигурирования приложения под конкретный тест.

  • Swift CLI для генерации моков.

  • CFNotificationCenter для имитации WebSocket‑событий.

Как записать сетевой трафик в Proxyman и экспортировать в HAR

Зачем нам нужен Proxyman

Чтобы создать моки, нам сначала нужно записать реальные запросы и ответы. Для этого идеально подходят HTTP‑снифферы, такие как Charles или Proxyman. В качестве альтернативы снифферам можно реализовать логирование всех запросов непосредственно в приложении, например, используя URLProtocol. 

Установив и запустив Proxyman, вы сразу начнете видеть сетевой трафик. Это весь трафик, который отправляет и получает ваш Mac: вы можете видеть сетевой трафик любого приложения или веб‑сайта в Proxyman.

Если запрос выполняется через HTTPS, то, чтобы увидеть его подробности, не забудьте включить SSL‑проксирование для нужного хоста или для всех запросов, исходящих из вашего приложения — Proxyman предложит установить корневой сертификат.

Запустив Proxyman и пройдя в iOS‑приложении тестовый сценарий, можно выгрузить захваченные запросы и ответы в различные форматы — мы выберем HAR:

Теперь нам нужно преобразовать HAR в моки для теста.

Что такое HAR (HTTP Archive) и почему он удобен 

HAR — это стандартный JSON‑формат для логирования сетевого взаимодействия. Proxyman позволяет экспортировать записанный трафик в.har файл.

Структура HAR‑файла упрощенно выглядит примерно так:

{
  "log": {
    "entries": [
      {
        "request": {
          "method": "GET",
          "url": "https://api.example.com/v1/user/profile",
          "headers": [...]
        },
        "response": {
          "status": 200,
          "content": {
            "text": "{\"id\": 123, \"name\": \"SomeUser\"}",
            "mimeType": "application/json"
          }
        },
        "_webSocketMessages": [
          "0": {
            "opcode": 1,
            "data": "some event",
            "time": 1763118875.54,
            "type": "receive"
          }
          // ... другие websocket-события
        ]
      },
      // ... другие запросы
    ]
  }
}

Этот файл — наш источник правды. Мы можем написать достаточно простую CLI‑утилиту на Swift или любом другом языке, которая будет парсить этот JSON и генерировать из него Swift‑код с моками. В этой статье я опускаю детали реализации CLI‑утилиты на Swift — сложного в этом ничего нет. Покажу сразу возможный вариант, как может выглядеть сгенерированный мок запросов на Swift:

struct SendingAttachmentsHttpScenario: Scenario {
    let scenarioId: ScenarioIdentifier = .sendingAttachmentsInChat

    let responseQueue: [ResponseQueueItem] = [
        ResponseQueueItem(
            requestResponse: RequestResponseItem(
                condition: { request in
                    request.url == URL(
                        string: "https://example.com/authorize/"
                    )!
                },
                response: HTTPURLResponse(
                    url: URL(string: "https://example.com/authorize/")!,
                    statusCode: 200,
                    httpVersion: "HTTP/1.1",
                    headerFields: [
                        "Content-Type": "application/json"
                    ]
                )!,
                data: #"""
                {
                  "authData" : {
                    "customerId" : "6216",
                    "visitorId" : "9291"
                  }
                }
                """#
                .data(using: .utf8)
            ),
            repeatability: .once,
            callStatus: .notCalled
        ),
        // ...
    ]
}

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

У запросов есть дополнительные свойства, которые позволяют:

  • задать условие, по которому будет происходить поиск подходящего под запрос из приложения мока (condition);

  • понять, был ли конкретный запрос уже вызван (callStatus);

  • указать, может ли запрос вызываться повторно (repeatability). 

Здесь все зависит от вашей фантазии и требований проекта.

Мок WebSocket-событий создается отдельным файлом. Выглядит он так:

enum SendingAttachmentsWebSocketEvents {
    static func register(with webSocketSimulator: WebSocketSimulatable) {
        DarwinNotificationCenter.default.addObserver(
            forName: Self.userSentMessageWithAttachmentImage.rawValue
        ) { _ in
            // Event time: 13.09.2025 13:45:35.823
            webSocketSimulator.simulateReceive(event: .text(#"""
            {
              "timestamp" : 1763118875970,
              "type" : "CHAT_UPDATE"
            }
            """#))

            // ...
    }
}

Обратите внимание: WebSocket‑события имитируются через WebSocketSimulator (в тестовой сборке он подменяет сервис для работы с WebSocket), который триггерится через DarwinNotificationCenter — это обертка над CFNotificationCenter, он используется для межпроцессного взаимодействия.

В итоге мы получаем два сгенерированных набора моков: для REST‑запросов и для WebSocket‑событий. Начнем с REST‑запросов и реализуем их подмену в тестах.

Как подменить сетевой слой через URLProtocol

Как заставить наше приложение использовать созданные моки REST‑запросов? Здесь поможет URLProtocol. Он позволяет вклиниться в процесс загрузки данных по URL. От нас потребуется создать наследника от URLProtocol, например:

class NetworkMock: URLProtocol {
    static var scenario: Scenario?

    override class func canInit(with request: URLRequest) -> Bool {
        if let url = request.url, let scheme = url.scheme, scheme.hasPrefix("ws") {
            return false
        }
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        guard let scenario = NetworkMock.scenario else { 
            fatalError("No mock responses available!") 
        }

        guard let queueItem = scenario.responseQueue.first(where: { queueItem in
            queueItem.requestResponse.condition(request) && queueItem.canBeCalled
        }) else {
            fatalError("No mock responses available for request \(request)")
        }

        queueItem.didCalled()

        client?.urlProtocol(
            self, 
            didReceive: queueItem.requestResponse.response, 
            cacheStoragePolicy: .notAllowed
        )
      
        if let data = queueItem.requestResponse.data {
            client?.urlProtocol(self, didLoad: data)
        }
      
        client?.urlProtocolDidFinishLoading(self)
    }
}

В методе startLoading() мы получаем доступ к экземпляру сценария, который хранит массив сетевых моков под конкретный тест, и ищем среди моков подходящий под инициированный приложением запрос. В случае успешного поиска мы возвращаем замокированный ответ сервера, иначе выбрасываем ошибку, которая приведет к неудачному завершению теста.

Чтобы NetworkMock заработал, его нужно зарегистрировать в конфигурации URLSession:

let configuration = URLSessionConfiguration.default
configuration.protocolClasses?.insert(NetworkMock.self, at: 0)

Такое изменение нужно внести только для тестовой сборки. Выше я писал, что мы не можем в коде UI‑теста напрямую модифицировать код приложения. Как тогда реализовать дополнительную настройку конфигурации сессии только для тестов? На помощь приходит возможность передавать аргументы запуска приложения — launchArguments.

Как управлять тестовыми сценариями через launchArguments в XCUIApplication

Как из UI‑теста (первый процесс) дать понять приложению (второй процесс), что нужно включить NetworkMock и загрузить в него нужный набор моков, например, для сценария «Отправка прикрепленных файлов к сообщению»? 

Мы можем воспользоваться возможностью передачи аргументов запуска приложения:

let app = XCUIApplication()
        
// Устанавливаем аргументы перед запуском
app.launchArguments = [
    "-scenario sendingAttachmentsInChat",
    "-httpEndpoint https://api.example.com/",
    // ...
]
        
app.launch()

Вместо явной передачи аргументов строками можно реализовать билдер, который будет формировать аргументы запуска:

let argumentsBuilder = LaunchArgumentsBuilder()
let launchArguments = argumentsBuilder.build(from: [
    .scenario(.sendingAttachmentsInChat),
    .httpEndpoint("https://api.example.com/"),
    // ...
])
app.launchArguments = launchArguments

// Реализация билдера может быть такой:

enum LaunchArgumentName: String {
    case scenario
    case httpEndpoint
    // ...
}

enum LaunchArgument: Hashable {
    case scenario(ScenarioIdentifier)
    case httpEndpoint(String)
    // ...

    func asCommandString() -> String {
        switch self {
        case .scenario(let scenarioIdentifier):
            return "-\(LaunchArgumentName.scenario.rawValue) \(scenarioIdentifier.rawValue)"
        case .httpEndpoint(let httpEndpoint):
            return "-\(LaunchArgumentName.httpEndpoint.rawValue) \"\(httpEndpoint)\""
        // ...
        }
    }
}

struct LaunchArgumentsBuilder {
    func build(from arguments: Set<LaunchArgument>) -> [String] {
        arguments.map { $0.asCommandString() }
    }
}

В коде приложения, например, в AppDelegate, нам нужно будет проверить, передан ли требующийся аргумент запуска, и, если да, сконфигурировать сетевую сессию на использование NetworkMock:

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // ...
        let sessionConfigurator = SessionConfigurator()
        sessionConfigurator.configure(sessionConfiguration)
        // ...
    }
}

struct SessionConfigurator {
    func configure(_ sessionConfiguration: URLSessionConfiguration) {
        // Передавая имя тестируемого сценария мы переключаем приложение
        // на использование моков сетевых запросов
        if let scenarioId = AppEnvironment.scenarioId {
            let scenarioFactory = ScenarioFactory()
            let scenario = scenarioFactory.create(with: scenarioId)
            NetworkMock.scenario = scenario
            sessionConfiguration.protocolClasses?.insert(NetworkMock.self, at: 0)
        }
    }
}

enum AppEnvironment {
    static let scenarioId: ScenarioIdentifier? = {
        if let scenarioArgument = CommandLine.arguments.first(where: {
            $0.contains("-\(LaunchArgumentName.scenario.rawValue)")
        }) {
            let scenarioArgumentComponents = scenarioArgument.components(separatedBy: " ")

            guard scenarioArgumentComponents.count == 2 else {
                fatalError("Не задано значение для аргумента -\(LaunchArgumentName.scenario.rawValue)")
            }

            guard let scenarioId = ScenarioIdentifier(rawValue: scenarioArgumentComponents[1]) else {
                fatalError("Не найден сценарий (mock-ответы): \(scenarioArgumentComponents[1])")
            }

            return scenarioId
        } else {
            return nil
        }
    }()
}

Таким способом в каждом UI‑тесте через аргументы запуска можно указать:

  • какой именно набор сетевых ответов ему нужен; 

  • по какому эндпоинту подключаться и так далее.

Приложение сконфигурирует себя соответствующим образом при запуске.

Как имитировать WebSocket-события через CFNotificationCenter

Мы научились симулировать REST API. А что делать с событиями, которые инициирует сервер? Например, WebSocket‑сообщения. 

launchArguments здесь не помогут: они работают только в момент запуска. При этом мы помним, что UI‑тесты в Xcode работают в двух процессах. Нам нужен способ общаться между Test Runner'ом и приложением во время выполнения теста

Здесь хорошо работает CFNotificationCenter. Он позволяет обмениваться уведомлениями между различными процессами: из процесса Test Runner'а мы отправляем уведомление, а в приложении его принимаем.

Для удобства можно написать небольшую обертку над CFNotificationCenter:

public class DarwinNotificationCenter {
    public static let `default` = DarwinNotificationCenter()

    public private(set) var observations: [String: (String) -> Void] = [:]

    public func addObserver(forName name: String, using block: @escaping (String) -> Void) {
        observations[name] = block

        let callback: CFNotificationCallback = { _, _, name, _, _ in
            guard let name = name?.rawValue as String? else { return }
            DarwinNotificationCenter.default.observations[name]?(name)
        }

        CFNotificationCenterAddObserver(
            CFNotificationCenterGetDarwinNotifyCenter(),
            Unmanaged.passUnretained(self).toOpaque(),
            callback,
            name as CFString,
            nil,
            .deliverImmediately
        )
    }

    public func removeObserver(withName name: String) {
        observations.removeValue(forKey: name)

        CFNotificationCenterRemoveObserver(
            CFNotificationCenterGetDarwinNotifyCenter(),
            Unmanaged.passUnretained(self).toOpaque(),
            CFNotificationName(name as CFString),
            nil
        )
    }

    public func removeAllObservers() {
        CFNotificationCenterRemoveEveryObserver(
            CFNotificationCenterGetDarwinNotifyCenter(),
            Unmanaged.passUnretained(self).toOpaque()
        )
    }

    public func post(name: String) {
        CFNotificationCenterPostNotification(
            CFNotificationCenterGetDarwinNotifyCenter(),
            CFNotificationName(name as CFString),
            nil,
            nil,
            true
        )
    }
}

Нам нужно зарегистрировать моки WebSocket‑событий под некоторыми именами нотификаций — этим как раз занимается CLI‑утилита для генерации моков из HAR‑файла:

enum SendingAttachmentsWebSocketEvents {
    static func register(with webSocketSimulator: WebSocketSimulatable) {
        DarwinNotificationCenter.default.addObserver(
            forName: WebSocketEvents.incomingMessage
        ) { _ in
            webSocketSimulator.simulateReceive(event: .text(#"""
            {
              "timestamp" : 1763118875970,
              "type" : "INCOMING_MESSAGE",
              "message": "..."
            }
            """#))

            // ...
    }
}

Также в приложении нужно подменить сервис, который работает с реальными WebSocket‑событиями, на сервис, имитирующий их (WebSocketSimulator). Сделать это можно через рассмотренные ранее launchArguments, например, при регистрации зависимости в DI‑контейнере.

Теперь, чтобы сымитировать WebSocket‑событие, в UI‑тесте в нужный момент достаточно отправить Darwin‑нотификацию:

// ...
app.launch()

chatPageObject.type(text: "...")
chatPageObject.attachImage(...)
chatPageObject.sendMessage()

// Имитируем websocket-событие: другой пользователь написал нам сообщение
DarwinNotificationCenter.default.post(
    name: WebSocketEvents.incomingMessage
)

В приложении мы получим нотификацию, и вызовется метод webSocketSimulator.simulateReceive(event:), который имитирует приход WebSocket‑событий так, как если бы они были инициированы сервером.

Еще немного про WebSocket-события

При использовании Proxyman для перехвата трафика требуется еще одна настройка конфигурации сессии — нужно настроить socksv5Proxy:

if #available(iOS 17.0, *) {
    // см. https://docs.proxyman.io/advanced-features/websocket
    let socksV5Proxy = NWEndpoint.hostPort(host: "localhost", port: 8889)
    let proxyConfiguration = ProxyConfiguration.init(socksv5Proxy: socksV5Proxy)
    sessionConfiguration.proxyConfigurations.insert(proxyConfiguration, at: 0)
}

В самом Proxyman нужно задать аналогичные настройки. Для этого переходим в Tools → Proxy Settings → SOCKS PROXY SETTINGS, ставим галочку Enable SOCKS Proxy и задаем порт, например, 8889:

После этой настройки вы сможете видеть WebSocket-события в Proxyman.

Какие итоги

Описанный подход похож на End‑to‑End (E2E) тестирование, но на самом деле им не является. Это интеграционные тесты, в которых проверяется взаимодействие всех (почти) внутренних компонентов iOS‑приложения

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

Реализация моков и тестовых сервисов в виде файлов, которые лежат непосредственно в приложении, хорошо подходит, если вы пишете SDK: для него обычно создается специальное тестовое или демо‑приложение, которое эти файлы может хранить как часть своего кода. 

Если хочется применить данный подход к приложению, которое дальше распространяется через App Store, то моки и дополнительные тестовые сервисы нужно исключать из сборки, используя директивы условной компиляции. В этом случае, возможно, лучше задействовать моковый локальный сервер — но это уже другая история.

И напоследок: если вы пишете End‑to‑End тесты, описанный в статье метод вам не подойдет — без реального бэкенда обойтись не получится.

Комментарии (0)