mcpField
A
axiom-xctest-automation

axiom-xctest-automation

github.com/charleswiltgenテスト・品質1.2k193 回インストール今日更新
リポジトリ

axiom-xctest-automation について

axiom-xctest-automationは、保守しやすいXCUITest UIテストを書く開発者向けのガイドです。安定した要素識別、条件に基づく待機、テストの分離を重視し、実行やデバッグの例も示します。

主な機能

  1. 01accessibilityIdentifierによる安定したクエリ
  2. 02sleepに代わる条件待機の推奨
  3. 03セットアップと独立したテストの構成
  4. 04一般的なUI操作とテストプランの説明
  5. 05CI実行と失敗時のデバッグ

活用シーン

  1. 01堅牢なログインUIテストの作成
  2. 02非同期UIの変化待ち
  3. 03複数設定でのテスト実行
  4. 04スクリーンショットや階層出力による失敗調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-xctest-automation

インストール後、Claude Code で/axiom-xctest-automationを入力して読み込まれたか確認します。

詳細

XCUITest 自動化のパターン

XCUITest で信頼性が高く、保守しやすい UI テストを書くための包括的なガイドです。

基本原則

信頼性の高い UI テストには、次の 3 つが必要です:

  1. 安定した要素の識別(accessibilityIdentifier)
  2. 条件に基づく待機(ハードコードされた sleep は使用しない)
  3. 適切なテストの分離(状態を共有しない)

要素の識別

アクセシビリティ識別子のパターン

テストで重要な要素には、必ず accessibilityIdentifier を使用してください。

// SwiftUI
Button("Login") { ... }
    .accessibilityIdentifier("loginButton")

TextField("Email", text: $email)
    .accessibilityIdentifier("emailTextField")

// UIKit
loginButton.accessibilityIdentifier = "loginButton"
emailTextField.accessibilityIdentifier = "emailTextField"
クエリ選択のガイドライン

WWDC 2025-344「Recording UI Automation」より:

  1. ローカライズされた文字列は変わります → 代わりに accessibilityIdentifier を使用します
  2. 深くネストされたビュー → 可能な限り短いクエリを使用します
  3. 動的コンテンツ → 汎用クエリまたは識別子を使用します
// BAD - Fragile queries
app.buttons["Login"]  // Breaks with localization
app.tables.cells.element(boundBy: 0).buttons.firstMatch  // Too specific

// GOOD - Stable queries
app.buttons["loginButton"]  // Uses identifier
app.tables.cells.containing(.staticText, identifier: "itemTitle").firstMatch

待機方法

sleep() は使用しない
// BAD - Hardcoded wait
sleep(5)
XCTAssertTrue(app.buttons["submit"].exists)

// GOOD - Condition-based wait
let submitButton = app.buttons["submit"]
XCTAssertTrue(submitButton.waitForExistence(timeout: 5))
待機パターン
// Wait for element to appear
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    element.waitForExistence(timeout: timeout)
}

// Wait for element to disappear
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 10) -> 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
}

// Wait for element to be hittable (visible AND enabled)
func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval = 10) -> Bool {
    let predicate = NSPredicate(format: "isHittable == true")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
    let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
    return result == .completed
}

// Wait for text to appear anywhere
func waitForText(_ text: String, timeout: TimeInterval = 10) -> Bool {
    app.staticTexts[text].waitForExistence(timeout: timeout)
}
非同期処理
// Wait for network response
func waitForNetworkResponse() {
    let loadingIndicator = app.activityIndicators["loadingIndicator"]

    // Wait for loading to start
    _ = loadingIndicator.waitForExistence(timeout: 5)

    // Wait for loading to finish
    _ = waitForElementToDisappear(loadingIndicator, timeout: 30)
}

テスト構造

セットアップとティアダウン
class LoginTests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()

        // Reset app state for clean test
        app.launchArguments = ["--uitesting", "--reset-state"]
        app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
        app.launch()
    }

    override func tearDownWithError() throws {
        // Capture screenshot on failure
        if testRun?.failureCount ?? 0 > 0 {
            let screenshot = XCUIScreen.main.screenshot()
            let attachment = XCTAttachment(screenshot: screenshot)
            attachment.name = "Failure Screenshot"
            attachment.lifetime = .keepAlways
            add(attachment)
        }
        app.terminate()
    }
}
テストメソッドのパターン
func testLoginWithValidCredentials() throws {
    // ARRANGE - Navigate to login screen
    let loginButton = app.buttons["showLoginButton"]
    XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
    loginButton.tap()

    // ACT - Enter credentials and submit
    let emailField = app.textFields["emailTextField"]
    XCTAssertTrue(emailField.waitForExistence(timeout: 5))
    emailField.tap()
    emailField.typeText("[email protected]")

    let passwordField = app.secureTextFields["passwordTextField"]
    passwordField.tap()
    passwordField.typeText("password123")

    app.buttons["loginSubmitButton"].tap()

    // ASSERT - Verify successful login
    let welcomeLabel = app.staticTexts["welcomeLabel"]
    XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 10))
    XCTAssertTrue(welcomeLabel.label.contains("Welcome"))
}

