mcpField
C
cross-app-tests-with-uiautomator

cross-app-tests-with-uiautomator

github.com/skydovesテスト・品質33963 回インストール22 週間前に更新
リポジトリ

cross-app-tests-with-uiautomator について

UiAutomatorでシステムUIや他のアプリを操作するAndroid計装テスト向けのスキルガイドです。最新のセレクター、古いオブジェクトの再取得、アプリ間の起動方法を扱います。

主な機能

  1. 01プロセスをまたいだシステムUI操作
  2. 02BySelectorとUiObject2による要素検索
  3. 03非同期UI状態の待機
  4. 04古いUiObject2参照の再取得
  5. 05アプリ起動と画面遷移の待機

活用シーン

  1. 01設定、通知、クイック設定のテスト
  2. 02共有シートなどアプリ間フローの検証
  3. 03StaleObjectExceptionの調査
  4. 04ホームからアプリを起動して画面を確認

インストール

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

$npx skills add https://github.com/skydoves/android-testing-skills --skill cross-app-tests-with-uiautomator

インストール後、Claude Code で/cross-app-tests-with-uiautomatorを入力して読み込まれたか確認します。

詳細

UiAutomator によるアプリ間テスト — システム UI と他のアプリを操作する

UiAutomator は、プロセス境界を越えて UI を操作できる唯一の AndroidX テストフレームワークです。Settings、ダイヤラー、システム通知、別のアプリのアクティビティなどが対象です。Espresso は、イベントが別のウィンドウに渡ると拒否します(InjectEventSecurityException)。UiAutomator はプラットフォームの AccessibilityNodeInfo ツリーを操作するため、すべてのウィンドウを認識します。このスキルでは、最新の BySelector/UiObject2 API、シングルトンの取得方法、StaleObjectException の復旧パターン、アプリ間の起動手順を説明します。

このスキルを使う場面

  • テストでシステム UI(Settings、ダイヤラー、システム通知、クイック設定、最近使ったアプリ)を操作する必要がある場合。
  • テストが開発者のアプリと別のアプリ(共有シート、連絡先ピッカー、カメラ)にまたがる場合。
  • テストが別のウィンドウに到達したため、Espresso が InjectEventSecurityException をスローした場合。
  • ユーザーが StaleObjectException を報告し、キャッシュされたノードの有効期間をデバッグしている場合。
  • テストでデバイスのホーム画面に戻る方法、最近使ったアプリを表示する方法、機内モードを切り替える方法をユーザーが尋ねた場合。
  • ユーザーが同じテストで BySelector と UiSelector を混在させていて、どちらが現行なのか分からなくなっている場合。

このスキルを使わない場面

  • UI がすべて開発者のアプリ内にある場合 — Espresso(Views)または Compose のテストのほうが高速で信頼性があります。Views については ../../espresso/writing-espresso-tests/SKILL.md を参照してください。
  • ランナーのスタックがまだ設定されていない場合 — ../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md から始めてください。
  • ユーザーがデバイス側の状態変更(アニメーションをオフにする、インテントのスタブ化など)だけをプッシュしたい場合 — ../../../adb/control/injecting-input-and-state/SKILL.md を参照してください。
  • テストで実行後のログを収集するだけの場合 — ../../../adb/observability/extracting-logs-with-logcat/SKILL.md を参照してください。

前提条件

  • ../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md に記載されているランナーのスタック。
  • androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")。
  • accessibility_service が利用可能な実機または仮想 Android デバイス(デフォルトで利用可能です)。
  • API 30 以降で Context.startActivity を使って別のパッケージを起動するには、テスト APK のマニフェストに、対象パッケージまたはインテントアクションを指定する <queries> エントリが必要です。

ワークフロー

  • 1. 明示的なインストルメンテーションを指定し、テストごとに一度だけシングルトン経由で UiDevice を取得します。 引数なしの getInstance() は @Deprecated です(R4 の 56~58 行)。事前に初期化されていない場合は IllegalStateException がスローされます。
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice

private lateinit var device: UiDevice

@Before fun setUp() {
    device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
}
  • 2. ハードキーとグローバルアクションは UiDevice のメソッド経由で送信します。 出典:R4 の 88~104 行。
device.pressBack()
device.pressHome()
device.pressMenu()
device.pressRecentApps()                                // GLOBAL_ACTION_RECENTS
device.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.openNotification()                                // GLOBAL_ACTION_NOTIFICATIONS
device.openQuickSettings()                               // GLOBAL_ACTION_QUICK_SETTINGS
device.wakeUp()                                          // power-on if off
device.sleep()                                           // power-off if on
  • 3. UI 要素は By ファクトリーを使って BySelector(モダン)で検索します。 セレクターはほぼイミュータブルなビルダーです。パッケージプライベートのコンストラクターにより、静的ファクトリーの使用が強制されます。条件は暗黙的な AND で結合されます。出典:R4 の 185~258 行。
