В зрелом iOS-проекте автоматизированные тесты — давно не экзотика. Unit-тесты проверяют отдельные компоненты, интеграционные тесты контролируют взаимодействие между слоями, а XCUITest проходит критичные пользовательские маршруты. Проблема начинается не с отсутствия автоматизации, а с её масштабирования.

Несколько десятков независимых UI-тестов можно поддерживать практически в любом виде. Но по мере роста регрессии появляются сценарии, которым недостаточно просто запустить приложение и пройти последовательность экранов. Одному тесту нужен пользователь в определённом состоянии, другому — конкретная ошибка backend, третьему — несколько последовательных ответов от одного эндпоинта.

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

XCUITest остался исполнителем пользовательского сценария. Robot Pattern сформировал стабильный слой взаимодействия с интерфейсом. Launch arguments позволили управлять стартовым состоянием приложения. Собственный mock backend взял на себя подготовку серверных данных, а Mock Admin сделал эту инфраструктуру доступной всей команде. Для ускорения разработки тестов мы подключили ИИ-агента, способного работать с Xcode и iOS Simulator через XcodeBuildMCP.

В этой статье разберём, как эти компоненты соединяются в единый процесс: от сценария, описанного тестировщиком, до воспроизводимого запуска в CI.

Единицей регрессии становится сценарий

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

Поэтому основной единицей нашей регрессии стал воспроизводимый сценарий:

Начальное состояние приложения
        +
Конфигурация backend
        +
Действия пользователя
        +
Ожидаемый результат

Рассмотрим условный кейс:

Предусловия: пользователь авторизован, приложение разблокировано, backend подготовлен для проверки повторной попытки.

Шаги: открыть целевой раздел, запустить операцию, дождаться временной ошибки, нажать «Повторить».

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

Тестировщик описывает такой сценарий обычным текстом в системе управления тест-кейсами. Ему не нужно писать Swift-код, знать названия классов приложения или искать accessibility identifiers. Его зона ответственности — однозначно определить предусловия, действия и ожидаемое поведение.

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

Как ИИ-агент и XcodeBuildMCP помогают писать тесты

Основные затраты при автоматизации нового сценария часто приходятся не на написание нескольких строк Swift. Разработчику необходимо сопоставить текстовый кейс с реальным приложением: найти нужный маршрут, изучить accessibility-дерево, определить подходящих роботов и проверить промежуточные состояния.

Эту исследовательскую часть мы частично передали ИИ-агенту. Через XcodeBuildMCP он получает инструменты для работы с Xcode и iOS Simulator: может собрать нужную схему, запустить приложение, получить accessibility-дерево, взаимодействовать с элементами и запустить XCUITest.

MCP здесь выступает мостом между рассуждением агента и реальным окружением iOS-разработки. Без него агент может анализировать исходный код и предполагать, как устроен пользовательский путь. С XcodeBuildMCP он проверяет эти предположения на собранном приложении.

Процесс выглядит так:

Текстовый сценарий
        ↓
Сборка и запуск приложения
        ↓
Активация mock-профиля
        ↓
Прохождение сценария в Simulator
        ↓
Анализ accessibility-дерева и существующих роботов
        ↓
Формирование и запуск XCUITest
        ↓
Изменения передаются разработчику на review

Чтобы агент писал код в стиле проекта, рядом с тестами хранится его рабочий контекст: параметры сборки, test plan, целевой Simulator, карта навигации, каталог роботов и правила работы с accessibility. Отдельно зафиксированы ограничения: не использовать координаты при наличии accessibility identifier, не добавлять sleep, переиспользовать роботов и не обходить продуктовую логику тестовыми переходами.

Конфигурация агента версионируется вместе с проектом и проходит обычный code review. Благодаря этому правила генерации меняются синхронно с тестовой архитектурой. Агент получает не весь репозиторий как неструктурированный набор файлов, а ограниченный контекст: тест-кейс, подходящие роботы, соглашения по именованию и способы запуска приложения.

Для сценария также определён критерий завершения работы. Агент должен не только добавить тестовый метод, но и собрать проект, запустить новый тест в чистом состоянии и приложить результат. Если для прохождения не хватает accessibility identifier или существующий робот не предоставляет нужного действия, это становится явным изменением в коде, а не скрытым координатным нажатием.

После генерации тест остаётся обычным нативным XCUITest. ИИ и интерактивный MCP-сеанс используются при разработке, но не становятся внешней зависимостью для CI.

Robot Pattern и accessibility как тестовый API

Чтобы тесты, написанные вручную и подготовленные агентом, выглядели одинаково, техническую работу с интерфейсом мы вынесли в роботов.

