UI-тесты успешно проходят на компьютере разработчика, но после нескольких десятков запусков на облачном Mac иногда застревают на экране онбординга. Обычно причина не в производительности симулятора, а в отсутствии чёткого контракта начального состояния. Один тест передаёт -skipOnboarding, другой использует переменную окружения, а третий забывает удалить сохранённые данные. По отдельности каждый сценарий выглядит корректно, но при параллельном выполнении или изменении порядка тесты начинают влиять друг на друга. Решение — рассматривать имена параметров, формат значений, сброс состояния и исключение тестового кода из Release как единый проверяемый интерфейс.
Сначала определите контракт запуска, а не разбрасывайте строки по коду
Параметры запуска подходят для логических флагов, а переменные окружения — для настроек со значениями. И те и другие следует объявлять централизованно, чтобы тестовый target и target приложения не использовали разные варианты написания. Приведённый ниже парсер работает только в сборках Debug и явно задаёт поведение для отсутствующих и пустых значений.
#if DEBUG
enum UITestLaunchContract {
static let arguments = ProcessInfo.processInfo.arguments
static let environment = ProcessInfo.processInfo.environment
static var enabled: Bool {
arguments.contains("-ui-testing")
}
static var resetState: Bool {
arguments.contains("-reset-state")
}
static var fixture: String? {
guard enabled else { return nil }
return environment["UITEST_FIXTURE"]?.trimmingCharacters(
in: .whitespacesAndNewlines
).nonEmpty
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
#endif
Контракт должен отвечать как минимум на четыре вопроса: как называется флаг, откуда читается значение, что происходит с недопустимым значением и как ведёт себя приложение при обычном, не тестовом запуске. Не допускайте, чтобы отсутствие значения неявно означало какое-либо бизнес-состояние. Также не передавайте через переменные окружения учётные данные, материалы для подписи или токены: они могут попасть в сведения о процессе, отчёты о тестировании или журналы ошибок.
Назначение тестовой точки входа — создавать воспроизводимое состояние, а не обходить бизнес-проверки. Утверждения, которые можно проверить через публичный интерфейс, по-прежнему должны работать через него. В контракт запуска стоит включать только инициализацию фикстур и удаление остаточных данных.
Изолируйте состояние на раннем этапе запуска приложения
Сброс состояния должен выполняться до чтения пользовательских настроек, создания контейнера базы данных и выбора первого экрана. Если сначала построить интерфейс, а затем асинхронно очистить данные, тест одновременно увидит старое и новое состояние, что приведёт к трудно воспроизводимой гонке.
Удаляйте только данные, принадлежащие тестам
Для UI-тестов используйте отдельный suite вместо неограниченного удаления каталогов. Базу данных также следует размещать в специальном тестовом контейнере и удалять до создания стека постоянного хранения.
#if DEBUG
func prepareUITestState() throws {
guard UITestLaunchContract.enabled else { return }
if UITestLaunchContract.resetState {
let defaults = UserDefaults(suiteName: "com.example.app.uitests")
defaults?.removePersistentDomain(
forName: "com.example.app.uitests"
)
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("UITestStore.sqlite")
if FileManager.default.fileExists(atPath: storeURL.path) {
try FileManager.default.removeItem(at: storeURL)
}
}
}
#endif
Функция очистки должна быть идемпотентной: отсутствие целевого объекта не должно считаться ошибкой, а при неудачном удалении запуск теста следует немедленно прекратить, не продолжая работу с частично очищенным состоянием. Имена фикстур также должны выбираться по белому списку, например empty-project и three-items. Нельзя позволять переменной окружения напрямую задавать произвольный путь к файлу.
Централизованно формируйте параметры запуска в XCTest
На стороне тестов должен использоваться только один фабричный метод запуска. Он завершает старый процесс, записывает фиксированные параметры и проверяет фикстуру, поэтому отдельным тестовым методам больше не нужно самостоятельно изменять launchArguments.
final class AppLauncher {
static func launch(fixture: String) -> XCUIApplication {
let allowed = ["empty-project", "three-items"]
precondition(allowed.contains(fixture))
let app = XCUIApplication()
if app.state != .notRunning {
app.terminate()
}
app.launchArguments = [
"-ui-testing",
"-reset-state",
"-disable-animations"
]
app.launchEnvironment = [
"UITEST_FIXTURE": fixture,
"LC_ALL": "en_US_POSIX"
]
app.launch()
return app
}
}
Присваивайте launchArguments целиком, а не вызывайте append несколько раз для общего экземпляра приложения. Полное присваивание гарантирует, что при повторном запуске не останутся флаги от предыдущего теста. Если тесту нужны два разных состояния, завершите процесс и снова вызовите фабричный метод, а не меняйте переменные окружения во время работы приложения. После запуска процесса ProcessInfo не инициализируется повторно с новыми значениями из тестового сценария.
Превращайте ошибки контракта в сбой конвейера
Одного ревью кода недостаточно, чтобы исключить использование устаревших параметров. Добавьте небольшой модульный тест, который проверяет уникальность имён параметров и возможность разобрать каждую фикстуру. Дымовые UI-тесты должны охватывать три пути: обычный запуск без параметров, переход на ожидаемый первый экран с допустимой фикстурой и явный сбой при недопустимой фикстуре.
Перед запуском на облачном Mac зафиксируйте scheme, план тестирования и идентификатор симулятора:
set -euo pipefail
: "${SIMULATOR_ID:?SIMULATOR_ID is required}"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan CI \
-destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
-resultBundlePath artifacts/LaunchContract.xcresult
Конвейер не должен скрывать первый сбой повторным запуском. Пакет результатов первого неудачного запуска необходимо сохранить без изменений. Повторный запуск допустим только как диагностический шаг и должен использовать новый путь вывода. Иначе успешная вторая попытка перезапишет наиболее ценные данные о состоянии приложения при запуске.
Проверяйте условия компиляции Release
После защиты тестовой точки входа директивой #if DEBUG необходимо также убедиться, что в Release по ошибке не включены DEBUG или пользовательские условия тестирования:
set -euo pipefail
settings="$(
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings
)"
if printf '%s\n' "$settings" |
grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
echo "Unexpected test compilation condition in Release" >&2
exit 1
fi
Эта проверка анализирует итоговые вычисленные настройки сборки, поэтому она надёжнее простого просмотра файла проекта. Если команда использует несколько файлов xcconfig, проверку следует отдельно запускать для каждой конфигурации, предназначенной для выпуска.
Учитывайте параллельное выполнение и сохраняйте данные о сбоях
При параллельном тестировании у каждого worker должен быть отдельный идентификатор данных. Конвейер может сгенерировать нечувствительный UITEST_RUN_ID, на основе которого формируются временный каталог и имя suite. Не позволяйте нескольким worker использовать один и тот же файл базы данных. После завершения теста удаляйте только каталог, соответствующий идентификатору текущего запуска, чтобы одна задача не уничтожила данные о сбое другой.
Среди данных о сбое следует сохранять как минимум белый список параметров запуска, имя фикстуры, тестовый метод, идентификатор симулятора и путь к пакету результатов. Не выводите в журнал полный словарь переменных окружения: в нём могут оказаться конфиденциальные значения, добавленные конвейером. Рекомендуется записывать только разрешённые ключи и маскировать значения в соответствии с их типом.
Используйте фиксированный порядок диагностики:
- Убедитесь, что приложение действительно было завершено перед запуском.
- Проверьте, что параметры присваиваются целиком и не содержат повторяющихся ключей.
- Убедитесь, что состояние очищается до инициализации базы данных и корневого интерфейса.
- Сравните каталоги данных неудачного и предыдущего тестов.
- По одному и тому же пакету результатов проверьте временную шкалу сбоев, утверждений и снимков экрана.
- Выполните один холодный запуск без тестовых параметров и убедитесь, что обычная точка входа не нарушена.
Минимальный контрольный список перед выпуском
Перед слиянием изменений должны одновременно выполняться следующие условия: имена параметров определены централизованно; настройки со значениями ограничены белым списком; тестовые данные находятся в отдельном контейнере; очистку можно безопасно выполнять повторно; при каждом запуске параметры полностью перезаписываются; параллельные worker не используют общие каталоги; повторные попытки не перезаписывают пакет результатов сбоя; условия компиляции Release не содержат тестовых флагов; холодный запуск без параметров приводит к обычному сценарию работы.
Ценность этих ограничений заключается не просто в добавлении ещё одной фабрики запуска. Они превращают скрытую договорённость тестового сценария о том, «в каком состоянии запускается приложение», в интерфейс, который могут проверять и само приложение, и конвейер. Только когда состояние можно описать, отклонить и очистить, запуски на облачном Mac с разным порядком тестов, параллельным выполнением и длительными повторениями начинают давать сопоставимые результаты.
Часто задаваемые вопросы
Почему не стоит добавлять параметры запуска отдельно в каждом UI-тесте?
Разрозненные строки приводят к расхождениям в именах, зависимости от порядка и сохранению устаревших флагов. Имена, форматы значений и значения по умолчанию следует определить в одном общем типе.
Как убедиться, что тестовые переключатели не попали в Release?
Компилируйте их реализацию только при DEBUG, проверяйте условия компиляции и настройки Release в CI, а затем выполняйте холодный запуск без тестовых параметров.
Размещайте проверенные рабочие процессы на выделенных физических узлах
Выберите M4 или M4 Pro, целевой узел и расчётный период, а перед заказом проверьте конфигурацию и дополнительные опции.