クラウドMacで行うiOSバックグラウンドタスク回帰テスト

クラウドMacで行うiOSバックグラウンドタスク回帰テスト

開発用Macではバックグラウンド更新がときどき成功するのに、CIへ組み込むと頻繁に「実行されていない」ように見えることがあります。多くの場合、問題はタスクのコードではなく、テストがシステムのスケジュール時刻を確定条件として扱っていることにあります。プロセスをいつ起動するかはiOSが決定するため、クラウドMacを使っても固定タイマーのようには制御できません。信頼できる方法は、静的な登録設定、スケジューラのアダプター層、実際に同期やクリーンアップを行うビジネスロジックの3つを分離することです。

検証可能な境界を先に定義する

バックグラウンドタスクの処理経路には、少なくとも登録、リクエストの送信、システムからのコールバック、処理の実行、期限切れ時のキャンセル、結果の報告が含まれます。継続的インテグレーションでは、この経路の両端を安定して検証し、システムが「たまたま」起動するのを待つべきではありません。

レイヤー 自動チェック アサーションにすべきでない項目
設定層 識別子、Capability宣言、ターゲットの設定 システムが起動する時刻
アダプター層 登録の成功、コールバックの転送、完了状態 スケジュールの優先度
タスク層 出力、エラー、キャンセル、繰り返し実行 実際のバッテリー残量や利用傾向

回帰テストの目的は、タスクが特定の時刻に開始されることを証明することではありません。システムからタスクが渡された時点で、アプリが正しく完了、キャンセル、または安全に再試行できることを確認することです。

まず、com.example.app.refresh のようにタスク識別子を固定します。この識別子は、登録コードと BGTaskSchedulerPermittedIdentifiers の両方に存在しなければなりません。ビルド構成ごとに異なるInfo.plistが生成される場合は、リポジトリ内のソースファイルだけでなく、実際のビルド成果物を確認します。

スケジューラを薄いアダプター層にする

BGAppRefreshTask のコールバック内に、ネットワーク、データベース、キャッシュのロジックを直接記述しないでください。まず、差し替え可能な実行単位を定義します。

protocol RefreshJob {
    func run() async throws
    func cancel()
}

final class BackgroundRefreshAdapter {
    private let job: RefreshJob

    init(job: RefreshJob) {
        self.job = job
    }

