クラウド Mac で iOS Combine の購読キャンセルと寿命を検証する

クラウド Mac で iOS Combine の購読キャンセルと寿命を検証する

クラウド Mac で iOS Combine の購読キャンセルと寿命を検証する

検索画面を離れた後も結果が状態に書き戻される、あるいは画面を開くたびにリクエストが増えていく。こうした問題の原因は、ネットワークの遅さではなく、Combine の購読が必要以上に長く生き続けていることにある場合がよくあります。クラウド Mac で回帰テストを実行する際は、まず実際のネットワークを、イベントを制御できるソースに置き換えましょう。いつ購読し、いつキャンセルし、どのオブジェクトが解放されるべきかを明確にしてから、実際のサービスに接続します。これなら遠隔サーバーの応答を待たずに、失敗を安定して再現できます。

まずキャンセルの契約を切り分ける

1 回のキャンセルには、少なくとも 3 つの異なる結果が関わります。下流が値を受け取らなくなること、上流にキャンセルの通知が届くこと、そして内部のタスクが停止することです。最初の 2 つは Publisher のパイプラインをテストすれば確認できます。一方、タスクの停止は具体的な実装を調べる必要があり、購読の状態から判断することはできません。

画面の受け入れ条件は、次の 4 つにします。キャンセル後に値が変わらないこと、キャンセルを繰り返してもクリーンアップは 1 回だけ行われること、画面モデルが解放されること、古いリクエストが新しいリクエストの結果を上書きしないことです。正常完了は別のテストケースにします。完了通知とキャンセル通知は同じものではありません。

cancel はロールバックではありません。キャンセル前に書き込まれた状態、ディスクに保存されたファイル、サーバー側ですでに実行が確定した操作は、自動では取り消されません。

まず Xcode のツールチェーンを固定し、既存の XCTest ユニットテストターゲットにテストを追加します。以下のコードが依存するのは Combine と XCTest だけです。シミュレーターの画面を起動する必要も、ネットワークアクセスの権限もありません。

同期イベントソースでキャンセルの伝播を確認する

次のコードをテストターゲットに追加します。ここで使うカウンターはテスト専用です。本番コードでは、グローバルなカウンターをキャンセル状態の代わりに使わないでください。テストでは、まず値を 1 つ送信し、2 回キャンセルしてから、別の値を送信します。3 つのアサーションで、値の受信、キャンセルの伝播、キャンセル後の値が届かないことをそれぞれ確認します。

import Combine
import XCTest

final class SubscriptionLifetimeTests: XCTestCase {
    func testCancelStopsDeliveryAndPropagatesOnce() {
        let source = PassthroughSubject<Int, Never>()
        var received: [Int] = []
        var cancellations = 0

        let token = source
            .handleEvents(receiveCancel: {
                cancellations += 1
            })
            .sink { value in
                received.append(value)
            }

        source.send(1)
        XCTAssertEqual(received, [1])

        token.cancel()
        token.cancel()
        source.send(2)

        XCTAssertEqual(cancellations, 1)
        XCTAssertEqual(received, [1])
    }
}

このパイプラインにはスケジューラーの切り替えがないため、PassthroughSubject は同期的に値を送信します。アサーションは各操作の直後に置けます。「システムに処理する時間を与える」ために sleep を追加しないでください。イベントの順序が曖昧な問題を隠すだけです。

このテストが検証しているのは、あくまでサンプルのパイプラインです。自分のサービスに組み込む際は、依存性注入によってイベントソースを業務ロジックのモデルに渡し、モデルが公開する状態に対して同じアサーションを行う必要があります。製品と無関係なパイプラインだけをテストして終わらせないでください。

cancel だけでなく参照の保持関係も確認する

典型的な循環参照は、モデルが AnyCancellable を保持し、sink のクロージャーがモデルを強参照する形です。この状態で、モデルの破棄によって購読が解放されることを期待すると、互いに解放を待つことになります。以下では弱参照キャプチャで循環を断ち、弱参照を使ってオブジェクトが実際に解放されたことを確認します。

private final class ScreenModel {
    var value = 0
    private var token: AnyCancellable?

    init(source: PassthroughSubject<Int, Never>) {
        token = source.sink { [weak self] value in
            self?.value = value
        }
    }
}

