クラウド Mac で iOS 検索デバウンスを仮想時間で検証する

クラウド Mac で iOS 検索デバウンスを仮想時間で検証する

クラウド Mac で iOS 検索デバウンスを仮想時間で検証する

検索欄には 300 ミリ秒のデバウンスを設定しているのに、テストは「入力してから半秒待ち、リクエスト回数を確認する」という実装になっていませんか。クラウド Mac でタスクを並列実行する場合、このようなテストは遅いだけでなく、スケジューリングの遅延にも左右されます。本当に検証すべきなのは、最後の入力から算出した期限、古い入力の置き換え、そして入力を消した後に検索語が渡されないことです。これらのルールをタイマーから切り離せば、実時間の経過を待たずにテストできます。

まず検索の時間に関する契約を明確にする

この記事では、末尾で発火するデバウンスを採用します。空でない入力を受け取るたびに期限を再計算し、期限に達したら最新の検索語だけを一度渡します。入力の前後にある空白は先に取り除き、空白だけの文字列も空文字列も、受け渡し待ちの検索語を取り消します。同じ検索語をもう一度入力した場合も、待機時間を最初から数え直します。これは明示的な製品仕様であり、すべての実装に共通する既定の動作ではありません。

時刻はシステムの日時ではなく、テスト開始時点からの経過時間を整数のミリ秒で表します。本番環境では単調増加するクロックから経過時間を取得し、時刻補正で値が飛ぶ可能性のある壁時計は使わないようにします。300 ミリ秒はデバウンスの設定例にすぎず、サービスの性能指標ではありません。

デバウンスが決めるのは、検索語をいつ渡すかだけです。すでに送信したネットワークリクエストを取り消すか、古いレスポンスで画面を更新してよいかは、それぞれ別の契約です。「タイマーをキャンセルした」ことだけで、これらを検証したことにはなりません。

時間のルールを独立した状態機械にする

クラウド Mac のコマンドラインで、小さな検証用パッケージを作成します。現在選択されている開発ツールチェーンには Swift が含まれている必要があります。ここではシミュレータも GUI セッションも使いません。

mkdir SearchTiming
cd SearchTiming
swift package init --type library --name SearchTiming

Sources/SearchTiming/SearchTiming.swift を次の内容に置き換えます。このコードはスレッドもタイマーも作成しません。呼び出し側が時刻を非減少順で渡し、状態へのアクセスを直列化する必要があります。

import Foundation

struct SearchDebounce {
    let delay: Int64
    private(set) var deadline: Int64?
    private var pending: String?

    init(delay: Int64 = 300) {
        precondition(delay > 0)
        self.delay = delay
    }

    mutating func input(_ text: String, at now: Int64) {
        let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !query.isEmpty else {
            pending = nil
            deadline = nil
            return
        }
        pending = query
        deadline = now + delay
    }

    mutating func takeDue(at now: Int64) -> String? {
        guard let limit = deadline, now >= limit else {
            return nil
        }
        let query = pending
        pending = nil
        deadline = nil
        return query
    }
}

takeDue は期限の確認と、受け渡し待ちの検索語の取り出し・消去を同時に行います。そのため、コールバックが繰り返されても重複して渡されず、期限より早いコールバックで検索語が消えることもありません。この例では、時刻の値が通常の動作範囲内に収まることを前提としています。クロックのアダプターが外部から数値を受け取る場合は、負の値、時刻の逆行、加算時のオーバーフローもチェックする必要があります。

sleep を呼ばずに仮想時間を進める

Tests/SearchTimingTests/SearchTimingTests.swift を次のテストに置き換えます。各テストケースで状態を新しく作り、クロックや保留中の入力を共有しないようにします。

import XCTest
@testable import SearchTiming

final class SearchTimingTests: XCTestCase {
    func testDeadlineAndSingleDelivery() {
        var state = SearchDebounce()
        state.input(" swift ", at: 0)
        XCTAssertNil(state.takeDue(at: 299))
        XCTAssertEqual(state.takeDue(at: 300), "swift")
        XCTAssertNil(state.takeDue(at: 301))
    }

    func testLatestInputMovesDeadline() {
        var state = SearchDebounce()
        state.input("s", at: 0)
        state.input("sw", at: 100)
        state.input("swift", at: 220)
        XCTAssertEqual(state.deadline, 520)
        XCTAssertNil(state.takeDue(at: 300))
        XCTAssertNil(state.takeDue(at: 519))
        XCTAssertEqual(state.takeDue(at: 520), "swift")
    }

