同一组日期测试在本地通过,到了云端 Mac 却偶尔差一天,通常先别怀疑 XCTest。更常见的原因是构建进程、模拟器和应用分别使用了不同的时区、Locale 或 Calendar。跨过夏令时切换点、月末和午夜后,这类隐含依赖才会暴露。
先识别四类时间依赖
时间问题不只有“机器几点”。工程里至少存在四层状态:
| 层级 | 常见来源 | 典型影响 |
|---|---|---|
| 主机 | 系统时区、网络授时 | 日志时间、未隔离的脚本 |
| 构建进程 | TZ、LANG、LC_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 构建、自动化测试、远程开发或模型推理。