import androidx.test.uiautomator.By

By.text("Submit")                              // exact, case-sensitive
By.textContains("ubmi")                        // case-sensitive substring
By.textMatches("Sub.*")                        // Pattern.compile regex
By.desc("Open menu")                           // content description
By.res("com.example.app", "submit_btn")        // package + res id
By.res("com.example.app:id/submit_btn")        // qualified res id
By.clazz("android.widget.Button")              // fully-qualified, or ".widget.Button" auto-prefixed with "android"
By.clazz(android.widget.Button::class.java)
By.pkg("com.android.settings")
By.depth(2)                                    // depth from window root
By.checkable(true)
By.hasChild(By.text("Inner"))                  // sugar for hasDescendant(child, 1)
By.hasDescendant(By.text("anywhere"))
By.hasParent(By.clazz("...LinearLayout"))
By.hasAncestor(By.res("...container"))
  • 4. device.findObject(...) で UiObject2 を取得して操作します。 一致するものがない場合、findObject は null を返します(例外は絶対にスローされません)。操作には、遅いレンダリングにも対応できるよう、device.wait(Until.findObject(By...), timeoutMillis) を優先してください。
import androidx.test.uiautomator.Until

val submit = device.wait(Until.findObject(By.res("com.example", "submit")), 5_000)
    ?: error("Submit not found within 5s")

submit.click()
submit.longClick()
submit.setText("hello")
submit.clear()
submit.swipe(Direction.LEFT, 0.8f)            // percent ∈ [0,1]
submit.scroll(Direction.DOWN, 1.0f)
submit.scrollUntil(Direction.DOWN, Until.findObject(By.text("Bottom")))
submit.pinchOpen(0.5f)
submit.pinchClose(0.5f)

UiObject2 は AccessibilityNodeInfo をキャッシュします。各アクションでは getAccessibilityNodeInfo() が呼び出され、さらに device.waitForIdle() と mCachedNode.refresh() が呼び出されます。失敗した場合は、登録済みの UiWatcher を実行して一度再試行し、2 回目も失敗すると StaleObjectException がスローされます(R4 の 281~286 行、622~637 行)。

  • 5. Until 条件で非同期の UI 状態を待機します。 R4 の 495~540 行によると、条件には 3 つの種類があります。
// SearchCondition (consume the device tree)
device.wait(Until.hasObject(By.text("Loaded")), 5_000)
device.wait(Until.gone(By.text("Loading")), 5_000)
val obj: UiObject2? = device.wait(Until.findObject(By.text("OK")), 5_000)
val list: List<UiObject2>? = device.wait(Until.findObjects(By.clazz("...Button")), 3_000)

// UiObject2Condition (consume a UiObject2 — for state predicates)
val checked = button.wait(Until.checked(true), 5_000)

// EventCondition (consume AccessibilityEvents — for window transitions)
val newWindowFired = device.performActionAndWait(
    { button.click() }, Until.newWindow(), 10_000,
)
  • 6. StaleObjectException が発生したら、再検索して復旧します。 StaleObjectException は RuntimeException を継承しており、チェックされない例外です。長期間保持する UiObject2 を使用する場合は、必ず処理してください。
fun clickRetry(selector: BySelector, timeoutMillis: Long = 5_000) {
    val obj = device.wait(Until.findObject(selector), timeoutMillis)
        ?: error("Not found: $selector")
    try {
        obj.click()
    } catch (e: StaleObjectException) {
        device.wait(Until.findObject(selector), timeoutMillis)?.click()
            ?: throw e
    }
}

よりよい方法は、長期間参照を保持するのではなく、操作のたびに再検索することです。よくある原因は、アクティビティの遷移、Compose の再コンポジションによるセマンティックノードの置き換え、RecyclerView のリサイクル、構成変更です(R4 の 639~645 行)。

  • 7. 別のパッケージを起動する場合は、executeShellCommand より Context.startActivity を優先します。 R4 の 147~151 行によると、executeShellCommand は @Discouraged です(UiDevice.java:1432)。Javadoc では、標準入力/標準エラーには UiAutomation.executeShellCommandRwe を推奨しています。通常のアプリ起動には、アプリ内のインテント経由の方法と、API 30 以降での <queries> マニフェストエントリーを使用します。

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <queries>
        <package android:name="com.android.settings" />
        <intent>
            <action android:name="android.settings.SETTINGS" />
        </intent>
    </queries>
    
</manifest>
val ctx = ApplicationProvider.getApplicationContext<Context>()
ctx.startActivity(
    Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
)
device.wait(Until.hasObject(By.pkg("com.android.settings").depth(0)), 10_000)