    func testBlankCancelsPendingInput() {
        var state = SearchDebounce()
        state.input("swift", at: 0)
        state.input(" \n ", at: 100)
        XCTAssertNil(state.deadline)
        XCTAssertNil(state.takeDue(at: 1000))
    }

    func testSameInputRestartsDelay() {
        var state = SearchDebounce()
        state.input("swift", at: 0)
        state.input("swift", at: 200)
        XCTAssertNil(state.takeDue(at: 300))
        XCTAssertEqual(state.takeDue(at: 500), "swift")
    }
}

swift test を実行します。アサーションが失敗した場合、コマンドは必ず非ゼロの終了コードを返す必要があります。パイプラインも、出力を保存するだけで後続処理を続けてはいけません。このテストでは、期限の直前、期限ちょうど、取り出しの繰り返しを検証しています。期限を十分に過ぎてから取り出すケースも追加すれば、スケジューリングが遅れても検索語が失われたままにならないことを確認できます。

実際のタスクに接続するときは 2 種類のキャンセルミスを防ぐ

実際の組み込みでは、ContinuousClock などを使って期限まで待機できます。状態機械がルールを担当し、アダプターが待機からの再開を担当します。入力のたびに、まず状態を更新し、次に古い待機タスクをキャンセルして、新しい期限に向けた待機タスクを作成します。入力を消した場合は、新しいタスクを作成しません。

1 つ目のミスは、キャンセルによるエラーを握りつぶすことです。try? による待機の直後に受け渡し処理を呼ぶと、キャンセルされた場合も処理が続いてしまいます。キャンセルを捕捉したらそこで処理を終了し、キャンセルを「期限に達した」と扱わないようにします。

2 つ目のミスは、古いタスクが遅れて再開することです。入力ごとに世代番号を更新し、待機タスクには作成時の世代番号を保持させます。待機から再開したら、同じ直列実行のエグゼキューター上で現在の世代番号と比較し、一致する場合にだけ状態から検索語を取り出せるようにします。キャンセルと待機からの再開は同時に起こり得るため、タスクがキャンセル済みかどうかを確認するだけでは不十分です。

画面からの入力、世代番号の更新、結果の受け渡しは、すべてメインエグゼキューター上にまとめられます。バックグラウンドで状態を読み取ってから、非同期でメインエグゼキューターに戻って状態を変更する構成は避けてください。確認と取り出しの間に隙間が生まれてしまいます。仮想時間のテストでミリ秒を使うのは、アサーションを書きやすくするためです。実際の待機にはクロックの期限型と期間型を使い、換算誤差の蓄積を避けます。

少数の接続テストで仕上げる

純粋なロジックのテストが通ったら、すべてのケースを長時間待機するテストに戻すのではなく、実際のアダプターを確認します。テストのタイムアウトには十分な余裕を持たせつつ、期限前には検索語を渡さないことと、最終的に一度だけ渡すことを引き続き検証します。タイムアウトは処理が止まったことを検出するためのものであり、厳密な時間のアサーションとして使うものではありません。

検証項目には少なくとも、短時間に連続入力しても最後の検索語だけが渡されること、入力を消すと受け渡し待ちの検索語が直ちに取り消されること、キャンセルによるエラーから受け渡し処理に進まないこと、古い世代のコールバックが破棄されること、画面の解放後に更新しないこと、何度も連続実行して結果が変わらないことを含めます。同じ検索語の重複排除が業務要件にある場合は、別の重複排除レイヤーを追加し、入力を消してから同じ語を再入力したときに再検索を許可するかどうかも明確にします。

記録として残すのは、コミットのバージョン、ツールチェーンのバージョン、テストの終了コードだけで十分です。ユーザーが実際に入力した検索内容を記録する必要はありません。最終的に得られるのは、「半秒待てばたぶん通る」スクリプトではなく、どの時間的ルールが破られたのかを特定できるアサーションの集合です。

よくある質問

仮想時間のテストだけで実際のタイマーを検証できますか?

できません。状態機械の規則は検証できますが、単調時計の使用、タイマーのキャンセル、指定した実行器への結果の引き渡しは統合テストで確認します。

検索欄を空にすると送信済みのリクエストもキャンセルされますか?

されません。この状態機械が取り消すのは未送信の検索語だけです。進行中の通信は別途キャンセルし、リクエストの世代を確認して古い応答の反映を防ぎます。

開発用に、専用のMacを1台確保

Mac miniの物理ノードを、仮想マシンではなく専有で利用できます。日単位・週単位・月単位・四半期単位から選び、用途と構成を決めたら、コントロールパネルに表示される最新の空き状況をご確認ください。

機種と利用期間を選ぶ 料金一覧を見る