MiniBin 工程笔记

云端 Mac 时间与时区治理:消除日期类测试漂移

云端 Mac 时间与时区治理:消除日期类测试漂移

同一组日期测试在本地通过,到了云端 Mac 却偶尔差一天,通常先别怀疑 XCTest。更常见的原因是构建进程、模拟器和应用分别使用了不同的时区、Locale 或 Calendar。跨过夏令时切换点、月末和午夜后,这类隐含依赖才会暴露。

先识别四类时间依赖

时间问题不只有“机器几点”。工程里至少存在四层状态:

层级 常见来源 典型影响
主机 系统时区、网络授时 日志时间、未隔离的脚本
构建进程 TZLANGLC_ALL Shell 工具、生成脚本、测试进程
模拟器与应用 启动环境、区域设置 日期展示、日历计算
业务代码 Date()Calendar.current 到期判断、跨日统计、倒计时

“当前时间”不是稳定的测试输入。只要断言依赖执行瞬间,它就不具备可复现性。

先把失败分型。固定相差数小时,优先检查时区;只在特定语言环境失败,检查 Locale;月末、闰日或夏令时附近失败,检查 Calendar 与日期运算;只在午夜附近失败,通常是重复读取 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;用户界面则使用当前 Locale。不要拿格式化后的中文或英文日期做业务判断,也不要用固定的 24 小时秒数代替“下一个本地自然日”,因为夏令时切换可能改变一天的实际长度。

覆盖边界样本

至少准备以下固定样本:

  • UTC 日期与本地日期不一致的时刻;
  • 月末、年末和闰日;
  • 夏令时开始与结束附近;
  • 12 小时制与 24 小时制;
  • 周一和周日作为一周起点的区域;
  • 非拉丁数字或不同日期顺序的 Locale。

这些样本应直接写成带时区的 ISO 8601 输入,再转换为 Date,不要依赖测试运行当天。

隔离模拟器与界面测试

界面测试要区分“业务时间”和“状态栏显示”。修改状态栏只能稳定截图外观,不能改变应用对 Date() 的读取。真正需要固定业务时间时,可以通过启动环境把测试值传给应用:

SIMCTL_CHILD_UITEST_FIXED_NOW='2026-07-23T12:00:00Z' \
xcrun simctl launch --terminate-running booted com.example.App

应用只在测试构建中读取 UITEST_FIXED_NOW,解析失败时应让测试立即失败,而不是静默退回系统时间。正式构建不要接受这个入口。

每个测试批次还应明确设备、系统运行时、语言和区域设置。不要让某个用例改变全局 Locale 后把状态留给下一批。若必须覆盖多区域,按区域拆分测试任务或在每批开始前重建模拟器状态,结果会比在同一会话里来回切换更容易解释。

把时间条件纳入构建验收

最终验收不应只看“测试通过”。还要确认日志同时包含 UTC 时间和时区偏移,失败报告记录 Locale、Calendar 与测试固定时间,并确保任务没有修改主机全局时区。

检查顺序可以固定为:采集基线、设置进程环境、启动全新测试会话、注入固定时钟、执行边界样本、归档上下文。这样再次出现日期漂移时,团队能判断差异来自主机、进程、模拟器还是业务代码,而不是靠重复运行碰运气。

常见问题

CI 中统一设置 TZ=UTC,就能解决所有日期测试问题吗?

不能。TZ 只约束继承该环境变量的进程,模拟器、已运行的应用和显式创建的 Calendar 仍可能使用其他设置。测试代码还应注入固定时钟,并明确 Locale、Calendar 与 TimeZone。

日期格式化应该固定为 en_US_POSIX 吗?

协议字段、日志键和机器可读时间可以使用 en_US_POSIX;面向用户的日期不应固定为该区域,而应使用用户当前 Locale,并在测试中显式传入目标区域设置。

为什么不建议在每个 CI 任务中修改系统全局时区?

同一节点上的并行任务可能互相覆盖全局设置,导致结果随执行顺序变化。更稳妥的做法是在任务进程中设置 TZ,并让应用和测试依赖显式注入的时区。

独享物理云端 Mac

把可复现的开发环境部署到独享物理节点

选择配置、租期和五个节点之一,用于 Xcode 构建、自动化测试、远程开发或模型推理。

选择配置并订购