シェル経由の方法が本当に必要な場合(例:対応する Intent がないディープリンク):

device.executeShellCommand("am start -n com.android.settings/.Settings")
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)
  • 8. 必要に応じて Configurator のグローバルタイムアウトを調整し、終了処理で元に戻します。 設定はプロセス全体に適用されます。デフォルト値(R4 の 550~558 行)は、アイドル待機が 10 秒、セレクター待機が 10 秒(レガシーの UiObject のみ)、アクション応答待機が 3 秒、スクロールイベント待機が 1 秒です。
import androidx.test.uiautomator.Configurator

@Before fun setUp() {
    val c = Configurator.getInstance()
    originalIdle = c.waitForIdleTimeout
    c.waitForIdleTimeout = 5_000   // tighter for fast tests
}

@After fun tearDown() {
    Configurator.getInstance().waitForIdleTimeout = originalIdle
}

setKeyInjectionDelay は @Deprecated です(「このパラメーターは使用されなくなりました(テキストはキーではなく直接設定されます)。」— R4 の 575~577 行)。呼び出さないでください。

  • 9. レガシーの UiSelector/UiObject に切り替えるべき状況を把握します。 UiSelector は各アクションのたびに Configurator.getWaitForSelectorTimeout() を使ってノードを再解決するため、古くなることはありません。ただし、呼び出しごとのコストが高く、チェック例外の UiObjectNotFoundException が使われます。モダンな BySelector/UiObject2 をデフォルトとして推奨します。レガシーを使うのは、古いテストコードとの相互運用、または再解決の動作が必須の場合に限ってください。R4 の 594~619 行。

  • 10. 起動直後に findObject(By.text("...")) を呼び出し、対象ウィンドウが表示されていると決めつけないでください。 By.text は現在画面に表示されているものに一致するため、一時的なスプラッシュ画面や前のアプリが対象になる場合があります。必ず By.pkg(...) と組み合わせるか、明示的に待機してください。

device.pressHome()
ctx.startActivity(Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)   // gate on package
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()

パターン

パターン:パッケージ遷移を待たずに設定アプリを操作する場合の WRONG と RIGHT
// WRONG
device.findObject(By.text("Settings")).click()
// WRONG because: at the moment of the call, By.text("Settings") matches whatever text reads
// "Settings" anywhere on the current screen — that might be a launcher tile, a notification,
// or a previous-app menu item. The test silently navigates to the wrong place.
// RIGHT
device.pressHome()
device.executeShellCommand("am start -a android.settings.SETTINGS")
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()
パターン:引数なしの UiDevice.getInstance() の WRONG と RIGHT
// WRONG
val device = UiDevice.getInstance()
// WRONG because: UiDevice.java:344 — the no-arg getInstance() is @Deprecated and throws
// IllegalStateException if the singleton has not been previously initialized with the
// instrumentation argument. Setting it implicitly via androidTest is unreliable across
// runner versions.
// RIGHT
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
パターン:遷移をまたいで UiObject2 を長期間保持する場合の WRONG と RIGHT
// WRONG
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi")), 5_000)
device.openQuickSettings()
tile.click()                            // throws StaleObjectException — quick settings replaced the layout
// RIGHT — re-acquire after the transition
device.openQuickSettings()
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi").clickable(true)), 5_000)
tile?.click()
device.pressBack()
パターン:すべての通知を閉じる
device.openNotification()
val shade = device.wait(
    Until.findObject(By.res("com.android.systemui:id/notification_stack_scroller")),
    5_000,
) ?: error("Notification shade not visible")

for (n in shade.findObjects(By.res("com.android.systemui:id/notification_layout"))) {
    n.swipe(Direction.RIGHT, 1.0f)
}
device.pressBack()
パターン:クリーンな状態からテスト対象のアプリを起動する
@Before fun launchAppFromHome() {
    device.pressHome()
    val ctx = ApplicationProvider.getApplicationContext<Context>()
    val launch = ctx.packageManager.getLaunchIntentForPackage("com.example.app")!!
        .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
    ctx.startActivity(launch)
    device.wait(Until.hasObject(By.pkg("com.example.app").depth(0)), 5_000)
}

