cross-app-tests-with-uiautomator について
UiAutomatorでシステムUIや他のアプリを操作するAndroid計装テスト向けのスキルガイドです。最新のセレクター、古いオブジェクトの再取得、アプリ間の起動方法を扱います。
主な機能
- 01プロセスをまたいだシステムUI操作
- 02BySelectorとUiObject2による要素検索
- 03非同期UI状態の待機
- 04古いUiObject2参照の再取得
- 05アプリ起動と画面遷移の待機
活用シーン
- 01設定、通知、クイック設定のテスト
- 02共有シートなどアプリ間フローの検証
- 03StaleObjectExceptionの調査
- 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.java43-456 行目 —hasObject、gone、findObject、findObjects、newWindow、scrollFinishedと、UiObject2Conditionの述語。androidx/test/uiautomator/Configurator.java36 行目(復元するよう警告)、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 の復旧概要。