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 建置、自動化測試、遠端開發或模型推論。

選擇設定並訂購