MiniBin エンジニアリングノート

クラウドMacの時刻・タイムゾーン管理でテストを安定化

クラウドMacの時刻・タイムゾーン管理でテストを安定化

同じ日付テストがローカルでは通るのに、クラウドMacでは日付が時折1日ずれる場合、まずXCTestを疑うべきではありません。多くの場合、ビルドプロセス、シミュレータ、アプリがそれぞれ異なるタイムゾーン、Locale、Calendarを使用していることが原因です。こうした暗黙の依存関係は、夏時間の切り替え、月末、深夜0時をまたいだときに初めて表面化します。

4種類の時間依存を特定する

時間に関する問題は、単に「マシンの現在時刻」だけではありません。プロジェクトには少なくとも4層の状態があります。

レイヤー 主な発生源 代表的な影響
ホスト システムのタイムゾーン、ネットワーク時刻同期 ログの時刻、分離されていないスクリプト
ビルドプロセス TZLANGLC_ALL Shellツール、生成スクリプト、テストプロセス
シミュレータとアプリ 起動環境、地域設定 日付表示、カレンダー計算
ビジネスロジック Date()Calendar.current 期限判定、日単位の集計、カウントダウン

「現在時刻」は安定したテスト入力ではありません。実行された瞬間に依存するアサーションには、再現性がありません。

まず、失敗の種類を切り分けます。常に数時間ずれるなら、タイムゾーンを優先的に確認します。特定の言語環境だけで失敗するならLocaleを確認します。月末、うるう日、夏時間の切り替え付近で失敗するなら、Calendarと日付演算を確認します。深夜0時付近だけで失敗する場合は、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と安定したLocaleを統一して使用し、再現可能なタイムスタンプに対応するツールには現在のコミット時刻を渡せます。

#!/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を渡します。1回のビジネス処理ではnowを1回だけ取得し、処理中に秒、分、深夜0時の境界をまたぐことを防ぎます。

日付演算のルールも明示的に指定する必要があります。サーバープロトコルの時刻には、固定フォーマット、en_US_POSIX、グレゴリオ暦、UTCを使用し、ユーザーインターフェースには現在のLocaleを使用します。フォーマット済みの日本語や英語の日付をビジネス判定に使ってはいけません。また、「次のローカル暦日」を固定の24時間相当の秒数で置き換えないでください。夏時間の切り替えによって、1日の実際の長さが変わる可能性があるためです。

境界ケースを網羅する

少なくとも次の固定ケースを用意します。

  • UTCの日付とローカル日付が一致しない時刻;
  • 月末、年末、うるう日;
  • 夏時間の開始と終了付近;
  • 12時間制と24時間制;
  • 月曜日を週の開始日とする地域と、日曜日を開始日とする地域;
  • 非ラテン数字を使うLocale、または日付要素の順序が異なるLocale。

これらのケースは、タイムゾーンを含む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を読み取るのは、テストビルドだけに限定します。解析に失敗した場合は、システム時刻へ暗黙にフォールバックせず、直ちにテストを失敗させてください。本番ビルドでは、この入力経路を受け付けないようにします。

各テストバッチでは、デバイス、OSランタイム、言語、地域も明示する必要があります。あるテストケースがグローバルなLocaleを変更し、その状態を次のバッチに残さないようにしてください。複数の地域を検証する必要がある場合は、地域ごとにテストジョブを分割するか、各バッチの開始前にシミュレータの状態を再構築します。同一セッション内で設定を何度も切り替えるより、結果を解釈しやすくなります。

時間条件をビルドの受け入れ基準に含める

最終的な受け入れ判定では、単に「テストが通った」ことだけを確認してはいけません。ログにUTC時刻とタイムゾーンオフセットの両方が含まれていること、失敗レポートにLocale、Calendar、テスト用の固定時刻が記録されていること、ジョブがホストのグローバルタイムゾーンを変更していないことも確認します。

確認手順は、ベースラインの取得、プロセス環境の設定、新しいテストセッションの開始、固定クロックの注入、境界ケースの実行、コンテキストのアーカイブという順序に固定できます。日付のずれが再び発生しても、ジョブを繰り返し実行して偶然の成功を待つのではなく、差異がホスト、プロセス、シミュレータ、ビジネスロジックのどこから生じたのかをチームで判断できます。

よくある質問

CIでTZ=UTCを設定すれば日付問題はすべて解決しますか?

解決しません。TZが作用するのは変数を継承したプロセスです。シミュレータや起動済みアプリ、明示設定されたCalendarには別の条件が残るため、固定Clockと各設定の注入も必要です。

DateFormatterは常にen_US_POSIXへ固定すべきですか?

通信形式や機械処理するログには適しています。ユーザー表示では現在のLocaleを使い、テスト時だけ対象Localeを明示的に渡す設計が適切です。

CIジョブごとにMac全体のタイムゾーンを変更しない方がよい理由は何ですか?

並列ジョブが同じグローバル設定を書き換えると、実行順によって結果が変わるためです。プロセス単位のTZと依存性注入で分離する方が安全です。

専有物理クラウドMac

再現可能な開発環境を専有物理ノードにデプロイ

構成、利用期間、5つのノードのいずれかを選択し、Xcodeビルド、自動テスト、リモート開発、モデル推論に利用できます。

構成を選んで注文