必須ルール

  • 必須 UiDevice は UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) を使用して取得します。引数なしの getInstance() は @Deprecated であり、信頼性がありません。
  • 必須 新しいテストでは UiSelector/UiObject ではなく、BySelector/UiObject2 を優先します。レガシー API は相互運用のためにのみ使用してください。
  • 必須 ウィンドウをまたぐ遷移の後に内部要素を解決する前に、device.wait(Until.hasObject(By.pkg("...")), timeout) で遷移を待ちます。単独の findObject は、待機処理を挟まずに現在のウィンドウを対象に実行されます。
  • 必須 再コンポーズ、遷移、またはリサイクラービューの再利用をまたいで UiObject2 の参照を保持する場合は、StaleObjectException を処理します(device.wait(Until.findObject(By...), timeout) で再取得します)。
  • 必須 テストで Configurator の設定を変更した場合は、@After で元に戻します。設定はプロセス全体に適用され、他のテストに漏れます。
  • 禁止 Configurator.setKeyInjectionDelay を呼び出さないでください。@Deprecated であり、何も行いません。
  • 禁止 通常のアプリ起動に executeShellCommand を使用しないでください。これは @Discouraged です。API 30 以降では、<queries> のマニフェストエントリーと Context.startActivity を使用してください。
  • 禁止 パッケージで絞り込まずに device.findObject(By.text("...")) を使用しないでください。テキストの一致対象は現在のウィンドウの内容であり、一時的なスプラッシュ画面の場合があります。
  • 推奨: 単一の操作は、findObject を呼び出してから 2 行に分けて click を呼び出すのではなく、device.wait(Until.findObject(...), timeout)?.click() で囲みます。2 段階に分ける形式では、描画との間で競合が発生します。
  • 推奨: テストで起動する外部パッケージには <queries> を追加してください。追加しない場合、API 30 以降では失敗が通知されないことがあります。

検証

  • ソースファイルで、引数なしの UiDevice.getInstance() を呼び出していない。
  • ソースファイルで Configurator.setKeyInjectionDelay を呼び出していない。
  • 長期間保持する UiObject2 の参照は、操作を try { ... } catch (StaleObjectException) で囲んでいる。または、操作の前にテストで再取得している。
  • 外部パッケージを起動するテストには、そのパッケージまたはインテントアクションに対応する <queries> エントリーが src/androidTest/AndroidManifest.xml にある。
  • ./gradlew :<module>:connectedDebugAndroidTest を実行しても、UiDevice.getInstance() による IllegalStateException が発生せず、未処理の StaleObjectException がテスト結果に伝播しない。
  • ウィンドウをまたぐ遷移の直後に findObject(By.text(...)) を呼び出す場合、その前に device.wait(Until.hasObject(By.pkg(...)), ...) を呼び出している。
  • @Before で Configurator を変更した場合、@After で元の値に戻している。

参考資料

  • Android Developers — UiAutomator の概要:https://developer.android.com/training/testing/other-components/ui-automator
  • AndroidX Test(UiAutomator)のリリースノート:https://developer.android.com/jetpack/androidx/releases/test
  • UiAutomator API リファレンス:https://developer.android.com/reference/androidx/test/uiautomator/package-summary
  • androidx/test/uiautomator/UiDevice.java — シングルトンのエントリーポイント。344 行目(引数なしの getInstance、@Deprecated)、357 行目(現在の getInstance(Instrumentation))、1432 行目(@Discouraged の executeShellCommand)。
  • androidx/test/uiautomator/By.java — BySelector の静的ファクトリー。text/desc/res/clazz/pkg/depth/checkable/hasChild/hasDescendant/hasParent/hasAncestor のソース。
  • androidx/test/uiautomator/BySelector.java(838 行)— 条件の全カタログ。128 行目(.widget.Button に android を自動的に前置)、198/211/224 行目(正規表現/部分一致/前方一致の規則)、605-650 行目(maxDepth)、660 行目(displayId)。
  • androidx/test/uiautomator/UiObject2.java(1152 行)— click、setText、swipe、scroll、scrollUntil、pinchOpen/Close、fling。1031 行目 — 更新失敗とウォッチャーの再試行後、getAccessibilityNodeInfo() が StaleObjectException をスローします。
  • androidx/test/uiautomator/Until.java 43-456 行目 — hasObject、gone、findObject、findObjects、newWindow、scrollFinished と、UiObject2Condition の述語。
  • androidx/test/uiautomator/Configurator.java 36 行目(復元するよう警告)、108 行目(waitForSelector はレガシー専用)、163 行目(actionAcknowledgment はレガシー専用)、189 行目(setKeyInjectionDelay は @Deprecated)、295 行目(setDefaultDisplayId)。
  • androidx/test/uiautomator/StaleObjectException.java — RuntimeException を継承します。
  • tasks/research/R4-uiautomator.md — UiAutomator 2.3.0 の詳細な解説。56-58 行目(非推奨のシングルトン)、185-258 行目(BySelector のカタログ)、268-369 行目(UiObject2 の操作)、495-540 行目(Until の条件)、594-619 行目(モダン方式とレガシー方式の表)、622-662 行目(StaleObjectException の復旧)。
  • docs/CORPUS.md セクション H.6 — UiAutomator の基本事項と StaleObjectException の復旧概要。

コメント