一般的な操作

テキスト入力
// Clear and type
let textField = app.textFields["emailTextField"]
textField.tap()
textField.clearText()  // Custom extension
textField.typeText("[email protected]")

// Extension to clear text
extension XCUIElement {
    func clearText() {
        guard let stringValue = value as? String else { return }
        tap()
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
        typeText(deleteString)
    }
}
スクロール
// Scroll until element is visible
func scrollToElement(_ element: XCUIElement, in scrollView: XCUIElement) {
    while !element.isHittable {
        scrollView.swipeUp()
    }
}

// Scroll to specific element
let targetCell = app.tables.cells["targetItem"]
let table = app.tables.firstMatch
scrollToElement(targetCell, in: table)
targetCell.tap()
アラートとシート
// Handle system alert
addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in
    if alert.buttons["Allow"].exists {
        alert.buttons["Allow"].tap()
        return true
    }
    return false
}
app.tap() // Trigger the monitor

// Handle app alert
let alert = app.alerts["Error"]
if alert.waitForExistence(timeout: 5) {
    alert.buttons["OK"].tap()
}
キーボードを閉じる
// Dismiss keyboard
if app.keyboards.count > 0 {
    app.toolbars.buttons["Done"].tap()
    // Or tap outside
    // app.tap()
}

テストプラン

複数構成でのテスト

テストプランを使うと、同じテストを異なる構成で実行できます:


{
  "configurations" : [
    {
      "name" : "English",
      "options" : {
        "language" : "en",
        "region" : "US"
      }
    },
    {
      "name" : "Spanish",
      "options" : {
        "language" : "es",
        "region" : "ES"
      }
    },
    {
      "name" : "Dark Mode",
      "options" : {
        "userInterfaceStyle" : "dark"
      }
    }
  ],
  "testTargets" : [
    {
      "target" : {
        "containerPath" : "container:MyApp.xcodeproj",
        "identifier" : "MyAppUITests",
        "name" : "MyAppUITests"
      }
    }
  ]
}
テストプランを使った実行
xcodebuild test \
  -scheme "MyApp" \
  -testPlan "MyTestPlan" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -resultBundlePath /tmp/results.xcresult

CI/CD との統合

テストの並列実行
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -parallel-testing-enabled YES \
  -maximum-parallel-test-targets 4 \
  -resultBundlePath /tmp/results.xcresult
失敗したテストの再試行
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -retry-tests-on-failure \
  -test-iterations 3 \
  -resultBundlePath /tmp/results.xcresult
コードカバレッジ
xcodebuild test \
  -scheme "MyAppUITests" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -enableCodeCoverage YES \
  -resultBundlePath /tmp/results.xcresult

# Export coverage report
xcrun xcresulttool export coverage \
  --path /tmp/results.xcresult \
  --output-path /tmp/coverage

失敗したテストのデバッグ

スクリーンショットの撮影
// Manual screenshot capture
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "Before Login"
attachment.lifetime = .keepAlways
add(attachment)
ビデオの撮影

テストプランまたはスキームで有効にします:

"systemAttachmentLifetime" : "keepAlways",
"userAttachmentLifetime" : "keepAlways"
要素階層の出力
// Debug: Print all elements
print(app.debugDescription)

// Debug: Print specific container
print(app.tables.firstMatch.debugDescription)

避けるべきアンチパターン

1. ハードコードされた遅延
// BAD
sleep(5)
button.tap()

// GOOD
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()
2. インデックスベースのクエリ
// BAD - Breaks if order changes
app.tables.cells.element(boundBy: 0)

// GOOD - Uses identifier
app.tables.cells["firstItem"]
3. テスト間での状態共有
// BAD - Tests depend on order
func test1_CreateItem() { ... }
func test2_EditItem() { ... }  // Depends on test1

// GOOD - Independent tests
func testCreateItem() {
    // Creates own item
}
func testEditItem() {
    // Creates item, then edits
}
4. 実装の詳細をテストする
// BAD - Tests internal structure
XCTAssertEqual(app.tables.cells.count, 10)

// GOOD - Tests user-visible behavior
XCTAssertTrue(app.staticTexts["10 items"].exists)

UI Automation の記録(Xcode 26 以降)

WWDC 2025-344 より:

  1. 記録 — Xcode で操作を記録します(Debug → Record UI Automation)
  2. 再生 — テストプランを使って、デバイス、言語、構成を変えて実行します
  3. 確認 — テストレポートでビデオ記録を視聴します
記録したコードの改善
// RECORDED (may be fragile)
app.buttons["Login"].tap()

// ENHANCED (stable)
let loginButton = app.buttons["loginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
loginButton.tap()

リソース

WWDC:2025-344、2024-10206、2023-10175、2019-413

ドキュメント:/xctest/xcuiapplication、/xctest/xcuielement、/xctest/xcuielementquery

スキル:axiom-ui-testing、axiom-swift-testing

コメント