extension SubscriptionLifetimeTests {
    func testModelReleaseCancelsSubscription() {
        let source = PassthroughSubject<Int, Never>()
        var cancellations = 0
        let publisher = source.handleEvents(receiveCancel: {
            cancellations += 1
        })
        var token: AnyCancellable?
        weak var weakModel: ScreenModel?

        do {
            let model = ScreenModel(source: source)
            weakModel = model
            token = publisher.sink { [weak model] value in
                model?.value = value
            }
        }

        XCTAssertNil(weakModel)
        token?.cancel()
        XCTAssertEqual(cancellations, 1)
    }
}

2 つ目のテストケースでは、モデルの解放と外部の購読のキャンセルを別々に観測しており、1 つの動作として扱ってはいません。モデル内部の購読は、モデルの解放に伴ってキャンセルされます。一方、外部の token はテストが保持しているため、明示的に終了させる必要があります。実際のプロジェクトに移す際は、内部の購読にもキャンセルを観測するポイントを追加し、2 つの購読をそれぞれアサートしてください。

弱参照キャプチャも万能ではありません。画面を離れた後も継続すべきタスクは、より長寿命のサービスに保持させ、画面は進捗だけを購読する構成にします。これにより、画面の解放によって本来続けるべきタスクまで止まることを防げます。

非同期の書き戻しにリクエスト識別チェックを加える

検索 A をキャンセルして検索 B を開始しても、別の実行キューにすでに投入された処理が、状態への書き戻しを試みることがあります。「キャンセルを呼び出した」という事実を、状態の安全を保証する境界として扱わないでください。

リクエストごとに単調増加する識別番号を割り当て、書き戻す際に番号を照合します。また、識別番号の変更と状態の書き込みは、同じ actor または直列実行コンテキスト内に限定します。テストの順序は、A を開始、B を開始、B の応答を返す、最後に A の応答を返す、で固定します。最終状態は B の結果のままでなければなりません。

画面を離れるケースも追加します。リクエストを開始し、画面を離れた後に遅延した応答を発生させ、表示に使われる状態が更新されないことを確認します。バックグラウンドでの継続実行を業務要件として認めている場合は、サービスの状態は更新されることと、画面へのコールバックは止まることを、別々にアサートします。

receive(on:) が関わる場合は、まず同期的なコア部分を検証し、その後でスケジューリングの境界を検証します。制御可能なスケジューラーでキューの実行を進めてください。そのような基盤がない場合は、明示的な実行完了シグナルを待ちます。単に一定時間待っただけで、遅れて届くイベントが存在しないと判断してはいけません。

原因を特定できる回帰テストにする

失敗したテストケースごとに、リクエストの識別番号、イベントの順序、キャンセル回数、最終状態を記録します。リクエストトークンや実際のユーザーデータは記録しません。一度に変える条件は 1 つだけにします。まず単一の購読を検証し、その後で短時間での再購読、正常完了、失敗による終了、画面の解放をカバーします。

実際のネットワークタスクに接続したら、キャンセルのコールバックが内部タスクの cancel を呼び出しているか、タスクが最終的にどの状態になったかを個別に確認します。サーバーから結果が返ってこなかったという理由だけで、成功と判断しないでください。キャンセルと完了が競合する場合は、契約に沿った終了順序を許容する一方で、終了後の業務状態への書き戻しは許容してはいけません。

最後に、意図的にコードを壊して検証します。弱参照キャプチャを外せば循環参照が検出され、リクエスト識別番号のチェックを削除すれば応答順序が逆転するテストが失敗するはずです。意図的に混入させた不具合を検出できて初めて、これらのテストが実際の境界を守っていると言えます。単にパイプラインを成功表示にするだけでは不十分です。

よくある質問

cancel を呼べば通信も必ず停止しますか?

必ずしも停止しません。基になるタスクの停止は Publisher の実装次第です。キャンセル経路でタスクを停止しているか、タスク側の状態も確認します。

固定待機を使わずに検証するには?

同期イベントは PassthroughSubject で直接送ります。非同期配信には制御可能なスケジューラか明示的な完了通知を使い、sleep で無配信を推測しないようにします。

次のビルドに向けてクラウド Mac を予約

機種、ロケーション、利用期間を選び、専有物理サーバーでお使いの開発ツールを実行できます。利用可能状況はコントロールパネルに表示される最新情報をご確認ください。

次のビルドにつなげる

機種と利用期間を確認して、プロジェクトの準備を始めましょう。