エンジニアリング

クラウドMacでiOS WidgetKitのタイムラインと共有データを検証する

クラウドMacでiOS WidgetKitのタイムラインと共有データを検証する

チームがToDoの件数をメインアプリに保存しても、リモートシミュレータ上のウィジェットには古い値が表示されたまま。このとき更新APIを何度も呼んでも、たいていは調査を複雑にするだけです。共有データへの書き込み、拡張機能による読み取り、タイムラインの生成、システムによる表示タイミングの決定は、それぞれ別の段階です。ToDoの件数を表示するWidgetKitウィジェットを例に、各段階を個別に検証できる条件に分けていきます。

データ受け渡しの境界を明確にする

メインアプリとウィジェット拡張機能は別のプロセスです。通常のUserDefaults.standardが、自動的に双方の共有データソースになるわけではありません。まず、プロジェクト内の両方のtargetで同じApp Groupを有効にし、双方から参照できるプロジェクト設定にグループ識別子を置きます。テスト用の識別子をリリース設定にハードコードしないでください。

メインアプリで業務データを書き込んだら、件数と更新日時をUserDefaults(suiteName:)に保存します。拡張機能は同じsuite名で読み取ります。書き込み関数は古い値を黙って使うのではなく、成功またはエラーを返すようにします。拡張機能で読み取りに失敗した場合は、明確なデフォルト状態を表示します。データが整数一つに収まらないなら、バージョン番号付きの単一レコードとしてエンコードし、件数だけが更新されてタイトルは古いまま、というフィールド間の食い違いを防ぎます。

更新要求は、システムに「データが変わった可能性がある」と伝えるだけです。メインアプリから拡張機能へデータを運ぶものでも、ウィジェットの即時再描画を保証するものでもありません。

まずメインアプリで書き込んだ値を一度読み戻し、拡張機能の読み取り箇所では機密情報を除いたバージョン番号を記録します。双方のバージョンが違えば、問題はデータの受け渡しにあります。バージョンが同じなのに画面が違う場合に、タイムラインと表示を調べます。タスクの内容、アカウント情報、共有レコード全体を診断ログに書き込まないでください。

タイムラインの計算を純粋関数にする

タイムラインのエントリ内容を、テスト実行時の実際の時計に依存させてはいけません。プロバイダの役割はデータの読み取りと計算関数の呼び出しに絞り、計算関数には現在時刻と読み取ったスナップショットを渡します。次のコードは最小限の境界を示したものです。型はメインアプリと拡張機能で共有するソースファイルに配置できます。

import Foundation

struct CountEntry: Equatable {
    let date: Date
    let count: Int
    let revision: Int
}

func makeEntries(
    now: Date,
    count: Int,
    revision: Int
) -> [CountEntry] {
    [
        CountEntry(date: now, count: count, revision: revision),
        CountEntry(
            date: now.addingTimeInterval(15 * 60),
            count: count,
            revision: revision
        )
    ]
}

二つ目のエントリにも同じスナップショットを持たせます。15分経過しただけでToDoの件数が変わると推測してはいけません。プロバイダがエントリを取得した後は、最後のエントリより後をタイムラインの再要求タイミングとして指定できます。このポリシーが示すのは確認を希望する時点であり、画面が何秒以内に更新されるという約束ではありません。新しい業務データを受け取ったときに、該当する種類のウィジェットの再読み込みを要求します。正確なカウントダウン表示が製品要件なら、タイムラインのエントリを密に並べてリアルタイム処理を模倣するのではなく、要件自体を見直すべきです。

入力を固定し、制御可能な結果を検証する

ユニットテストでは固定したDate、件数、リビジョン番号を用意し、エントリがちょうど二つ生成されること、日付が厳密に昇順であること、両エントリの件数とリビジョン番号が一致することを検証します。件数が0の境界条件もテストします。0は実際の値として描画されるべきで、読み取り失敗時のデフォルト値に隠されてはいけません。このテストではシミュレータの起動もシステムのスケジュール待ちも不要なので、失敗すれば計算ロジックを直接調べられます。

クラウドのシミュレータで実際の受け渡しを検証する

