WWDC Quick Look 💓 By SwiftGGTeam
Record, replay, and review: UI automation with Xcode

Record, replay, and review: UI automation with Xcode

元の動画を見る

ハイライト

Xcode はこれまでのタップ操作をワンクリックで XCUITest コードに記録でき、Xcode Cloud 上で多言語・多デバイス・多システム状態をまたいで再生します。最後に、動画付きの Test Report で失敗箇所を特定できます。


主要内容

多くの開発者が UI テストを書かない理由は単純です。XCUIElementQuery のチェーンを手書きするのは冗長で、多言語・多デバイスでの検証は手作業に頼るしかありません。たとえば「お気に入りに追加」というフローを、英語・アラビア語・ドイツ語でそれぞれ実行し、さらに iPhone と iPad で各 1 回ずつ走らせる——コストが高すぎて誰もやりたがらないのです。

Apple の Xcode チームが提示した解決策は、UI 自動化を 3 つの段階に分けることです。記録(Record)、再生(Replay)、レビュー(Review)です。記録段階では開発者がタップするだけで、Xcode が各タップ・スワイプ・キーボード入力を Swift コードに変換します(12:00)。再生段階では Test Plan を使って同じテストを複数のロケールとデバイス構成にマッピングし、ローカルでも Xcode Cloud でも実行できます。レビュー段階では Test Report が完全な動画再生を提供し、失敗時にはダイアモンドマークが表示され、要素をクリックするとコード候補が表示され、View Source をクリックするとテストのソースコードにジャンプできます(03:00)。

覚えておくべき根本的なロジックは、UI 自動化が accessibility フレームワークの上に構築されていることです(05:02)。accessibilityIdentifier は自動化にとっての「安定した主キー」です。これはローカライズされず、スクリーンリーダーにも読み上げられず、テストコードが要素を特定するためにのみ使用されます。まずアクセシビリティを整えれば、UI テストは自然と安定します。


詳細

一、ビューに accessibility identifier を付ける

SwiftUI では .accessibilityIdentifier 修飾子を使い、データモデルの id と組み合わせて各セルを一意にします(07:52)。

import SwiftUI

struct LandmarkDetailView: View {
  let landmark: Landmark
  var body: some View {
    VStack {
      Image(landmark.backgroundImageName)
        .accessibilityIdentifier("LandmarkImage-\(landmark.id)")

      Text(landmark.description)
        .accessibilityIdentifier("LandmarkDescription-\(landmark.id)")
    }
  }
}

重要なポイント:

  • LandmarkImage-\(landmark.id):identifier にデータの id を連結することで、リスト内の各セルが一意になります。
  • identifier を "Great Barrier Reef" のようなユーザーに見えるテキストにしないでください。そのような文字列はローカライズによって変化します。
  • identifier の 3 原則は、unique(アプリ全体で一意)、descriptive(明確に説明する)、static(コンテンツに依存しない)です。

UIKit 側ではプロパティに値を代入します(08:19)。

import UIKit

struct LandmarksListViewController: UIViewController {
  let landmarks: [Landmark] = [landmarkGreatBarrier, landmarkCairo]

  override func viewDidLoad() {
    super.viewDidLoad()

    for landmark in landmarks {
      let button = UIButton(type: .custom)
      setupButtonView()

      button.accessibilityIdentifier = "LandmarkButton-\(landmark.id)"

      view.addSubview(button)
    }
  }
}

重要なポイント:

  • accessibilityIdentifierUIView の組み込みプロパティです。デフォルトでアクセシビリティ要素になっているコントロール(ボタン、テキスト、画像)に対しては、直接値を代入するだけです。
  • これは VoiceOver によって読み上げられず、ユーザーにも公開されません。自動化のための「プライベート」フィールドです。
  • 手間を省きたい場合は、Xcode の coding assistant が「Add accessibility identifiers to the relevant parts of this view」のような自然言語指令をサポートしており、自動的にモデルの id フィールドを使って identifier を構築します(09:05)。

二、記録後すぐにクエリを書き換える

レコーダーは各行のコードにドロップダウンを付け、複数のクエリ方法を列挙します。Max は 3 つの選択原則を挙げています(13:54)。

// 悪い例:ローカライズ文字列を使う
XCUIApplication().staticTexts["Max's Australian Adventure"]

// 良い例:accessibility identifier を使う
XCUIApplication().staticTexts["Collection-1"]

