Если один и тот же набор тестов дат проходит локально, но на Cloud Mac время от времени даёт расхождение в один день, не стоит сразу подозревать XCTest. Гораздо чаще процесс сборки, симулятор и приложение используют разные часовые пояса, локали или календари. Такие скрытые зависимости обычно проявляются только при переходе на летнее или зимнее время, в конце месяца либо около полуночи.
Определите четыре вида зависимостей от времени
Проблемы со временем не сводятся к тому, «который час на машине». В проекте есть как минимум четыре уровня состояния:
| Уровень | Типичный источник | Типичное влияние |
|---|---|---|
| Хост | Системный часовой пояс, сетевая синхронизация времени | Время в журналах, неизолированные скрипты |
| Процесс сборки | TZ, LANG, LC_ALL |
Инструменты Shell, скрипты генерации, тестовые процессы |
| Симулятор и приложение | Среда запуска, региональные настройки | Отображение дат, календарные вычисления |
| Бизнес-логика | Date(), Calendar.current |
Проверка сроков, статистика на границе суток, обратный отсчёт |
«Текущее время» — нестабильное входное значение для теста. Если проверка зависит от момента выполнения, её результат нельзя воспроизвести гарантированно.
Сначала следует классифицировать сбой. При постоянном расхождении на несколько часов в первую очередь проверяйте часовой пояс. Если ошибка возникает только в определённой языковой среде, проверяйте локаль. Сбои в конце месяца, в високосный день или рядом с переводом часов указывают на календарь и операции с датами. Ошибки исключительно около полуночи обычно вызваны повторным чтением Date() или неверным определением границ локальных суток.
Фиксируйте базовое состояние узла, а не ищите причину наугад
В начале каждого задания записывайте параметры среды, но не выводите учётные данные или полный набор переменных окружения. Следующих команд достаточно, чтобы получить базовую информацию о времени:
sw_vers
xcodebuild -version
date '+%Y-%m-%dT%H:%M:%S%z'
date -u '+%Y-%m-%dT%H:%M:%SZ'
sudo systemsetup -gettimezone
sudo systemsetup -getusingnetworktime
defaults read -g AppleLocale
defaults read -g AppleLanguages
locale
Архивируйте вывод вместе с версией коммита и номером задания. При диагностике сравнивайте успешные и неуспешные задания, а не изучайте только состояние в момент сбоя. MiniBin предоставляет выделенные физические узлы, однако на одном узле по-прежнему могут параллельно выполняться несколько заданий сборки. Поэтому глобальные настройки остаются общим состоянием для разных заданий.
Не вызывайте systemsetup -settimezone заново в каждом задании. Эта команда изменяет конфигурацию на уровне хоста, поэтому параллельные задания могут перезаписывать настройки друг друга. Изоляцию на уровне процесса следует предпочесть во всех случаях, кроме узлов, которые выполняют только одну последовательную задачу с явным требованием использовать локальный часовой пояс.
Задайте детерминированную базовую среду в процессе CI
Скрипты сборки могут единообразно использовать UTC и стабильную локаль для обработки символов, одновременно передавая время текущего коммита инструментам, которые поддерживают воспроизводимые временные метки:
#!/bin/zsh
set -euo pipefail
export TZ=UTC
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
date -u '+build_started=%Y-%m-%dT%H:%M:%SZ'
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$PWD/.derived-data" \
test
SOURCE_DATE_EPOCH — не универсальный переключатель, который распознаётся на всех этапах работы Xcode. Эту переменную следует использовать только как входное значение для скриптов и средств упаковки, поддерживающих данное соглашение. Она не заменяет внедрение времени в приложение.
Также следует учитывать, что TZ=UTC влияет только на процессы, унаследовавшие эту переменную. Уже запущенный симулятор или фоновая служба не обновит окружение автоматически. Поэтому каждое задание должно самостоятельно запускать необходимые процессы и очищать их после завершения, а не повторно использовать долгоживущие сеансы неизвестного происхождения.
Внедряйте в приложение часы, часовой пояс и календарь
Если прямые вызовы Date() и Calendar.current разбросаны по бизнес-логике, тестам сложно контролировать граничные условия. Надёжнее представить понятие «сейчас» в виде зависимости:
protocol Clock {
var now: Date { get }
}
struct SystemClock: Clock {
var now: Date { Date() }
}
struct FixedClock: Clock {
let now: Date
}
В рабочей среде передавайте SystemClock, а в тестах — FixedClock. В рамках одной бизнес-операции значение now следует читать только один раз, чтобы во время выполнения не пересечь границу секунды, минуты или суток.
Правила вычислений с датами также нужно задавать явно. Для времени из серверных протоколов используйте фиксированный формат, en_US_POSIX, григорианский календарь и UTC; для пользовательского интерфейса — текущую локаль. Не принимайте бизнес-решения на основе отформатированных дат на русском, китайском или английском языке. Также не заменяйте понятие «следующий локальный календарный день» фиксированным количеством секунд в 24 часах: перевод часов может изменить фактическую продолжительность суток.
Покройте граничные случаи
Подготовьте как минимум следующие фиксированные примеры:
- моменты, когда дата UTC не совпадает с локальной датой;
- конец месяца, конец года и високосный день;
- моменты рядом с началом и окончанием летнего времени;
- 12-часовой и 24-часовой формат;
- регионы, где первым днём недели считается понедельник или воскресенье;
- локали с нелатинскими цифрами или другим порядком компонентов даты.
Эти значения следует задавать напрямую в виде входных данных ISO 8601 с часовым поясом, а затем преобразовывать в Date. Они не должны зависеть от дня, в который запускается тест.
Изолируйте симулятор и UI-тесты
В UI-тестах необходимо различать «время бизнес-логики» и время, отображаемое в строке состояния. Изменение строки состояния позволяет стабилизировать внешний вид снимков экрана, но не влияет на значение, которое приложение получает через Date(). Если требуется зафиксировать именно время бизнес-логики, тестовое значение можно передать приложению через среду запуска:
SIMCTL_CHILD_UITEST_FIXED_NOW='2026-07-23T12:00:00Z' \
xcrun simctl launch --terminate-running booted com.example.App
Приложение должно считывать UITEST_FIXED_NOW только в тестовых сборках. Если значение не удалось разобрать, тест должен немедленно завершиться с ошибкой, а не незаметно вернуться к системному времени. Рабочие сборки не должны принимать этот параметр.
Для каждой серии тестов также нужно явно задавать устройство, версию системной среды выполнения, язык и регион. Не допускайте, чтобы один тест изменял глобальную локаль и оставлял это состояние следующей серии. Если необходимо проверить несколько регионов, разделите задания по регионам либо заново создавайте состояние симулятора перед каждой серией. Такие результаты проще интерпретировать, чем многократное переключение настроек в одном сеансе.
Включите временные условия в приёмку сборки
Итоговая приёмка не должна ограничиваться условием «тесты прошли». Убедитесь, что журналы содержат и время UTC, и смещение часового пояса, а отчёты об ошибках фиксируют локаль, календарь и заданное для теста время. Кроме того, задание не должно изменять глобальный часовой пояс хоста.
Порядок проверки можно закрепить следующим образом: собрать базовую информацию, настроить окружение процесса, запустить новый тестовый сеанс, внедрить фиксированные часы, выполнить граничные примеры и архивировать контекст. Тогда при следующем расхождении дат команда сможет определить, где возникла разница — на хосте, в процессе, симуляторе или бизнес-логике, — вместо того чтобы рассчитывать на случайный успех при повторных запусках.
Часто задаваемые вопросы
Решает ли TZ=UTC все проблемы с датами в CI?
Нет. Переменная влияет только на унаследовавшие её процессы. Симулятор, уже запущенное приложение или явно настроенный Calendar могут работать иначе. Нужны также фиксированные часы и явные зависимости.
Следует ли всегда использовать en_US_POSIX для форматирования дат?
Эта локаль подходит для протоколов и машиночитаемых значений. Пользовательский интерфейс должен учитывать текущую локаль, а тесты — явно передавать требуемую локаль.
Почему в каждом задании CI не стоит менять часовой пояс всей системы?
Параллельные задания способны перезаписывать общую настройку, делая результат зависимым от порядка запуска. Переменная TZ на уровне процесса и внедрение зависимостей обеспечивают надёжную изоляцию.
Выделенный физический облачный Mac
Разверните воспроизводимую среду разработки на выделенном физическом узле
Выберите конфигурацию, срок аренды и один из пяти узлов для сборки в Xcode, автоматизированного тестирования, удалённой разработки или инференса моделей.