まずxcrun simctl list devices availableで、利用可能なiOSシミュレータとそのデバイスIDを確認します。ビルドとテストの対象IDを固定し、実行環境が変わった後にデバイス名で別のランタイムを選んでしまうのを防ぎます。

xcodebuild test \
  -project Widgets.xcodeproj \
  -scheme Widgets \
  -destination "platform=iOS Simulator,id=$SIM_ID" \
  -resultBundlePath "$PWD/widget-tests.xcresult"

ここでのプロジェクト名とschemeは例です。実行前にリポジトリに実在する名前へ置き換えてください。SIM_IDはタスク設定から明示的に渡します。既存の.xcresultが原因でコマンドが途中で失敗しないよう、実行ごとに新しい結果ディレクトリを使います。タイムラインのユニットテストが通ったら、メインアプリと拡張機能を含むビルドをインストールし、シミュレータにウィジェットを追加します。最初にデフォルト状態を確認し、次にメインアプリから新しいリビジョンのデータを書き込んで更新を要求します。最後に拡張機能の読み取り記録と画面を照合します。

画面の受け入れ確認では最終的な内容とリビジョン番号だけを確認し、「要求から数秒以内に表示されること」を必須のアサーションにしないでください。システムが更新タイミングを調整することがあります。即時表示を厳密に検証する必要があるなら、システムのスケジューリングをアプリのタイマーとみなすのではなく、メインアプリ内の制御可能なプレビュー画面をテストします。

再インストールを繰り返さず、症状から原因を絞る

観察結果 優先して確認する項目
メインアプリで読み戻した値から間違っている 書き込みのタイミング、suite名、エラー処理
メインアプリでは正しいが、拡張機能では古い値を読む 両方のtargetのApp Group設定とレコードのバージョン
拡張機能では新しい値を読めるが、エントリは古い プロバイダに渡す引数とタイムラインの構築
新しいエントリは正しいが、画面は古い ウィジェットの種類、ビューが参照する値、システムが実際に表示するタイミング

調査前に、プロジェクトのコミット、ランタイムのバージョン、デバイスID、共有レコードのリビジョン番号を記録します。「画面が変わらない」というスクリーンショット一枚より、問題の把握に役立ちます。失敗するたびにシミュレータ全体をすぐ消去しないでください。再現に必要な共有データまで失われます。リセットが必要な場合は、テスト結果と必要な機密情報を除いた記録を先に保存し、アプリデータの消去とシミュレータの再作成のどちらを行うか明確に選びます。

受け入れ条件をパイプラインに組み込む

自動化タスクは二層に分けます。第一層では純粋関数のテストを実行し、エントリの順序、0の値、リビジョン番号のリグレッションを防ぎます。第二層では固定したシミュレータ上でアプリと拡張機能の両方がインストールされ、同じレコードを読んでいることを確認し、レイアウトを手動または制御可能なプレビューで検証します。ウィジェットの実際の更新は別の観察項目とし、第一層の決定的なアサーションとは混ぜません。

最後に三点を確認します。両方のtargetが同じ共有グループを参照しているか、新しいデータに内容を漏らさず追跡できるリビジョン番号が付いているか、テストが実際に数分経つのを待つのではなく時刻を入力として受け取っているか、です。この三点が満たされていれば、次に古い画面が現れても、更新要求を繰り返して原因を推測する代わりに、データ、エントリ、表示の順に問題を切り分けられます。

よくある質問

更新を要求してもウィジェットがすぐ変わらないのはなぜですか?

更新要求は即時実行の保証ではありません。共有コンテナの値とプロバイダーの読み取り結果を先に確認し、表示の変化だけで成否を判断しないでください。

タイムラインのテストでシミュレータの更新を待つ必要はありますか?

ありません。時刻とデータを純粋関数に渡してエントリーの順序と内容を検証し、シミュレータではインストール、データ共有、最終表示を確認します。

専有Appleシリコン

固定構成のクラウドMacをワークフローに導入

専有物理ノードを日単位・週単位・月単位・四半期単位でレンタル。固定のハードウェアリソースでビルド、自動化、MLX推論を実行できます。

レンタル可能な構成を見る