final class OperationRobot {
    private let app: XCUIApplication

    init(app: XCUIApplication) {
        self.app = app
    }

    @discardableResult
    func retry() -> Self {
        let button = app.buttons["operation_retry"]
        XCTAssertTrue(button.waitForExistence(timeout: 5))
        button.tap()
        return self
    }

    func assertCompleted() {
        XCTAssertTrue(
            app.staticTexts["operation_completed_title"]
                .waitForExistence(timeout: 5)
        )
    }
}

Верхнеуровневый тест после этого читается как сценарий:

operationRobot
    .retry()
    .assertCompleted()

Для разработчика роботы убирают дублирование XCUITest-кода. Для ИИ-агента они становятся целевым языком, в который переводятся действия из тест-кейса.

Мы не создаём отдельного робота на каждый SwiftUI View. Робот представляет законченную область взаимодействия: авторизацию, Tab Bar, настройки, профиль или системные уведомления.

Accessibility identifiers при этом становятся стабильным контрактом между приложением и тестами. Идентификатор вроде operation_retry переживёт изменение текста и положения кнопки, в отличие от координат или поиска по локализованной строке.

Управление стартовым состоянием через launch arguments

Регрессионные тесты должны отдельно проверять авторизацию, onboarding и настройку кода доступа. Но это не означает, что каждый тест внутреннего раздела должен повторно проходить все предыдущие этапы.

Для управления запуском мы используем launch arguments и launch environment:

let app = XCUIApplication()

app.launchArguments += [
    "-uiTesting",
    "-resetApplicationState"
]

app.launchEnvironment["AUTHORIZATION_STATE"] = "authorized"
app.launchEnvironment["ACCESS_STATE"] = "unlocked"
app.launchEnvironment["API_BASE_URL"] = backendURL.absoluteString

app.launch()

Тестовые точки входа доступны только в специальной конфигурации сборки. Launch arguments не подменяют предмет проверки: если тестируется авторизация, пользователь проходит её через интерфейс. Если проверяется внутренний раздел, приложение может сразу получить подготовленную локальную сессию.

Мы также разделяем локальное состояние и серверные данные. Launch arguments управляют сессией, onboarding и доступом к приложению, но не создают бизнес-сущности внутри клиента. Данные для экранов по-прежнему приходят по сети — только в регрессионной конфигурации источником становится mock backend.

Так сценарии становятся короче и не зависят от несвязанных этапов.

Mock backend и профили ответов

После управления локальным состоянием остаётся серверная часть сценария. Позитивному тесту нужен стабильный набор данных, негативному — конкретная ошибка, а для retry или polling один эндпоинт должен последовательно возвращать разные ответы.

Общий тестовый стенд плохо подходит для точного управления такими ситуациями. Данные могут измениться, другой тест может использовать ту же сущность, а редкую ошибку бывает невозможно воспроизвести по запросу. Поэтому приложение в регрессионной конфигурации работает с собственным mock backend как с обычным HTTP-сервером: сохраняются реальные URLSession-запросы, декодирование моделей и обработка ошибок.

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

Базовые ответы
        +
Переопределения сценария
        =
Mock-профиль

Упрощённый профиль повторной попытки выглядит так:

{
  "id": "operation_retry_after_error",
  "overrides": [
    {
      "method": "POST",
      "path": "/api/operation",
      "responses": [
        {
          "times": 1,
          "status": 500,
          "body": { "code": "temporary_error" }
        },
        {
          "status": 200,
          "body": { "result": "completed" }
        }
      ]
    }
  ]
}

Все эндпоинты, отсутствующие в overrides, продолжают возвращать стандартные ответы. Благодаря этому профиль описывает только отличие конкретного сценария от нормального поведения.

Переопределение может учитывать HTTP-метод, путь, query-параметры и тело запроса, а также задавать задержку или ограниченное количество применений. Так моделируются временные ошибки, retry, polling, пагинация и другие сценарии с состоянием.

Например, один и тот же маршрут может вести себя по-разному в рамках одного теста:

Первый POST → temporary_error
Второй POST → accepted
Первый GET status → processing
Второй GET status → completed

Для изменяющих состояние операций backend хранит результат до сброса профиля. Благодаря этому последующий GET видит изменения, выполненные предыдущим POST, а тест проходит связный сценарий, а не набор независимых заглушек.

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

Почему мы не взяли готовый mock server

Задача HTTP-mocking давно решена, поэтому перед разработкой собственного инструмента мы посмотрели на существующие продукты.

