ユーザーがアプリで保存した書類を、システムの検索結果から開けない。開発用のMacでは再現しないのに、クラウドMacの自動テストではときどき失敗する。索引への登録コールバックは成功していても、検索結果にはまだ項目が現れない。この種の問題を切り分けるには、「登録の成功」「検索での検出」「削除後に検索結果から消えること」を一つのアサーションにまとめてはいけない。索引のライフサイクルにおいて、それぞれ検証すべき三つの段階だ。
まずテストの範囲を明確にする
Core SpotlightのCSSearchableIndexは、アプリが提供する検索可能な項目の登録と削除を担う。登録コールバックが示すのはその操作の結果であり、検索クエリから新しいデータをすでに読み取れるという意味ではない。そのため、テストでは少なくとも項目の識別子、検索語、登録結果、最初にヒットした時刻、削除後のクエリ結果を記録する。
テストは、アプリ環境を読み込まないロジックテストターゲットだけで実行するのではなく、アプリをホストとするXCTestターゲットで実行する。テスト項目には専用のdomainIdentifierを使い、実行のたびに新しいuniqueIdentifierとタイトルを生成して、前回の項目による偽陽性を防ぐ。まずは一つの項目で一連の流れを確認し、その後に一括登録へ広げる。固定の業務文書をテストデータにしてはいけない。ユーザーがその文書を変更・削除すると、テスト結果も変わってしまう。
登録コールバックの成功で通過できるのは「登録段階」だけだ。検索段階ではクエリを実行し、削除段階では再度クエリを実行する。三つの段階の結果をそれぞれ残す。
XCTestで登録と削除を検証する
次のテストは、非同期操作をXCTestで扱う方法を示している。まず識別子を指定して残存している可能性のある項目を削除し、固有のタイトルを持つ項目を一つ登録してから、その識別子の項目を削除する。この例で確認するのは操作のコールバックであり、これだけでは検索結果に表示されることは証明できない。検索のアサーションは次の節に従って追加する。
import CoreSpotlight
import XCTest
final class SpotlightLifecycleTests: XCTestCase {
func testWriteAndRemove() {
let index = CSSearchableIndex.default()
let id = "smoke-\(UUID().uuidString)"
let title = "SpotlightSmoke-\(id)"
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = title
let item = CSSearchableItem(
uniqueIdentifier: id,
domainIdentifier: "spotlight-smoke-tests",
attributeSet: attributes
)
func complete(
_ name: String,
_ operation: (@escaping (Error?) -> Void) -> Void
) {
let done = expectation(description: name)
operation { error in
XCTAssertNil(error, "\(name): \(String(describing: error))")
done.fulfill()
}
wait(for: [done], timeout: 10)
}
complete("remove previous item") { finish in
index.deleteSearchableItems(
withIdentifiers: [id],
completionHandler: finish
)
}
complete("index item") { finish in
index.indexSearchableItems(
[item],
completionHandler: finish
)
}
complete("remove indexed item") { finish in
index.deleteSearchableItems(
withIdentifiers: [id],
completionHandler: finish
)
}
}
}
実際のプロジェクトでは、削除をテストの後片付け処理に組み込む。登録のアサーションが途中で失敗しても、登録済みの項目の削除を試みる必要がある。後片付けの失敗を黙って見過ごさず、項目の識別子とともに報告する。そうしないと、残ったデータが後続の実行に影響しかねない。
検索の同期に期限を設ける
テストターゲット内でCSSearchQueryを使い、今回生成した固有のタイトルまたは検索語で問い合わせ、返された項目のuniqueIdentifierを照合する。「結果が少なくとも一件ある」だけでは不十分だ。同名の業務データによって、誤った項目でもテストが通ってしまう可能性がある。登録コールバックが返ったらクエリを開始する。ヒットしなければ短時間待ってクエリを作り直し、設定した期限までにヒットしなかった場合にのみ失敗とする。削除コールバックの後も同様に、対象の識別子が現れなくなるまでポーリングする。
| 段階 | 合格条件 | 失敗時に優先して記録する情報 |
|---|---|---|
| 登録 | コールバックにエラーがない | 識別子、エラー、シミュレータの状態 |
| 検索 | 期限までに今回の識別子がヒットする | 検索語、クエリのエラー、所要時間 |
| 削除 | 期限までに対象の識別子が返されなくなる | 削除時のエラー、最後のクエリ結果 |
期限はテストに割り当てる時間であり、サービスの性能保証ではない。まず構成を固定したシミュレータで複数回実行して傾向を確認し、それから妥当な上限を設定する。一度クエリが遅かっただけで待機時間を際限なく延ばしてはいけない。クエリのコールバックとポーリングのタイマーにもそれぞれ終了条件を設け、一回の処理停止がパイプライン全体を占有しないようにする。
クラウドMacで実行環境を固定する
このテストで使うプロジェクト、Scheme、シミュレータの実行先を固定し、Schemeが共有されていることを確認する。初回実行前にxcodebuild -showdestinationsで現在利用できる実行先を調べ、選んだデバイスの識別子を実行設定に指定する。自動選択される「最初の」シミュレータに依存してはいけない。例えば次のように実行する。
xcodebuild test \
-project Example.xcodeproj \
-scheme Example \
-destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \
-only-testing:ExampleTests/SpotlightLifecycleTests \
-resultBundlePath "$PWD/spotlight-result.xcresult"
SIMULATOR_UDIDとプロジェクト名は実際の値に置き換える。結果バンドルのパスは、毎回の実行前に存在しない状態にしておく。索引のテストでシミュレータ全体を初期化する必要は通常ない。テスト用ドメインと今回の識別子を優先してクリーンアップすれば、実行時間を短縮でき、ほかのテストに必要な状態も消さずに済む。どうしてもデバイスのリセットが必要なら、その手順を独立したジョブに分け、並列テストとデバイスセットを共有しないようにする。
断続的な失敗を切り分け、検証を完了する
失敗したら、すぐに再実行するのではなく、まずどの段階で失敗したかを確認する。登録コールバックがエラーを返した場合は、テストホスト、項目の属性、エラーオブジェクトを調べる。登録は成功したのに検索がタイムアウトした場合は、検索語が検索可能な属性に実際に書き込まれているか、クエリで今回の識別子を確認しているか、別のジョブが同じテスト用ドメインをクリーンアップしていないかを確かめる。削除後も検索で見つかる場合は、最後にヒットした項目の識別子を記録し、削除呼び出しが同じuniqueIdentifierを対象にしていたか確認する。
最後に並列実行の構成を確認する。同じアプリの索引ライフサイクルテストがドメインのクリーンアップを共有するなら、同じシミュレータ上で並列実行してはいけない。実行ごとの識別子、三段階の結果、テスト結果バンドルを保存しておけば、操作のエラー、検索への反映の遅れ、テスト自身による状態の汚染を区別できる。登録・検索・削除のすべてを通過して初めて、ユーザーの検索経路を検証できたと言える。
よくある質問
登録のコールバックが成功しても、すぐに検索できないのはなぜですか?
コールバックは登録処理の完了を示しますが、検索側への反映完了までは保証しません。固有の語句で期限付きの再試行を行います。
前回のテスト結果との混同を防ぐには?
実行ごとに識別子と検索語を生成し、開始前と終了時に対象項目を削除します。失敗時は識別子と処理段階を記録します。
固定構成のクラウドMacをワークフローに導入
専有物理ノードを日単位・週単位・月単位・四半期単位でレンタル。固定のハードウェアリソースでビルド、自動化、MLX推論を実行できます。