axiom-ui-testing について
AppleプラットフォームのUIテストを作成する開発者向けに、タイミング由来の不安定さを減らす方法を解説します。条件待ち、アクセシビリティ識別子、複数デバイスでのテストに加え、Xcode 26の記録と動画デバッグも扱います。
主な機能
- 01固定遅延を条件待ちに置き換え
- 02要素の表示・消失や状態変化を待機
- 03安定したアクセシビリティ識別子の利用
- 04デバイスや言語をまたいだテスト再生
- 05記録動画の確認と失敗のデバッグ
活用シーン
- 01ローカルで通りCIで失敗するテストの調査
- 02UIテスト内のsleep()の置き換え
- 03UI記録で失敗した手順の特定
- 04異なるデバイスサイズへのテスト対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-ui-testingインストール後、Claude Code で/axiom-ui-testingを入力して読み込まれたか確認します。
詳細
UI テスト
概要
任意のタイムアウトではなく、条件が満たされるまで待ちます。基本原則 不安定なテストは、処理にかかる時間を推測することで発生します。条件に基づく待機により、競合状態を解消できます。
WWDC 2025 で新登場:UI Automation の記録を使うと、操作を記録して、デバイスや言語をまたいで再生し、テスト実行の動画記録を確認できます。
プロンプトの例
このスキルが答えるよう設計されている、開発者からの実際の質問です。
1. 「Mac では UI テストが通るのに、CI では失敗します。信頼性を高めるにはどうすればよいですか?」
→ このスキルでは、デバイスや速度を問わず機能する条件に基づく待機パターンを紹介し、CI でのタイミングの違いを解消します
2. 「テストで sleep(2) や sleep(5) を使っていますが、それでも不安定です。任意のタイムアウトを実際の条件に置き換えるにはどうすればよいですか?」
→ このスキルでは、データ読み込み、ネットワークリクエスト、アニメーションに対応する waitForExistence、XCTestExpectation、ポーリングのパターンを紹介します
3. 「Xcode 26 の Recording UI Automation を使ってテストを記録しました。動画を確認して、失敗をデバッグするにはどうすればよいですか?」
→ このスキルでは、記録を分析し、テストが失敗する正確な手順を特定するための動画デバッグのワークフローを説明します
4. 「iPad ではテストが失敗するのに、iPhone では成功します。すべてのデバイスサイズで動作するテストを書くにはどうすればよいですか?」
→ このスキルでは、複数の要素を考慮したテスト戦略と、さまざまなデバイスで安定して動作する、デバイスに依存しない述語について説明します
5. 「不安定にならないテストを書きたいです。知っておくべき重要なパターンは何ですか?」
→ このスキルでは、条件に基づく待機のテンプレート、アクセシビリティを優先するパターン、信頼性の高いテストアーキテクチャを構築するための判断ツリーを紹介します
注意すべき兆候 — テストの信頼性に関する問題
次のいずれかが見られる場合は、タイミングの問題を疑ってください:
- テストがローカルでは成功し、CI では失敗する(タイミングの違い)
- テストが成功したり失敗したりする(競合状態)
- テストで
sleep()やThread.sleep()を使っている(任意の遅延) - 「UI element not found」でテストが失敗した後、再試行すると成功する
- テストの実行に時間がかかる(最悪のケースを待っている)
簡易判断ツリー
Test failing?
├─ Element not found?
│ └─ Use waitForExistence(timeout:) not sleep()
├─ Passes locally, fails CI?
│ └─ Replace sleep() with condition polling
├─ Animation causing issues?
│ └─ Wait for animation completion, don't disable
└─ Network request timing?
└─ Use XCTestExpectation or waitForExistence
基本パターン:条件に基づく待機
❌ 誤り(任意のタイムアウト):
func testButtonAppears() {
app.buttons["Login"].tap()
sleep(2) // ❌ Guessing it takes 2 seconds
XCTAssertTrue(app.buttons["Dashboard"].exists)
}
✅ 正しい(条件を待つ):
func testButtonAppears() {
app.buttons["Login"].tap()
let dashboard = app.buttons["Dashboard"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
}
よくある UI テストのパターン
パターン 1:要素が現れるまで待つ
// Wait for element to appear
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
return element.waitForExistence(timeout: timeout)
}
// Usage
XCTAssertTrue(waitForElement(app.buttons["Submit"]))
パターン 2:要素が消えるまで待つ
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Usage
XCTAssertTrue(waitForElementToDisappear(app.activityIndicators["Loading"]))
パターン 3:特定の状態になるまで待つ
func waitForButton(_ button: XCUIElement, toBeEnabled enabled: Bool, timeout: TimeInterval = 5) -> Bool {
let predicate = NSPredicate(format: "isEnabled == %@", NSNumber(value: enabled))
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
return result == .completed
}
// Usage
let submitButton = app.buttons["Submit"]
XCTAssertTrue(waitForButton(submitButton, toBeEnabled: true))
submitButton.tap()
パターン 4:アクセシビリティ識別子
アプリで設定する:
Button("Submit") {
// action
}
.accessibilityIdentifier("submitButton")
テストで使う:
func testSubmitButton() {
let submitButton = app.buttons["submitButton"] // Uses identifier, not label
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
submitButton.tap()
}
理由:アクセシビリティ識別子はローカライズによって変わらず、UI の更新後も安定して使えます。
パターン 5:ネットワークリクエストの遅延
func testDataLoads() {
app.buttons["Refresh"].tap()
// Wait for loading indicator to disappear
let loadingIndicator = app.activityIndicators["Loading"]
XCTAssertTrue(waitForElementToDisappear(loadingIndicator, timeout: 10))
// Now verify data loaded
XCTAssertTrue(app.cells.count > 0)
}
パターン 6:アニメーションへの対応
func testAnimatedTransition() {
app.buttons["Next"].tap()
// Wait for destination view to appear
let destinationView = app.otherElements["DestinationView"]
XCTAssertTrue(destinationView.waitForExistence(timeout: 2))
// Optional: Wait a bit more for animation to settle
// Only if absolutely necessary
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3))
}
テストのチェックリスト
テストを書く前
- すべての操作可能な要素にアクセシビリティ識別子を使う
- ハードコードしたラベルを避ける(代わりに識別子を使う)
- ネットワークの遅延とアニメーションを考慮する
- 適切なタイムアウトを選ぶ(UI は 2 秒、ネットワークは 10 秒)
テストを書くとき
-
sleep()ではなくwaitForExistence()を使う - 複雑な条件には述語を使う
- 成功パスと失敗パスの両方をテストする
- テストを独立させる(どのような順番でも実行できるようにする)
テストを書いた後
- テストをローカルで 10 回実行する(不安定さを検出する)
- サポート対象のうち最も遅いデバイスでテストを実行する
- CI 環境でテストを実行する
- テストの実行時間を確認する(テストあたり 30 秒を超える場合は最適化する)
Xcode UI テストのヒント
テスト用の起動引数
func testExample() {
let app = XCUIApplication()
app.launchArguments = ["UI-Testing"]
app.launch()
}
アプリのコード:
if ProcessInfo.processInfo.arguments.contains("UI-Testing") {
// Use mock data, skip onboarding, etc.
}
テストをより高速に実行する
override func setUpWithError() throws {
continueAfterFailure = false // Stop on first failure
}
失敗するテストのデバッグ
func testExample() {
// Take screenshot on failure
addUIInterruptionMonitor(withDescription: "Alert") { alert in
alert.buttons["OK"].tap()
return true
}
// Print element hierarchy
print(app.debugDescription)
}
よくある間違い
❌ 何でも sleep() を使う
sleep(5) // ❌ Wastes time if operation completes in 1s
❌ アニメーションに対応しない
app.buttons["Next"].tap()
XCTAssertTrue(app.buttons["Back"].exists) // ❌ May fail during animation
❌ テキストラベルをハードコードする
app.buttons["Submit"].tap() // ❌ Breaks with localization
❌ テスト同士が依存している
// ❌ Test 2 assumes Test 1 ran first
func test1_Login() { /* ... */ }
func test2_ViewDashboard() { /* assumes logged in */ }
❌ タイムアウト戦略がない
element.waitForExistence(timeout: 100) // ❌ Too long
element.waitForExistence(timeout: 0.1) // ❌ Too short
適切なタイムアウトを使う:
- UI アニメーション:2~3 秒
- ネットワークリクエスト:10 秒
- 複雑な処理:最大 30 秒
実際の効果
変更前(sleep() を使用):
- テストスイート:15 分(最悪のケースを想定して待機)
- 不安定なテスト:失敗率 20%
- CI の失敗:50% は再試行が必要
変更後(条件に基づく待機):
- テストスイート:5 分(必要な場合のみ待機)
- 不安定なテスト:失敗率 <2%
- CI の失敗:<5% は再試行が必要
重要なポイント 実際の条件が満たされるまで待つことで、時間を推測して待つ場合よりも、テストの完了が速くなり、信頼性も高まります。
UI オートメーションの記録
概要
Xcode 26 の新機能:ビデオ録画を使って UI オートメーションテストを記録、再生、レビューできます。
3 つのフェーズ:
- 記録 — 操作(タップ、スワイプ、ハードウェアボタンの押下)を Swift コードとしてキャプチャします
- 再生 — 複数のデバイス、言語、地域、画面の向きで実行します
- レビュー — ビデオ録画を確認し、失敗を分析して、UI 要素のオーバーレイを表示します
対応プラットフォーム:iOS、iPadOS、macOS、watchOS、tvOS、axiom-visionOS(iPad 用に設計)
UI オートメーションの仕組み
主な原則:
- UI オートメーションは、ジェスチャやハードウェアイベントを使って、人が操作するのと同じようにアプリを操作します
- アプリから完全に独立して実行されます(アプリのモデルやデータに直接アクセスできません)
- 基盤技術としてアクセシビリティフレームワークを使用します
- OS に実行するジェスチャを伝え、完了を同期的に 1 つずつ待ちます
実行できる操作:
- アプリの起動
- ボタンやナビゲーションの操作
- システム状態の設定(ダークモード、axiom-localization など)
- シミュレートされた位置情報の設定
アクセシビリティが基盤
重要な理解:アクセシビリティは UI オートメーションに情報を直接提供します。
アクセシビリティから取得できる情報:
- 要素の種類(ボタン、テキスト、画像など)
- ラベル(表示されるテキスト)
- 値(チェックボックスなどの現在の状態)
- フレーム(要素の位置)
- 識別子(アクセシビリティ識別子。ローカライズされません)
ベストプラクティス:優れたアクセシビリティ体験は、優れた UI オートメーション体験につながります。
記録に向けたアプリの準備
ステップ 1:アクセシビリティ識別子を追加する
SwiftUI:
Button("Submit") {
// action
}
.accessibilityIdentifier("submitButton")
// Make identifiers specific to instance
List(landmarks) { landmark in
LandmarkRow(landmark)
.accessibilityIdentifier("landmark-\(landmark.id)")
}
UIKit:
let button = UIButton()
button.accessibilityIdentifier = "submitButton"
// Use index for table cells
cell.accessibilityIdentifier = "cell-\(indexPath.row)"
優れた識別子の条件:
- ✅ アプリ全体で一意である
- ✅ 要素の内容を表す
- ✅ 固定されている(内容の変更に応じて変わらない)
- ✅ ローカライズされない(言語を問わず同じ)
識別子が重要な理由:
- タイトルや説明は変更されることがありますが、識別子は安定しています
- ローカライズされた文字列でも機能します
- 動的な内容を持つ要素を一意に識別できます
プロのヒント:Xcode コーディングアシスタントを使って識別子を追加します:
Prompt: "Add accessibility identifiers to the relevant parts of this view"
ステップ 2:Accessibility Inspector でアクセシビリティを確認する
Accessibility Inspector を起動する:
- Xcode メニュー → Developer Tool を開く → Accessibility Inspector
- または、Spotlight から起動します
機能:
- 要素インスペクター — 任意のビューのアクセシビリティ値を一覧表示します
- プロパティの詳細 — プロパティ名をクリックするとドキュメントを表示します
- プラットフォーム対応 — すべての Apple プラットフォームで動作します
確認する項目:
- 要素にラベルが設定されている
- インタラクティブな要素に種類が設定されている(単なるテキストではなくボタンなど)
- 状態を持つ要素(チェックボックス、トグル)に値が設定されている
- 動的な内容やローカライズされた内容を持つ要素に識別子が設定されている
サンプルコードの参考資料:優れたアクセシビリティ体験の提供
ステップ 3:UI テストターゲットを追加する
- Xcode でプロジェクト設定を開きます
- ターゲットの一覧の下にある「+」をクリックします
- UI Testing Bundle を選択します
- Finish をクリックします
結果:テンプレートテストを含む新しい UI テストフォルダがプロジェクトに追加されます。
操作の記録
録画を開始する(Xcode 26)
- UI テストのソースファイルを開きます
- ポップオーバーが表示され、録画の開始方法が説明されます(初回のみ)
- エディターのガターにある 「Start Recording」 ボタンをクリックします
- Xcode がアプリをビルドし、シミュレーターまたはデバイスで起動します
録画中:
- アプリを通常どおり操作します(タップ、スワイプ、テキスト入力など)
- 操作を表すコードがソースエディターにリアルタイムで表示されます
- 入力中も録画が更新されます(テキストフィールドへの入力など)
録画を停止する:
- Xcode の 「Stop Run」 ボタンをクリックします
録画セッションの例
func testCreateAustralianCollection() {
let app = XCUIApplication()
app.launch()
// Tap "Collections" tab (recorded automatically)
app.tabBars.buttons["Collections"].tap()
// Tap "+" to add new collection
app.navigationBars.buttons["Add"].tap()
// Tap "Edit" button
app.buttons["Edit"].tap()
// Type collection name
app.textFields.firstMatch.tap()
app.textFields.firstMatch.typeText("Max's Australian Adventure")
// Tap "Edit Landmarks"
app.buttons["Edit Landmarks"].tap()
// Add landmarks
app.tables.cells.containing(.staticText, identifier:"Great Barrier Reef").buttons["Add"].tap()
app.tables.cells.containing(.staticText, identifier:"Uluru").buttons["Add"].tap()
// Tap checkmark to save
app.navigationBars.buttons["Done"].tap()
}
録画したコードの確認
録画後、クエリを確認して調整します:
複数のオプション:各行のドロップダウンには、要素を指定する別の方法が表示されます。
選択時の推奨事項:
- ローカライズされた文字列(テキスト、ボタンラベルなど)の場合:利用できる場合はアクセシビリティ識別子を選択します
- 深くネストされたビューの場合:最短のクエリを選択します(アプリの変更に対して安定します)
- 動的なコンテンツ(タイムスタンプ、温度など)の場合:汎用クエリまたは識別子を使用します
例:
// Recorded options for text field:
app.textFields["Collection Name"] // ❌ Breaks if label localizes
app.textFields["collectionNameField"] // ✅ Uses identifier
app.textFields.element(boundBy: 0) // ✅ Position-based
app.textFields.firstMatch // ✅ Generic, shortest
必要に応じて、最短で最も安定したクエリを選択します。
検証の追加
録画後、期待どおりに動作することを確認するためにアサーションを追加します:
存在を待機する
// Validate collection created
let collection = app.buttons["Max's Australian Adventure"]
XCTAssertTrue(collection.waitForExistence(timeout: 5))
プロパティの変更を待機する
// Wait for button to become enabled
let submitButton = app.buttons["Submit"]
XCTAssertTrue(submitButton.wait(for: .enabled, toEqual: true, timeout: 5))
XCTAssert と組み合わせる
// Fail test if element doesn't appear
let landmark = app.staticTexts["Great Barrier Reef"]
XCTAssertTrue(landmark.waitForExistence(timeout: 5), "Landmark should appear in collection")
高度な自動化 API
デバイスの状態を設定する
override func setUpWithError() throws {
let app = XCUIApplication()
// Set device orientation
XCUIDevice.shared.orientation = .landscapeLeft
// Set appearance mode
app.launchArguments += ["-UIUserInterfaceStyle", "dark"]
// Simulate location
let location = XCUILocation(location: CLLocation(latitude: 37.7749, longitude: -122.4194))
app.launchArguments += ["-SimulatedLocation", location.description]
app.launch()
}
起動引数と環境
func testWithMockData() {
let app = XCUIApplication()
// Pass arguments to app
app.launchArguments = ["-UI-Testing", "-UseMockData"]
// Set environment variables
app.launchEnvironment = ["API_URL": "https://mock.api.com"]
app.launch()
}
アプリのコード内:
if ProcessInfo.processInfo.arguments.contains("-UI-Testing") {
// Use mock data, skip onboarding
}
カスタム URL スキーム
// Open app to specific URL
let app = XCUIApplication()
app.open(URL(string: "myapp://landmark/123")!)
// Open URL with system default app (global version)
XCUIApplication.open(URL(string: "https://example.com")!)
テストでのアクセシビリティ監査
func testAccessibility() throws {
let app = XCUIApplication()
app.launch()
// Perform accessibility audit
try app.performAccessibilityAudit()
}
参考資料:Perform accessibility audits for your app — WWDC23
複数の構成に対応したテストプラン
テストプランでは、次のことができます:
- 個々のテストを含める/除外する
- システム設定(言語、地域、外観)を設定する
- テストのプロパティ(タイムアウト、繰り返し、並列化)を構成する
- 特定のビルド設定を使用するスキームに関連付ける
テストプランの作成
- 新しいテストプランを作成するか、既存のものを使用します
- 最初の画面でテストを追加/削除します
- Configurations タブに切り替えます
複数の言語の追加
Configurations:
├─ English
├─ German (longer strings)
├─ Arabic (right-to-left)
└─ Hebrew (right-to-left)
各ロケールは、テストプラン内の個別の構成です。
設定:
- 特定のロケールに適用
- すべての構成で共有
ビデオとスクリーンショットのキャプチャ
Configurations タブでは、次の項目を設定できます:
- Capture screenshots:オン/オフ
- Capture video:オン/オフ
- Keep media:「Only failures」または「On, and keep all」
デフォルト:確認用のビデオ/スクリーンショットは、失敗した実行のみ保存されます。
「On, and keep all」の用途:
- ドキュメント
- チュートリアル
- マーケティング資料
参考資料:Author fast and reliable tests for Xcode Cloud — WWDC22
Xcode Cloud でのテストの再実行
Xcode Cloudは、次の機能を備えた組み込みサービスです:
- アプリのビルド
- テストの実行
- App Store へのアップロード
- チームのデバイスを使わず、すべてをクラウド上で実行
ワークフローの構成:
- ローカルと同じテストプランを使用
- 複数のデバイスと構成で実行
- ビデオ/結果を App Store Connect で確認可能
結果の確認:
- Xcode:Xcode Cloud セクション
- App Store Connect:Xcode Cloud セクション
- ビルド情報、ログ、失敗の説明、ビデオ録画を確認できます
チームのアクセス:チーム全体が実行履歴を確認し、結果/ビデオをダウンロードできます。
参考資料:Create practical workflows in Xcode Cloud — WWDC23
ビデオでテスト結果を確認する
テストレポートへのアクセス
- Xcode の Test ボタンをクリックします
- 失敗した実行をダブルクリックすると、ビデオと説明が表示されます
機能:
- Runs ドロップダウン — 異なる構成(言語、デバイス)のビデオ録画を切り替えます
- ビデオを保存 — セカンダリクリック → 保存
- 再生/一時停止 — UI 操作のオーバーレイ付きでビデオを再生します
- タイムライン上の点 — UI 操作がタイムライン上に点で表示されます
- 失敗箇所に移動 — タイムライン上の失敗を示すひし形をクリックします
失敗時の UI 要素オーバーレイ
失敗した時点:
- タイムライン上の失敗箇所をクリックします
- オーバーレイに画面上に表示されているすべての UI 要素が表示されます
- 任意の要素をクリックすると、その要素に対処するためのコード候補が表示されます
- すべて表示 — 別の例を確認します
ワークフロー:
- 実際に表示されていたもの(テストが想定していたものとの違い)を特定します
- 要素をクリックしてクエリコードを取得します
- セカンダリクリック → コードをコピーします
- ソースを表示 → テストに直接移動します
- 修正したコードを貼り付けます
例:
// Test expected:
let button = app.buttons["Max's Australian Adventure"]
// But overlay shows it's actually text, not button:
let text = app.staticTexts["Max's Australian Adventure"] // ✅ Correct
別の言語でテストを実行する
テストのひし形をクリック → 構成を選択します(例:アラビア語)→ 自動化が右から左へのレイアウトで実行される様子を確認します。
検証内容:同じ自動化がさまざまな言語やレイアウトで動作することを確認します。
参考資料:Xcode のテストレポートで障害をより迅速に修正する — WWDC23
UI 自動化の記録チェックリスト
記録前
- インタラクティブな要素にアクセシビリティ識別子を追加します
- Accessibility Inspector でアプリを確認します
- プロジェクトに UI Testing Bundle ターゲットを追加します
- 記録するワークフロー(ユーザージャーニー)を計画します
記録中
- アプリを自然に操作します
- 個々のタップではなく、ユーザージャーニー全体を記録します
- 操作中にコードが生成されることを確認します
- ワークフローが完了したら記録を停止します
記録後
- 記録されたコードの候補を確認します(各行のドロップダウン)
- 安定したクエリを選択します(識別子 > ラベル)
- 検証処理(waitForExistence、XCTAssert)を追加します
- セットアップコード(デバイスの状態、起動引数)を追加します
- テストを実行して成功することを確認します
テストプランの構成
- テストプランを作成または更新します
- 複数の言語構成を追加します
- 右から左に記述する言語(アラビア語、ヘブライ語)を含めます
- ビデオ/スクリーンショットのキャプチャ設定を構成します
- ネットワークテストに適切なタイムアウトを設定します
実行と確認
- 構成ごとにローカルでテストを実行します
- 失敗時のビデオ録画を確認します
- UI 要素オーバーレイを使って失敗をデバッグします
- チームで確認できるように Xcode Cloud で実行します
- 必要に応じてビデオをダウンロードして共有します
テストにおけるネットワーク状態の調整
概要
UI テストは高速なネットワークでは成功しても、3G/LTE では失敗する場合があります。Network Link Conditioner は実環境のネットワーク状態をシミュレートし、タイミングに依存するクラッシュを検出します。
重要なシナリオ:
- ❌ Wi-Fi 接続の iPad Pro(高速)→ 成功
- ❌ 3G 接続の iPad Pro(低速)→ クラッシュ
- ✅ デバイス固有の障害を検出するため、両方でテストします
Network Link Conditioner のセットアップ
Network Link Conditioner をインストールする:
- Apple's Additional Tools for Xcode からダウンロードします
- 「Network Link Conditioner」を検索します
- インストールします:
sudo open Network\ Link\ Conditioner.pkg
インストールを確認する:
# Check if installed
ls ~/Library/Application\ Support/Network\ Link\ Conditioner/
テストで有効にする:
override func setUpWithError() throws {
let app = XCUIApplication()
// Launch with network conditioning argument
app.launchArguments = ["-com.apple.CoreSimulator.CoreSimulatorService", "-networkShaping"]
app.launch()
}
一般的なネットワークプロファイル
3G プロファイル(障害の大半が発生する環境):
override func setUpWithError() throws {
let app = XCUIApplication()
// Simulate 3G (type in launch arguments)
app.launchEnvironment = [
"SIMULATOR_UDID": ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "",
"NETWORK_PROFILE": "3G"
]
app.launch()
}
手動でネットワーク状態を調整する(macOS のシステム設定):
- システム設定 → ネットワークを開きます
- 「Network Link Conditioner」(上記でインストール済み)をクリックします
- プロファイルを選択します:3G、LTE、WiFi
- 「開始」をクリックします
- テストを実行します(速度制限されたネットワークが使われます)
実例:ネットワーク速度を制限した写真のアップロード
❌ ネットワーク状態の調整なし:
func testPhotoUpload() {
app.buttons["Upload Photo"].tap()
// Passes locally (fast network)
XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 5))
}
// ✅ Passes locally, ❌ FAILS on 3G with timeout
✅ ネットワーク状態の調整あり:
func testPhotoUploadOn3G() {
let app = XCUIApplication()
// Network Link Conditioner running (3G profile)
app.launch()
app.buttons["Upload Photo"].tap()
// Increase timeout for 3G
XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 30))
// Verify no crash occurred
XCTAssertFalse(app.alerts.element.exists, "App should not crash on 3G")
}
主な違い:
- タイムアウトを延長します(5 秒ではなく 30 秒)
- クラッシュを確認します
- 想定される最も遅いネットワークで実行します
複合要因テスト:デバイスサイズ + ネットワーク速度
問題
デバイス A では成功しても、レイアウトの違いとネットワーク遅延が原因でデバイス B では失敗することがあります。複合要因テストでは、こうした組み合わせを検出できます。
よくある障害パターン:
- ✅ iPhone 14 Pro(コンパクト、高速ネットワーク)
- ❌ iPad Pro 12.9(大型、3G ネットワーク)→ クラッシュ
- ✅ iPhone 15(コンパクト、LTE)
- ❌ iPhone 12(旧世代 GPU、3G)→ タイムアウト
複数デバイス向けのテストプラン設定
Xcode でテストプランを作成する:
- File → New → Test Plan
- 含めるテストを選択します
- 「Configurations」タブをクリックします
- デバイスとネットワークの組み合わせごとに構成を追加します
構成マトリックスの例:
Configurations:
├─ iPhone 14 Pro + LTE
├─ iPhone 14 Pro + 3G
├─ iPad Pro 12.9 + LTE
├─ iPad Pro 12.9 + 3G (⚠️ Most failures here)
└─ iPhone 12 + 3G (⚠️ Older device)
テストプランの UI で設定する項目:
- デバイス:iPhone 14 Pro / iPad Pro 12.9
- OS バージョン:最新
- ロケール:英語
- ネットワークプロファイル:LTE / 3G
デバイス別のプログラムによるテスト
import XCTest
final class MultiFactorUITests: XCTestCase {
var deviceModel: String { UIDevice.current.model }
override func setUpWithError() throws {
let app = XCUIApplication()
app.launch()
// Adjust timeouts based on device
switch deviceModel {
case "iPad" where UIScreen.main.bounds.width > 1000:
// iPad Pro - larger layout, slower rendering
app.launchEnvironment["TEST_TIMEOUT"] = "30"
case "iPhone":
// iPhone - compact, standard timeout
app.launchEnvironment["TEST_TIMEOUT"] = "10"
default:
app.launchEnvironment["TEST_TIMEOUT"] = "15"
}
}
func testListLoadingAcrossDevices() {
let app = XCUIApplication()
let timeout = Double(app.launchEnvironment["TEST_TIMEOUT"] ?? "10") ?? 10
app.buttons["Refresh"].tap()
// Wait for list to load (timeout varies by device)
XCTAssertTrue(
app.tables.cells.count > 0,
"List should load on \(deviceModel)"
)
// Verify no crashes
XCTAssertFalse(app.alerts.element.exists)
}
}
実例:iPad Pro + 3G でのクラッシュ
シナリオ:iPhone 14 では動作するアプリが、3G 接続の iPad Pro ではクラッシュします。
クラッシュする理由:
- iPad Pro ではレイアウトが大きくなります(横向き)
- 3G ネットワークは低速です(レイテンシー 100ms 以上)
- 画像の読み込みが間に合わず、レイアウトエンジンがクラッシュします
- 1 台のデバイスだけでテストしていると、この組み合わせを見逃します
問題を検出するテスト:
func testLargeLayoutOn3G() {
let app = XCUIApplication()
// Running with Network Link Conditioner on 3G profile
app.launch()
// iPad Pro: Large grid of images
app.buttons["Browse"].tap()
// Wait longer for images on slow network
let firstImage = app.images["photoGrid-0"]
XCTAssertTrue(
firstImage.waitForExistence(timeout: 20),
"First image must load on slow network"
)
// Verify grid loaded without crash
let loadedCount = app.images.matching(identifier: NSPredicate(format: "identifier BEGINSWITH 'photoGrid'")).count
XCTAssertGreater(loadedCount, 5, "Multiple images should load on 3G")
// No alerts (no crashes)
XCTAssertFalse(app.alerts.element.exists, "App should not crash on large device + slow network")
}
CI で多要因テストを実行する
GitHub Actions または Xcode Cloud の場合:
- name: Run tests across devices
run: |
xcodebuild -scheme MyApp \
-testPlan MultiDeviceTestPlan \
test
テストプランで実行する組み合わせ:
- iPhone 14 Pro + LTE
- iPhone 14 Pro + 3G
- iPad Pro + LTE
- iPad Pro + 3G
結果:App Store への提出前に、デバイス固有のクラッシュを検出できます。
UI テストで明らかになったクラッシュのデバッグ
概要
UI テストでは、手動テストでは発生しないクラッシュが明らかになることがあります。重要なポイント 自動テストは実行速度が速く、アプリとの操作方法も異なるため、並行処理やタイミングに関するバグが見つかることがあります。
クラッシュが発生するケース:
- ❌ 手動テスト:再現できない(実行すると正常に動作する)
- ✅ UI テスト:毎回クラッシュする(自動で繰り返すことで競合状態が見つかる)
テストで明らかになったクラッシュを見分ける
テスト出力に見られる兆候:
Failing test: testPhotoUpload
Error: The app crashed while responding to a UI event
App died from an uncaught exception
Stack trace: [EXC_BAD_ACCESS in PhotoViewController]
動画に表示される内容:アプリが明らかにクラッシュする(黒い画面になり、すぐに終了する)。
系統的なデバッグ手順
手順 1:クラッシュの詳細を記録する
詳細なログ記録を有効にする:
override func setUpWithError() throws {
let app = XCUIApplication()
// Enable all logging
app.launchEnvironment = [
"OS_ACTIVITY_MODE": "debug",
"DYLD_PRINT_STATISTICS": "1"
]
// Enable test diagnostics
if #available(iOS 17, *) {
let options = XCUIApplicationLaunchOptions()
options.captureRawLogs = true
app.launch(options)
} else {
app.launch()
}
}
手順 2:ローカルで再現する
func testReproduceCrash() {
let app = XCUIApplication()
app.launch()
// Run exact sequence that causes crash
app.buttons["Browse"].tap()
app.buttons["Photo Album"].tap()
app.buttons["Select All"].tap()
app.buttons["Upload"].tap()
// Should crash here
let uploadButton = app.buttons["Upload"]
XCTAssertFalse(uploadButton.exists, "App crashed (expected)")
// Don't assert - just let it crash and read logs
}
Console のログを表示してテストを実行する:
- Xcode:View → Navigators → Show Console
- 例外メッセージを確認します
手順 3:クラッシュログを分析する
保存場所:
- Xcode Console(リアルタイムで確認できますが、情報は少なめです)
- ~/Library/Logs/DiagnosticMessages/crash_*.log(詳細な情報)
- デバイスの「設定」→「プライバシー」→「解析」→「解析データ」
確認する項目:
- クラッシュしたスレッド
- 例外の種類(EXC_BAD_ACCESS、EXC_CRASH など)
- クラッシュしたメソッドを示すスタックトレース
クラッシュログの例:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000
Thread 0 Crashed:
0 MyApp 0x0001a234 -[PhotoViewController reloadPhotos:] + 234
1 MyApp 0x0001a123 -[PhotoViewController viewDidLoad] + 180
ここから分かること:
PhotoViewController.reloadPhotos(_:)でクラッシュしています- null ポインターの参照解除が原因と考えられます
viewDidLoadから呼び出されています
手順 4:Swift の並行処理に関する問題との関連を確認する
UI テストでのクラッシュの多くは並行処理のバグです(UI テストに固有の問題ではありません)。関連するスキルを参照してください。
// Common pattern: Race condition in async image loading
class PhotoViewController: UIViewController {
var photos: [Photo] = []
override func viewDidLoad() {
super.viewDidLoad()
// ❌ WRONG: Accessing photos array from multiple threads
Task {
let newPhotos = await fetchPhotos()
self.photos = newPhotos // May crash if main thread access
reloadPhotos() // ❌ Crash here
}
}
}
// ✅ CORRECT: Ensure main thread
class PhotoViewController: UIViewController {
@MainActor
var photos: [Photo] = []
override func viewDidLoad() {
super.viewDidLoad()
Task {
let newPhotos = await fetchPhotos()
await MainActor.run { [weak self] in
self?.photos = newPhotos
self?.reloadPhotos() // ✅ Safe
}
}
}
}
クラッシュを詳細に分析する場合:@MainActor のパターンについては axiom-swift-concurrency スキルを、スレッドセーフに関する問題については axiom-memory-debugging スキルを参照してください。
手順 5:クラッシュを防止するテストを追加する
修正した後:
func testPhotosLoadWithoutCrash() {
let app = XCUIApplication()
app.launch()
// Rapid fire interactions that previously caused crash
app.buttons["Browse"].tap()
app.buttons["Photo Album"].tap()
// Load should complete without crash
let photoGrid = app.otherElements["photoGrid"]
XCTAssertTrue(photoGrid.waitForExistence(timeout: 10))
// No alerts (no crash dialogs)
XCTAssertFalse(app.alerts.element.exists)
}
手順 6:ストレステストで修正を検証する
func testPhotosLoadUnderStress() {
let app = XCUIApplication()
app.launch()
// Repeat the crash-causing action multiple times
for iteration in 0..<10 {
app.buttons["Browse"].tap()
// Wait for load
let grid = app.otherElements["photoGrid"]
XCTAssertTrue(grid.waitForExistence(timeout: 10), "Iteration \(iteration)")
// Go back
app.navigationBars.buttons["Back"].tap()
app.buttons["Refresh"].tap()
}
// Completed without crash
XCTAssertTrue(true, "Stress test passed")
}
予防チェックリスト
リリース前
- 最も遅いネットワーク(3G)で UI テストを実行する
- 最大サイズのデバイス(iPad Pro)で実行する
- サポート対象の最も古いデバイス(iPhone 12)で実行する
- テスト実行の動画を記録する(デバッグの時間を節約できます)
- ログでクラッシュがないか確認する
- ストレステストを実行する(操作を 10 回繰り返す)
- UI プロパティに @MainActor が指定されていることを確認する
- 非同期コードに競合状態がないか確認する
リソース
WWDC:2025-344、2024-10179、2023-10175、2023-10035
ドキュメント:/xctest、/xcuiautomation/recording-ui-automation-for-testing、/xctest/xctwaiter、/accessibility/delivering_an_exceptional_accessibility_experience、/accessibility/performing_accessibility_testing_for_your_app
注記:このスキルでは、信頼性を高めるパターンと Recording UI Automation に焦点を当てています。TDD のワークフローについては、superpowers:test-driven-development を参照してください。
履歴: 変更については git log を参照してください