Регрессионное тестирование фоновых задач iOS на облачном Mac

Регрессионное тестирование фоновых задач iOS на облачном Mac

Фоновое обновление на машине разработчика иногда срабатывает, но после переноса в CI нередко оказывается, что задача «не выполнялась». Обычно проблема не в коде самой задачи, а в тестах, которые считают момент системного запуска предсказуемым условием. Когда именно iOS разбудит процесс, решает система, и облачный Mac не превращает этот механизм в точный таймер. Надёжный подход — разделить три составляющие: статическую конфигурацию регистрации, тонкий адаптер планировщика и бизнес-логику, которая непосредственно выполняет синхронизацию или очистку.

Сначала определите проверяемые границы

Цепочка фоновой задачи включает как минимум регистрацию, отправку запроса, системный обратный вызов, выполнение работы, отмену по истечении времени и передачу результата. Система непрерывной интеграции должна стабильно проверять начало и конец этой цепочки, а не ждать, пока система «случайно» запустит задачу.

Уровень Автоматическая проверка Что не следует проверять утверждением
Конфигурация Идентификатор, объявленные возможности, конфигурация целевого пакета Когда система разбудит процесс
Адаптер Успешная регистрация, передача обратного вызова, статус завершения Приоритет планирования
Задача Результат, ошибки, отмена, повторное выполнение Реальный заряд и привычки пользователя

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

Сначала зафиксируйте идентификатор задачи, например com.example.app.refresh. Он должен одновременно присутствовать в коде регистрации и в BGTaskSchedulerPermittedIdentifiers. Если разные конфигурации сборки создают разные файлы Info.plist, проверяйте результат сборки, а не только исходный файл из репозитория.

Сведите планировщик к тонкому адаптеру

Не размещайте сетевую логику, работу с базой данных и кэшем непосредственно в обратном вызове BGAppRefreshTask. Сначала определите заменяемый исполнитель:

protocol RefreshJob {
    func run() async throws
    func cancel()
}

final class BackgroundRefreshAdapter {
    private let job: RefreshJob

    init(job: RefreshJob) {
        self.job = job
    }

    func handle(task: BGAppRefreshTask) {
        task.expirationHandler = { [job] in
            job.cancel()
        }

        Task {
            do {
                try await job.run()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }
    }
}

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

Передавайте отмену до самого нижнего уровня

Одного логического флага обычно недостаточно. Состояние отмены следует проверять между загрузкой, разбором данных и пакетной записью. При использовании конкурентности Swift на границах этапов можно вызывать Task.checkCancellation(). Для записи в базу данных предпочтительны короткие транзакции, чтобы после истечения времени задача не удерживала долгую транзакцию и не оставляла частично записанные данные.

Сначала настройте шлюз проверки статической конфигурации

Самые частые причины сбоя фоновых задач — опечатка в идентификаторе, отсутствие необходимой возможности в конфигурации целевого объекта или чтение тестом неверного plist. После сборки проверяйте пакет приложения напрямую:

set -euo pipefail

APP_PATH="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME"
PLIST="$APP_PATH/Info.plist"
TASK_ID="com.example.app.refresh"

plutil -extract BGTaskSchedulerPermittedIdentifiers raw "$PLIST" |
  grep -Fx "$TASK_ID"

plutil -extract UIBackgroundModes raw "$PLIST" |
  grep -F "fetch"

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

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

Проверяйте успех, истечение времени и повторное выполнение

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

func testRepeatedRunIsIdempotent() async throws {
    let store = InMemoryStore()
    let client = StubClient(items: [.init(id: "42")])
    let job = SyncRefreshJob(client: client, store: store)

    try await job.run()
    try await job.run()

    XCTAssertEqual(store.items.map(\.id), ["42"])
    XCTAssertEqual(store.commitCount, 2)
}

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

Не добавляйте ожидание через sleep в тесты

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

Архивируйте проверяемые артефакты на облачном Mac

Сначала выведите список симуляторов, доступных на текущем узле, а затем выберите среду выполнения, установленную для проекта:

xcrun simctl list devices available

xcodebuild test \
  -scheme BackgroundTasks \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \
  -resultBundlePath artifacts/BackgroundTasks.xcresult

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

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

Часто задаваемые вопросы

Может ли симулятор подтвердить точное время запуска фоновой задачи?

Нет. На симуляторе можно проверять регистрацию, логику, отмену и идемпотентность, но момент системного пробуждения нельзя использовать как детерминированное условие CI.

Что делает тесты BGTaskScheduler устойчивыми?

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

ВЫДЕЛЕННЫЙ СЛОТ ДЛЯ СБОРКИ

Выберите выделенный облачный Mac для непрерывных задач сборки

Проверьте M4, объём памяти, хранилище, узел и расчётный период, а затем подключите стабильное окружение сборки к существующему конвейеру.

Выбрать конфигурацию и заказать