WireMock поддерживает request matching, JSON- и Java-конфигурации, задержки, fault injection, record/playback и stateful-сценарии. MockServer позволяет создавать expectations, ограничивать количество их применений, проверять запросы и анализировать трафик через развитый dashboard. Mockoon предлагает визуальную настройку эндпоинтов, response rules, data buckets и журнал запросов, а его Admin API позволяет управлять состоянием окружения.

У каждого из этих решений есть сильная сторона. WireMock удобен как программируемый HTTP mock server, MockServer предоставляет развитые инструменты анализа и верификации, а Mockoon снижает порог входа благодаря визуальной конфигурации. Поэтому выбор собственного backend нельзя объяснить отсутствием готовых возможностей на рынке.

По количеству универсальных возможностей эти продукты превосходят наш инструмент. Мы не пытались создать конкурента WireMock или MockServer.

Нам требовался специализированный слой, встроенный в UI-регрессию iOS-приложения. Основной сущностью должен был стать не отдельный stub, а профиль пользовательского сценария, который активируется из XCUITest одним вызовом, сбрасывает внутреннее состояние и используется в том же виде при ручном воспроизведении.

Это можно было построить поверх готового mock server, но собственными всё равно остались бы модель профилей, интеграция с XCUITest, каталог регрессионных сценариев и интерфейс для команды. Поэтому мы реализовали специализированный backend, выигрывающий не количеством функций, а отсутствием лишнего слоя между тест-кейсом, серверной конфигурацией и UI-регрессией.

Mock Admin: управление профилями без ручных запросов

API и JSON-конфигураций достаточно для автотестов, но неудобны для ручной работы. Поэтому над backend появился веб-интерфейс Mock Admin. Он объединяет каталог тестов и профилей, редактор пользовательских переопределений, поиск по эндпоинтам, просмотр эффективных ответов и журнал запросов.

Из того же интерфейса профиль можно активировать, его внутреннее состояние — сбросить, а журнал запросов — очистить. Пользователь видит не только JSON переопределения, но и итоговый ответ, который получит приложение после объединения профиля с базовой конфигурацией. Это особенно полезно, когда профиль изменяет лишь один маршрут из нескольких десятков.

Эталонные профили регрессии создают и поддерживают разработчики. QA использует готовые профили и при необходимости временно конфигурирует их через Mock Admin: заменяет успешный ответ ошибкой, добавляет задержку или изменяет данные. Это помогает воспроизвести падение из CI, проверить обработку нового серверного состояния или точно передать разработчику найденный граничный сценарий.

Временные настройки отделены от эталонных профилей:

Ручная проверка:
default responses + temporary overrides

Автоматическая регрессия:
default responses + versioned profile

При активации профиля из XCUITest пользовательские переопределения явно отключаются:

{
  "scenario": "operation_retry_after_error",
  "reset": true,
  "useCustomProfile": false
}

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

Так Mock Admin решает не задачу самостоятельного написания автотестов тестировщиками, а задачу воспроизводимости. Вместо сообщения «на втором запросе иногда должна быть ошибка» QA может передать разработчику название профиля и точное временное переопределение.

Как выглядит итоговый тест

Подготовка окружения скрыта в базовом классе: он сбрасывает backend, активирует профиль, задаёт launch configuration и запускает приложение. Верхнеуровневый тест остаётся коротким:

func prepare(
    profile: String,
    launchState: LaunchState
) throws {
    try backend.activate(
        profile: profile,
        resetState: true,
        useCustomProfile: false
    )

    app.launchArguments = launchState.arguments
    app.launchEnvironment["API_BASE_URL"] = backend.baseURL.absoluteString
    app.launch()
}

Метод prepare гарантирует порядок действий: сначала сбрасывается состояние и активируется профиль, затем конфигурируется и запускается приложение. Это важно, поскольку стартовые запросы могут уйти сразу после app.launch().

func testRetryAfterServerError() throws {
    try prepare(
        profile: "operation_retry_after_error",
        launchState: .authorizedAndUnlocked
    )

    mainRobot.openTargetSection()

    operationRobot
        .start()
        .assertTemporaryError()
        .retry()
        .assertCompleted()

    try backend.assertRequest(
        method: .post,
        path: "/api/operation",
        count: 2
    )
}

За этим методом скрывается вся инфраструктура, но сам тест по-прежнему читается как бизнес-сценарий.

Как мы тестируем push и deep links в iOS Simulator

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

Прямой вызов deep link router проверил бы только часть логики. Поэтому ключевые сценарии мы запускаем через уведомление, доставленное в iOS Simulator.