    func handle(task: BGAppRefreshTask) {
        task.expirationHandler = { [job] in
            job.cancel()
        }

        Task {
            do {
                try await job.run()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }
    }
}

実際のタスクには、ネットワーククライアント、ストレージインターフェース、クロックを注入します。これにより、単体テストで BGAppRefreshTask を偽装する必要がなくなり、指定した入力に対して RefreshJob がどのような出力を生成するかだけを検証できます。アダプター層は小さく保ち、実行の転送、期限切れの処理、完了状態の報告だけを担当させます。

キャンセルを最下層まで確実に伝播させる

ブール変数を設定するだけでは、通常は不十分です。ダウンロード、解析、バッチ書き込みの各処理間でキャンセル状態を確認する必要があります。Swift Concurrencyを使用する場合は、各フェーズの境界で Task.checkCancellation() を呼び出せます。データベースへの書き込みには短いトランザクションを使用し、タスクの期限切れ後も長いトランザクションを保持したり、不完全なデータを残したりしないようにします。

静的設定のゲートを先に設ける

バックグラウンドタスクで特に多い失敗は、識別子のタイプミス、Capabilityがターゲット設定に反映されていないこと、またはテストが誤ったplistを読み込んでいることです。ビルド後にアプリバンドルを直接確認します。

set -euo pipefail

APP_PATH="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME"
PLIST="$APP_PATH/Info.plist"
TASK_ID="com.example.app.refresh"

plutil -extract BGTaskSchedulerPermittedIdentifiers raw "$PLIST" |
  grep -Fx "$TASK_ID"

plutil -extract UIBackgroundModes raw "$PLIST" |
  grep -F "fetch"

スクリプトは独立したビルドフェーズとして実行し、入力ファイルへの依存関係を明示して、毎回無条件に実行されることを避けます。処理タスクを使用する場合は、対応するバックグラウンドモードも確認してください。2つのタスクを同じフレームワークで登録しているからといって、必要な宣言が完全に同じだと想定してはいけません。

登録コードからも観測可能な結果を返す必要があります。起動時に各識別子の登録結果をアプリ内部の診断記録へ書き込み、テストでその記録を読み取って、すべて成功したことをアサートします。ログにはタスク識別子、処理フェーズ、エラーの種類だけを残し、アクセストークンやリクエストの完全な内容は記録しないでください。

成功、期限切れ、繰り返し実行を網羅する

ビジネスタスクには、少なくとも4種類のテストが必要です。1つ目では固定レスポンスを使用し、成功後にカーソル、キャッシュ、更新時刻がアトミックにコミットされることを検証します。2つ目ではダウンロードまたは書き込みの段階でエラーを注入し、既存データを引き続き読み取れることを確認します。3つ目ではキャンセルを発生させ、一時ファイルが削除され、成功マーカーが更新されないことを確認します。4つ目では同じ入力を2回連続で処理し、重複レコードが生成されないことを確認します。

func testRepeatedRunIsIdempotent() async throws {
    let store = InMemoryStore()
    let client = StubClient(items: [.init(id: "42")])
    let job = SyncRefreshJob(client: client, store: store)

    try await job.run()
    try await job.run()

    XCTAssertEqual(store.items.map(\.id), ["42"])
    XCTAssertEqual(store.commitCount, 2)
}

冪等性とは、2回目に何もしないことではありません。最終状態が同じであり、繰り返しコミットしても重複オブジェクトが増えないことを意味します。タスクがファイルをアップロードする場合は、安定したビジネスキーを使用して送信済み状態を記録できます。ページネーションカーソルを使用する場合は、「データは書き込まれたが、カーソルはまだ更新されていない」という中断地点をテストします。

テストにスリープ待機を組み込まない

固定の sleep を使って非同期処理の完了時刻を推測しないでください。クロックとバックオフ戦略をプロトコルとして抽象化し、テストでは手動で進められるクロックを使用します。ポーリングには明確な終了条件を設け、失敗メッセージにはタイムアウトだけでなく、現在の処理フェーズも出力します。

クラウドMac上で再確認可能な証跡を保存する

まず現在のノードで利用可能なシミュレータを一覧表示し、プロジェクトにインストール済みのランタイムを選択します。

xcrun simctl list devices available

xcodebuild test \
  -scheme BackgroundTasks \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \
  -resultBundlePath artifacts/BackgroundTasks.xcresult

デバイス名はパイプライン変数から渡し、Xcodeの更新後に存在しないランタイムへスクリプトが固定されることを防ぎます。失敗時には xcresult、テストログ、アプリの診断記録、使用したコミット番号を保存してください。コンソール末尾の数十行だけをアップロードしてはいけません。

デバッグ中は、Xcodeのバックグラウンドタスク用デバッグ機能を使ってシステムコールバックを手動で発生させられます。ただし、この方法はアダプター層の接続を確認するためだけに使用し、リリースビルドの依存要件にしてはいけません。最終検証は2層に分けます。CIでは設定とタスクロジックを決定論的に検証し、管理下の実機ではシステムからタスクが渡された後の実際のライフサイクルを検証します。両者の結果を分けて記録することで、失敗時に設定の回帰、ビジネスロジックのエラー、システムのスケジュール時刻に対する誤った期待のいずれであるかを判断できます。

よくある質問

iOSシミュレータでバックグラウンドタスクの起動時刻を保証できますか?

保証できません。登録、処理ロジック、キャンセル、冪等性は検証できますが、システムが決める起動時刻は決定的なCI条件にできません。

BGTaskSchedulerのテストを安定させる設計は何ですか?

BGTaskSchedulerを薄いアダプタに限定し、実処理を注入可能な非同期コンポーネントへ分離します。テスト側から時刻、入力、失敗、キャンセルを制御します。

専用ビルドスロット

継続的なビルドタスクに専用クラウドMacを選択

M4、メモリ、ストレージ、ノード、請求期間を確認し、固定ビルド環境を既存のパイプラインに接続します。

構成を選択して注文