重要なポイント:

  • ローカライズ文字列はアラビア語やドイツ語で変化しますが、identifier は変わりません。
  • staticTexts[...] は identifier で検索する構文糖であり、SwiftUI で書いた .accessibilityIdentifier と一致します。

2 つ目:クエリは短いほど良い(14:09)。

// 悪い例:チェーンが長すぎる
XCUIApplication().scrollViews.staticTexts["Collection-1"]

// 良い例:直接特定する
XCUIApplication().staticTexts["Collection-1"]

重要なポイント:

  • 中間に .scrollViews を入れると、「ScrollView の中に存在する必要がある」という条件も固定化されます。後で List や Form に変更した場合、テストが崩れます。
  • identifier がアプリ全体で一意であれば、中間のコンテナをスキップしても安全です。

3 つ目:動的コンテンツには汎用クエリを使う(14:21)。

// 悪い例:具体的な内容を固定する
XCUIApplication().staticTexts["Max's Australian Adventure"]

// 良い例:最初のマッチを取得する
XCUIApplication().staticTexts.firstMatch

重要なポイント:

  • firstMatch は「ここにテキストが存在するが、具体的な内容は変化する」というシーンに適しています。

三、検証の追加、デバイス状態の設定、起動引数の渡し方

記録しただけでは第一歩です。XCTAssertwaitForExistence を補完する必要があります(15:49)。

import XCTest

class LandmarksUITests: XCTestCase {

  func testGreatBarrierAddedToFavorites() {
    let app = XCUIApplication()
    app.launch()
    app.cells["Landmark-186"].tap()
    XCTAssertTrue(
      app.staticTexts["Landmark-186"].waitForExistence(timeout: 10.0)),
      "Great Barrier exists"
    )

    let favoriteButton = app.buttons["Favorite"]
    favoriteButton.tap()
    XCTAssertTrue(
      favoriteButton.wait(for: \.value, toEqual: true, timeout: 10.0),
      "Great Barrier is a favorite"
    )
  }
}

重要なポイント:

  • waitForExistence(timeout:) は要素が出現するのを待ち、アニメーションやネットワーク遅延による flaky test を回避します。
  • wait(for: \.value, toEqual: true, timeout:) は新しい keypath 待機 API で、任意のプロパティが期待値になるのを待てます。sleep による無限ループよりずっとクリーンです。
  • お気に入りボタンの value は選択後に true に変化します。これはアクセシビリティフレームワークが公開する状態フィールドです。

デバイス状態は setUp で一度に設定できます(16:36)。

import XCTest
import CoreLocation

class LandmarksUITests: XCTestCase {

  override func setUp() {
    continueAfterFailure = false

    XCUIDevice.shared.orientation = .portrait
    XCUIDevice.shared.appearance = .light

    let simulatedLocation = CLLocation(latitude: 28.3114, longitude: -81.5535)
    XCUIDevice.shared.location = XCUILocation(location: simulatedLocation)
  }
}

重要なポイント:

  • XCUIDevice.shared.appearance でライト/ダークを切り替え、Dark Mode を手動でオンにする手間を省きます。
  • XCUIDevice.shared.location = XCUILocation(location:) はシミュレータに仮想位置情報を注入し、マップや近隣検索系アプリに役立ちます。
  • continueAfterFailure = false はアサーションが失敗したら即座に停止し、エラー状態のまま続行しないことを保証します。

起動引数と環境変数は初期データをコントロールするために使用します(16:54)。

import XCTest

class LandmarksUITests: XCTestCase {

  func testLaunchWithDefaultCollection() {
    let app = XCUIApplication()
    app.launchArguments = ["ClearFavoritesOnLaunch"]
    app.launchEnvironment = ["DefaultCollectionName": "Australia 🐨 🐠"]
    app.launch()

    app.tabBars.buttons["Collections"].tap()
    XCTAssertTrue(app.buttons["Australia 🐨 🐠"].waitForExistence(timeout: 10.0))
  }
}

重要なポイント:

  • launchArguments はアプリ内で CommandLine.arguments から読み取り、「お気に入りをクリア」などのテスト分岐を実現できます。
  • launchEnvironment はアプリ内で ProcessInfo.processInfo.environment から読み取り、初期値の渡しに適しています。
  • この仕組みにより各テストは独立して再現可能になり、前のテストが残した状態に依存しません。

四、URL scheme でページに直接遷移し、アクセシビリティ監査を実行する

URL scheme は複数階層のナビゲーションをスキップし、対象ページに直接遷移できます(17:04)。

import XCTest

class LandmarksUITests: XCTestCase {