Перед тестом активируется профиль с данными целевого раздела, а launch arguments задают состояние пользователя. Затем тест обращается к служебному API mock backend:

try backend.sendSimulatorPush(
    simulatorID: simulator.id,
    bundleIdentifier: application.bundleIdentifier,
    payload: payload
)

Backend вызывает simctl push, после чего в Simulator появляется системное уведомление. Отдельный робот взаимодействует со SpringBoard, нажимает на уведомление и возвращает управление роботам приложения.

func testOpenTargetSectionFromPush() throws {
    try prepare(
        profile: "target_section_available",
        launchState: .authorizedAndUnlocked
    )

    try backend.sendSimulatorPush(
        simulatorID: simulator.id,
        bundleIdentifier: application.bundleIdentifier,
        payload: .targetSection
    )

    springboardRobot.openNotification(title: "Обновление")
    targetSectionRobot.assertOpened()
}

Mock-профиль подготавливает данные, launch arguments задают состояние приложения, mock backend доставляет уведомление, а XCUITest проверяет обработку deep link и открытие целевого экрана. Парсинг URL и таблица маршрутов покрываются быстрыми unit-тестами, через Simulator проходят только ключевые сквозные сценарии: cold start, переход из фона и продолжение маршрута после разблокировки.

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

Push и deep links не требуют отдельной тестовой системы. Они используют те же профили, роботов и диагностические инструменты, что и остальная регрессия. Отличается только точка входа.

Наблюдаемость и диагностика

Финальный экран не всегда доказывает корректное выполнение сценария. После двойного нажатия приложение может показать успешный результат, хотя отправило два одинаковых запроса. Поэтому mock backend сохраняет журнал обращений, а тест может проверить их количество, порядок и содержимое:

try backend.assertRequestSequence([
    .post("/api/operation"),
    .post("/api/operation"),
    .get("/api/operation/status")
])

Такие проверки используются там, где сетевое взаимодействие является частью ожидаемого поведения: при retry, защите от двойного нажатия, polling или пагинации.

При падении тест прикладывает скриншот, accessibility hierarchy, launch configuration, активный профиль, журнал запросов и push payload. По этим данным можно восстановить последовательность событий, а ИИ-агент при необходимости использует тот же пакет для первичного анализа.

10:15:20 — активирован профиль operation_retry_after_error
10:15:21 — приложение запущено
10:15:24 — POST /api/operation → 500
10:15:25 — нажата кнопка retry
10:15:30 — ожидаемый повторный запрос не получен

Такая временная шкала показывает не только место, где завершилась проверка, но и последнее подтверждённое событие.

Отдельное правило регрессии — отсутствие фиксированных задержек. Вместо sleep тест ожидает наблюдаемое состояние:

XCTAssertTrue(
    app.staticTexts["operation_completed_title"]
        .waitForExistence(timeout: 10)
)

Если backend должен имитировать медленную сеть, задержка задаётся в профиле. Тест продолжает ждать результат, а не конкретное количество секунд.

Общая архитектура и границы решения

В итоге процесс создания теста выглядит так:

QA описывает тест-кейс
        ↓
Разработчик подготавливает mock-профиль
        ↓
ИИ-агент через XcodeBuildMCP проходит сценарий
        ↓
Формирует XCUITest на основе роботов
        ↓
Разработчик проверяет код и профиль
        ↓
Нативный XCUITest запускается в CI

Разработчики отвечают за тестовый код и эталонные профили. QA описывает сценарии и использует Mock Admin для ручной настройки и воспроизведения. ИИ-агент ускоряет разработку, но не участвует в обязательном выполнении регрессии.

Регулярный запуск устроен проще: XCUITest сбрасывает backend, активирует эталонный профиль с отключёнными пользовательскими переопределениями, запускает приложение и сохраняет диагностические артефакты. CI работает только с нативными тестами и не зависит от доступности ИИ-агента.

У mock backend есть важная граница: его ответы могут разойтись с реальным API. Поэтому базовые fixtures и профили необходимо проверять относительно актуальной схемы с помощью OpenAPI, consumer-driven contracts или отдельных contract tests. Сам mock backend не заменяет интеграционные проверки с настоящим окружением — он даёт детерминированные состояния для UI-регрессии.

Сильная сторона решения не в отдельном инструменте, а в связке всех компонентов. XcodeBuildMCP сокращает путь от текстового кейса до готового XCUITest. Robot Pattern даёт тестам стабильный язык. Launch arguments управляют состоянием приложения. Профили делают ответы backend воспроизводимыми, а Mock Admin позволяет команде работать с теми же конфигурациями без ручных служебных запросов.

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