  func testOpenGreatBarrier() {
    let app = XCUIApplication()
    let customURL = URL(string: "landmarks://great-barrier")!
    app.open(customURL)

    XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0))
    XCTAssertTrue(app.staticTexts["Great Barrier Reef"].waitForExistence(timeout: 10.0))
  }
}

重要なポイント:

  • app.open(customURL) はアプリ自身に URL を処理させます。深い階層のページをテストする際に、階層を辿るクリックを省略できます。
  • ブラウザでリンクをクリックしたようなシステムレベルの遷移をシミュレートしたい場合は、XCUIDevice.shared.system.open(customURL) に置き換えます(17:12)。
  • wait(for: .runningForeground, timeout:) はアプリが実際にフォアグラウンドに入るのを待ってからアサーションを行います。

最後のおまけ:1 行でアクセシビリティ監査を実行できます(17:13)。

import XCTest

class LandmarksUITests: XCTestCase {

  func testPerformAccessibilityAudit() {
    let app = XCUIApplication()
    try app.performAccessibilityAudit()
  }
}

重要なポイント:

  • performAccessibilityAudit() は現在の画面のコントラスト、タップ領域、欠落しているラベルなどを自動スキャンし、問題を発見した場合はテスト失敗として扱います。
  • このテストを CI に追加すれば、各 PR で自動的にアクセシビリティのリグレッションを阻止できます。

五、Test Plan の多ロケール再生と Test Report

Test Plan は同じテストコードを複数の構成に掛け合わせます。各ロケール、各デバイス外観、各画面方向が 1 つの構成です。Xcode Cloud はこれらの構成をクラウドマシンに分散して並列実行し、iPhone、iPad などのデバイスと英語、アラビア語、ギリシャ語、ドイツ語などの言語をカバーします。Test Report は各実行の動画を集約します。インタラクションポイントにはドットが重ねられ、失敗時にはダイアモンドマークが表示され、要素をクリックするとコード候補が表示され、View Source をクリックするとテストのソースファイルにジャンプします(03:22)。


重要ポイント

  • 何をすべきか:キーフローのすべてのビューに accessibilityIdentifier を付けてから、UI テストを書き始めます。

    • なぜ価値があるか:identifier は UI 自動化の安定した主キーです。これがないと、記録されたコードはすべてローカライズ文字列になり、多言語実行時に崩れます。
    • どう始めるか:1 つのキーフロー(登録、注文、お気に入り)を選び、Xcode coding assistant の 1 つのプロンプトで一括追加します。命名規則は <Type>-<id> とします。
  • 何をすべきか:Test Plan にデフォルトで 3 つのロケールを設定します——ローカル言語 + 1 つの RTL(アラビア語) + 1 つの長い文字列言語(ドイツ語)。

    • なぜ価値があるか:RTL はレイアウトのミラーリングを検証し、長い文字列は切り捨てと改行を検証します。これ 2 つで国際化バグの 80% をカバーできます。
    • どう始めるか:.xctestplan の Configurations に 3 つの構成を追加し、それぞれ Localization フィールドを変更します。まずローカルで通してから、Xcode Cloud に移行します。
  • 何をすべきかapp.performAccessibilityAudit() を CI の必須テストに追加します。

    • なぜ価値があるか:1 行のコードで各 PR でアクセシビリティのリグレッションを阻止でき、リリース後にユーザーからの苦情を受けて修正するよりはるかに安上がりです。
    • どう始めるか:新しい UI テストメソッドを作成し、各コアページで監査を 1 回実行します。既存の UI テストとセットアップを共有します。
  • 何をすべきか:記録後すぐにデフォルトのクエリを identifier または firstMatch に変更し、中間層を削除します。

    • なぜ価値があるか:記録で生成されたクエリは過度に具体的で、UI のリファクタリング後に大量に失効します。記録直後に修正すれば、メンテナンスコストを 1 桁下げられます。
    • どう始めるか:各行でドロップダウンをクリックし、identifier を優先します。identifier がない場合は、ソースコードに追加してから再記録します。
  • 何をすべきか:動画とスクリーンショットの保持ポリシーを「失敗時のみ」から「すべて保持」に変更します。

    • なぜ価値があるか:通過した実行の動画は、プロダクトデモ、新人オンボーディング、マーケティング素材として直接使え、ほぼゼロコストです。
    • どう始めるか:Test Plan の Configurations パネルで「UI Test Screenshots」と「UI Test Recording」を Always に変更します。

関連セッション

コメント

GitHub Issues · utterances