コンテンツにスキップ

English · 日本語

コード生成(codegen)

通過したシナリオから、出力先フレームワークの流儀に沿った ネイティブテスト を生成できます。これにより チームは、既存の CI(継続的インテグレーション)で同じフローを実行できます。テスト時に bajutsu ランタイムも AI も不要です。出力先は 3 つで、iOS backend 向けの XCUITest(Swift)、web backend 向けの Playwright(TypeScript)、Android backend 向けの UI Automator(Kotlin) です。マッピングは 純粋に構造的(AI 非依存)です。

実装: bajutsu/codegen/xcuitest.py(XCUITest)、bajutsu/codegen/playwright.py(Playwright)、 bajutsu/codegen/uiautomator.py(UI Automator)。

関連: scenarios · cli · drivers · showcase の UI テストターゲット


使い方

bajutsu codegen <scenario.yaml> --target <name> [--emit xcuitest | playwright | uiautomator] [-o <out>]

--emitxcuitest(既定)、playwrightuiautomator のいずれかです。-o -(既定)なら標準出力に、 ファイルパスならそのファイルに書き出します。--emit playwright は web ターゲット(targets.<name>.baseUrl の設定)を、 --emit uiautomator は Android ターゲット(targets.<name>.package の設定)を要求します。 対応するターゲットでなければ終了コード 2 で終わります。config の launchEnv は生成テストに引き継がれます(cli)。XCUITest では app.launchEnvironment に、 Playwright では localStorage のシードに反映され、UI Automator では intent extra として渡されます。

XCUITest の出力の形

シナリオ群を 1 つの XCTestCase サブクラスに落とします。1 シナリオ = 1 テストメソッド

// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import XCTest

final class ComponentsUITests: XCTestCase {
  private let app = XCUIApplication()
  private func el(_ id: String) -> XCUIElement {
    app.descendants(matching: .any)[id]
  }
  private func byLabel(_ label: String) -> XCUIElement { ... }
  private func matchingId(_ glob: String) -> XCUIElementQuery { ... }

  func test_open_filter_shows_the_sheet() {
    app.launchEnvironment["SHOWCASE_UITEST"] = "1"
    app.launch()

    byLabel("Log").tap()
    XCTAssertFalse(el("log.sheet.title").exists)
    el("log.openFilter").tap()

    // expect
    XCTAssertTrue(el("log.sheet.title").exists)
  }
}
  • ヘルパ el(id) / byLabel(label) / matchingId(glob) が、単一フィールドのセレクタ 3 形(id / label / idMatches)を XCUIElement に橋渡しします。
  • 各メソッドは冒頭で launchEnvironment を設定してから app.launch() を呼びます。env は config の launchEnv < シナリオの preconditions.launchEnv のマージで、テスト側が勝ちます。

セレクタのマッピング(XCUITest)

単一の id / label / idMatches は上記のヘルパをそのまま使います。複合セレクタ (valuetraitsindex、または複数フィールドの組み合わせ)は、// TODO に落とさず 1 つの NSPredicate クエリに合成します(BE-0026)。

Selector フィールド 生成される XCUITest
id / idMatches identifier == %@ / identifier LIKE %@
label label == %@
labelMatches(リテラル部分文字列) label CONTAINS %@
value value == %@
traits: [button \| link] elementType == XCUIElement.ElementType.<case>.rawValue
traits: [notEnabled] / [selected] enabled == NO / selected == YES
index: n .element(boundBy: n)(負の n は末尾から数え .element(boundBy: query.count - k)。それ以外は .firstMatch

設定された全フィールドを AND で結合します。忠実な構造マッピングが無いフィールドがあると、セレクタは el("UNSUPPORTED_SELECTOR") のまま残ります(誤った推測ではなく、明示的に残したギャップ)。

  • 正規表現メタ文字を含む labelMatches:これは Python の re.search パターンです。メタ文字を含ま ないものだけが単純な部分文字列で(CONTAINS)、本物の正規表現(例 ^Item)は忠実な NSPredicate 形が ありません(ICU の MATCHES は全体一致でアンカーの意味も異なる)。
  • within幾何的なフレーム包含制約です(候補のフレームがコンテナのフレーム内に収まること。 selectors 参照)。XCUITest のクエリはツリーベースで幾何的ではありません。
  • 未知の traitbutton / link / notEnabled / selected の語彙の外。

マッピング表

シナリオ要素 生成される XCUITest
tap el(id).tap() / byLabel(...).tap()
doubleTap .doubleTap()
longPress .press(forDuration: <sec>)
typeinto あり) el(id).tap() + .typeText(...)
typeinto なし) app.typeText(...)
clear .tap() + 全選択(typeKey("a", modifierFlags: .command))+ 削除。XCUIElement には「clear」の基本操作が無いため、フォーカス→全選択→削除で runner 自身の clear(BE-0265)を忠実に再現します
delete { count } .tap() + 削除キーを count 回(typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count:))、BE-0265)
select .tap() + 全選択(BE-0265)
copy app.typeKey("c", modifierFlags: .command)
back OS のナビゲーション戻るボタンを tap します。XCUITest ドライバが実行時に tap するのと同じ要素(共有定数、BE-0210)
swipe { on, direction } .swipeUp/Down/Left/Right()
swipe { from, to } coord(x1, y1).press(forDuration: 0.1, thenDragTo: coord(x2, y2))XCUICoordinate のドラッグ。BE-0025)
drag { on, direction } 方向指定 swipe と同じ基本操作(.swipeUp/Down/Left/Right())。iOS では実際のドラッグがスクロールとハンドル移動の両方を兼ねます(BE-0227)
pinch .pinch(withScale: <scale>, velocity: <±1.0>)。velocity の符号は scale に合わせます(scale ≥ 1 で拡大)
rotate .rotate(<radians>, withVelocity: 1.0)
scroll { to } // TODO。XCUITest には要素までスクロールする堅牢な単一の基本操作が無く(swipeUp() は再クエリせず固定量スクロールするだけ)、忠実な有界・再クエリ型のループは手書きヘルパが必要なため、偽の swipeUp() 連打を出す代わりに未生成のままにします(BE-0326)
handleSystemAlert XCTAssertTrue(XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons["…"].waitForExistence(timeout:)) + .tap()。ネイティブな SpringBoard の書き方で、ステップの timeout を引き継ぐ(BE-0316)。prompt / choice の形は label を run の locale から解決するため(BE-0320)、静的な変換では扱えず、ラベル付きの // TODO を出力する
wait { for } XCTAssertTrue(el(...).waitForExistence(timeout:))
wait { until: gone } .waitForNonExistence(timeout:)
wait { until: screenChanged/settled } コメント(XCUITest は hittability を自動待機)
relaunch app.terminate() + app.launch()
assert / expect の各種 下表

アサーションのマッピング

アサーション XCUITest
exists XCTAssertTrue(el(...).exists)negateXCTAssertFalse
value (equals/contains/matches) XCTAssertEqual(.value ...) / .contains(...) / 正規表現 range(of:options:.regularExpression)
label (equals/contains/matches) XCTAssertEqual(.label, ...) / .contains(...) / 正規表現
enabled / disabled XCTAssertTrue/False(...isEnabled)
selected XCTAssertTrue(...isSelected)
count (equals/atLeast/atMost) matchingId(glob).count(id 単体は exists ? 1 : 0)を XCTAssertEqual/GreaterThanOrEqual/LessThanOrEqual

未対応は TODO コメントに落とす

未対応の構文(simctl レベルのデバイス制御 setLocation / push、未知の trait、および Playwright 出力先での座標スワイプ)は、失敗させずに // TODO 行を出力します。デバイス制御ステップは レビュー担当が実行する simctl コマンド名を明記します。出力は常にレビューでき、生成結果を壊しません。 生成ファイルの先頭にも「手で編集せず再生成せよ」と明記します。これはどの出力先にも共通です。

ネットワークの request / requestSequence アサーションも、XCUITest と UI Automator では同じ // TODO の規則に従います。どちらの backend もネットワーク傍受の面を持たないためです。Playwright は 例外です。web backend はネットワーク通信を実際に傍受するため、生成テストは // TODO に落とさず、 本物のアサーションとして出力します(詳細は後述)。

ただし if / forEach / extract の 3 つは例外です。いずれも実行時の UI ツリーに対して 評価します — 現在の状態による分岐、その時点のマッチ集合に対するループ、解決した要素のプロパティの キャプチャであり、静的に生成したテストにはそれを再現するランタイムがありません。そこで黙って no-op スタブを出す代わりに、3 つの出力先すべてが生成時に CodegenError を送出し、その構文を使うシナリオには bajutsu run を本来の実行経路として示します(BE-0297)。

Playwright(web)の出力

--emit playwright は、シナリオを TypeScript の Playwright テスト@playwright/test)として描き出します。 これが web(Playwright)backend(drivers)の引き渡し成果物です。シナリオ群を 1 つの test.describe ブロックに落とし、1 シナリオ = 1 個の test(...) とします(テストメソッド 1 つに対応)。

run 用のドライバは DOM をたどって解決した要素の中心を座標クリックし、照合を iOS とバイト単位で一致させます。 生成テストはこれと反対に、Playwright の意味的ロケータ(getByTestId / getByRole)と web-first アサーション (expect(...).toBeVisible())を使います。これは意図したものです。出力先フレームワーク自身がランタイムである 以上、テストは Playwright の流儀で書く必要があり、引き渡し成果物の決定性は Playwright の自動待機が担います (web-first アサーションはテストのタイムアウトまで再試行します)。一方 iOS 側にはこうした切り分けがありません。BE-0290 で idb を撤去して以来、XCUITest backend は 実行時点ですでにアクセシビリティ識別子で直接 tap しており、生成された el(id).tap()waitForExistence と同じ流儀です。

// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import { test, expect } from '@playwright/test';

const BASE_URL = 'http://localhost:3000';

test.describe('Components', () => {
  test('long press reveals a label', async ({ page }) => {
    await page.goto(BASE_URL);

    await expect(page.getByTestId('comp.secret')).toBeHidden();
    await page.getByTestId('comp.longpress').click({ delay: 600 });

    // expect
    await expect(page.getByTestId('comp.secret')).toBeVisible();
  });
});
  • 各テストは BASE_URL(アプリの baseUrl。web では page.gotolaunch に相当)へ遷移します。config の launchEnv < シナリオの preconditions.launchEnvpage.addInitScript(() => localStorage.setItem(...)) で シードします。別のチャネル(クエリパラメータやクッキー)を期待するアプリには // TODO を出します。
  • 待機はすべて Playwright の自動待機を使います。出力する固定の時間は longPressdelay だけで、これは ジェスチャ固有のものです。iOS 側が press(forDuration:) に対して取るのと同じ扱いです。方向指定の swipe にはこの固定の時間はありません。要素をドラッグするのではなく、その中心から wheel イベントで ページをスクロールするからです(web ドライバ自身のスクロールと合わせています。BE-0227)。

セレクタのマッピング(Playwright)

Selector フィールド Playwright ロケータ
id page.getByTestId('…')data-testid の規約)
label(単独) page.getByText('…', { exact: true })
label + traits page.getByRole(role, { name: '…', exact: true })
traits(単独) page.getByRole('button')
idMatches(fnmatch glob) data-testid の CSS 属性セレクタ。prefix*[…^="prefix"]*suffix[…$="suffix"]*sub*[…*="sub"]。内側の *?[…] クラスは // TODO
labelMatches page.getByText(/regex/)(JS の RegExp。DSL の re.search の意味論に合わせる)
index .nth(<index>) でロケータを絞る
value / within AND 制約としては未対応で // TODO

アクションのマッピング(Playwright)

シナリオ要素 Playwright
tap await loc.click()
doubleTap await loc.dblclick()
typeinto あり) await loc.fill('…')
typeinto なし) await page.keyboard.type('…')
longPress await loc.click({ delay: <ms> })
clear await loc.clear()。ドライバ自身のフォーカス→バックスペースによる clear(BE-0265)を忠実に再現します
delete { count } フォーカス + page.keyboard.press('Backspace')count 回(BE-0265)
select await loc.selectText()。web における全選択の対応物です(BE-0265)
copy await page.keyboard.press('Control+c')
back await page.goBack()。ブラウザ履歴で、ドライバの back() と同じ基本操作です(BE-0210)
swipe { on, direction } 要素中心からその方向への page.mouse.wheel スクロール(BE-0227)
swipe { from, to } // TODO(座標スワイプは生成しない)
drag { on, direction } 要素中心から実際にポインタでドラッグ(move → down → move → up)します。方向指定の swipe は wheel で済ませますが、drag は web ドライバと同じくドラッグします(BE-0227)
scroll { to } await loc.scrollIntoViewIfNeeded()。Playwright のロケータは操作前に自動でスクロールして要素を可視領域に入れるため、direction / within / maxScrolls / amount は、自前でステップを刻むブラウザ自身のスクロールに吸収されます(BE-0326、BE-0400)
wait { for } await expect(loc).toBeVisible({ timeout: <ms> })
wait { until: gone } await expect(loc).toBeHidden({ timeout: <ms> })
wait { until: screenChanged/settled } コメント(Playwright は自動待機)
relaunch await page.goto(BASE_URL)
pinch / rotate // TODO(マルチタッチ。web backend は駆動しない)
handleSystemAlert // TODO(iOS 専用。web に OS レベルのプロンプトは無い)

アサーションのマッピング(Playwright、web-first expect

アサーション Playwright
exists await expect(loc).toBeVisible()negate.toBeHidden()
value (equals/contains/matches) await expect(loc).toHaveValue('…' \| /regex/)
label (equals/contains/matches) .toHaveText('…') / .toContainText('…') / .toHaveText(/regex/)
enabled / disabled .toBeEnabled() / .toBeDisabled()
selected .toBeChecked()
count (equals/atLeast/atMost) .toHaveCount(n)。atLeast/atMost は await loc.count() を比較

XCUITest や UI Automator と異なり、web backend はネットワーク通信を実際に傍受します。そのため request / requestSequence アサーション(および until: { request } の待機)は、この出力先では // TODO になりません。生成テストはナビゲーション前に page.on('requestfinished', ...) の recorder を 仕込み、そこまでに観測した通信に対してアサートします。これは runner 自身の collector を写した時点確認で、 今後の通信を待って止まりかねない waitForResponse は使いません。responseSchema アサーションは、 JSON Schema の検証に生成テストへ持ち込むライブラリが必要になるため、依然として // TODO に落とします。

describe ブロック名は -o のファイル名 stem(なければシナリオファイル名)を読みやすく整えたものです。各 test(...) のタイトルはシナリオ名をそのまま使います(TypeScript のテストタイトルは単なる文字列なので、識別子用の 正規化は要りません)。

UI Automator(Android)の出力

--emit uiautomator は、シナリオを Kotlin の UI Automator テストandroidx.test.uiautomator と JUnit)と して描き出します。これが Android(adb)backend(drivers)の引き渡し成果物です。シナリオ群を 1 つの 計装テストクラスに落とし、1 シナリオ = 1 個の @Test メソッド とします(XCUITest のメソッド 1 つに対応)。

UI Automator は adb backend と構造がきわめて近い出力先です。どちらもアプリを別プロセスからの黒箱として resource-id / text / content-desc を通して見ます。そのため生成テストは、ドライバ自身によるツリーの読み取りを忠実に逆向きにした ものになります。UiDeviceUiObject2 を駆動し JUnit でアサートするので、実行時にドライバが行うことをそのまま 写し取ります。Espresso のビューマッチャの流儀(文字列キーのシナリオが持たない R.id 参照を要する)ではありません。 待機は固定スリープではなく device.wait(Until.…) を使い、iOS と web の出力先と同じ決定性の切り分けを取ります。

以下は骨組みであり、生成物そのものではありません。実際のファイルでは各ヘルパの上に一段落の説明が 入りますが、ここでは残す価値のある箇所だけを短い行内コメントに縮めています。さらに、いくつかの ヘルパは丸ごと省いてあります。下で呼ばれているのに定義がどこにもない関数、すなわち uiAutomation()accessibilityWindows()windowSummary() があるのは、そのためです。区切りながら待つ処理の gone 側の 半分にあたる waitGone()awaitGone() は、呼び出す箇所ごと省いてあるので定義も載りません。エミッタの実際の出力は、 チェックイン済みの CodegenAndroidUITest.kt をご覧ください。

// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.SystemClock
import android.util.Log
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runner.RunWith
import java.io.File
import java.util.regex.Pattern

private const val PACKAGE = "com.example.app"
private const val LAUNCH_TIMEOUT_MS = 20000L
private const val LAUNCH_ATTEMPTS = 2
private const val ACT_TIMEOUT_MS = 15000L
private const val TRACKING_KICK_ATTEMPTS = 3
private const val CACHE_REREAD_SLICE_MS = 500L
private const val DIAGNOSTICS_DIR = "codegen-diagnostics"
private const val ADDITIONAL_OUTPUT_ARG = "additionalTestOutputDir"
private const val LOG_TAG = "BajutsuCodegen"

@RunWith(AndroidJUnit4::class)
class ComponentsUITest {
  private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

  @get:Rule
  val diagnostics: TestRule = object : TestWatcher() {
    override fun failed(error: Throwable, description: Description) {
      // ?: because methodName is a platform type — see the generated test's comment
      dumpDiagnostics(description.methodName ?: description.displayName)
    }
  }

  private fun byId(id: String) =
    By.res(Pattern.compile("(.*:id/)?" + Pattern.quote(id)))

  private fun kickWindowTracking(reason: String) {
    Log.w(LOG_TAG, "kicking accessibility window tracking with pressHome(): $reason")
    runCatching {
      if (!device.pressHome()) Log.w(LOG_TAG, "pressHome produced no window event")
    }.onFailure { Log.w(LOG_TAG, "pressHome failed", it) }
  }

  // Never throws: getWindows() raises IllegalStateException when the connection is not established
  private fun reportsWindows(): Boolean = runCatching {
    accessibilityWindows().isNotEmpty()
  }.getOrElse { Log.w(LOG_TAG, "could not read the window list", it); false }

  private fun ensureWindowTracking() {
    for (attempt in 1..TRACKING_KICK_ATTEMPTS) {
      if (reportsWindows()) return
      kickWindowTracking("no accessibility windows reported (pre-launch kick $attempt)")
    }
    // The last kick would otherwise go unchecked. This reads once more only so a failure
    // leaves a line — a recovery is silent, and shows as a kick with no failure line after
    // it. launch() is tried either way, since nothing here is reported back to it and
    // starting the activity is the stronger stimulus.
    if (!reportsWindows()) {
      Log.w(
        LOG_TAG,
        "no usable window list after $TRACKING_KICK_ATTEMPTS kick(s); trying launch" +
          " anyway; windows:\n" + windowSummary()
      )
    }
  }

  private fun launch(extras: Map<String, String>) {
    val context = ApplicationProvider.getApplicationContext<Context>()
    for (attempt in 1..LAUNCH_ATTEMPTS) {
      ensureWindowTracking()
      val intent = context.packageManager.getLaunchIntentForPackage(PACKAGE)!!
        .apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }
      for ((k, v) in extras) intent.putExtra(k, v)
      context.startActivity(intent)
      val by = By.pkg(PACKAGE).depth(0)
      if (waitPresent(by, LAUNCH_TIMEOUT_MS)) {
        device.waitForIdle(LAUNCH_TIMEOUT_MS)   // let the first frame settle
        return
      }
      Log.w(LOG_TAG, "launch attempt $attempt saw no $PACKAGE window in "
        + "${LAUNCH_TIMEOUT_MS}ms; windows:\n" + windowSummary())
      if (attempt < LAUNCH_ATTEMPTS) {   // HOME after the last one would overwrite the evidence
        kickWindowTracking("launch attempt $attempt timed out")
      }
    }
    throw AssertionError(
      "launch: no $PACKAGE window in the accessibility tree after $LAUNCH_ATTEMPTS attempt(s) " +
        "of ${LAUNCH_TIMEOUT_MS}ms; windows:\n" + windowSummary()
    )
  }

  private fun act(by: BySelector): UiObject2 {
    if (!waitPresent(by, ACT_TIMEOUT_MS)) {
      throw AssertionError(
        "act: no element matched $by within ${ACT_TIMEOUT_MS}ms; windows:\n" + windowSummary()
      )
    }
    return device.findObject(by)
  }

  // 例外を出しません。接続が確立していないと clearCache() が送出しますが、それこそがここで
  // 回復しようとしている故障そのものです。UiAutomation.clearCache() は API 34 からです。
  private fun clearAccessibilityCache() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
    try {
      uiAutomation().clearCache()
    } catch (e: RuntimeException) {
      Log.w(LOG_TAG, "could not clear the accessibility cache", e)
    }
  }

  private fun waitSliced(timeoutMs: Long, poll: (Long) -> Boolean): Boolean {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
      return poll(timeoutMs)   // API 34 未満には手段がないので、区切っても何も変わりません
    }
    val deadline = SystemClock.uptimeMillis() + timeoutMs
    while (true) {
      // 先に読み、そのあとで期限を見ます。待機時間が 0 ミリ秒でも 1 回は木を読むためです
      val remaining = deadline - SystemClock.uptimeMillis()
      if (poll(remaining.coerceIn(0L, CACHE_REREAD_SLICE_MS))) return true
      if (SystemClock.uptimeMillis() >= deadline) return false
      clearAccessibilityCache()
    }
  }

  private fun waitPresent(by: BySelector, timeoutMs: Long): Boolean =
    waitSliced(timeoutMs) { device.wait(Until.hasObject(by), it) }

  private fun awaitPresent(by: BySelector, timeoutMs: Long) {
    if (waitPresent(by, timeoutMs)) return
    throw AssertionError(
      "wait: no element matched $by within ${timeoutMs}ms; windows:\n" + windowSummary()
    )
  }

  @Test
  fun test_open_filter_shows_the_sheet() {
    val extras = mutableMapOf<String, String>()
    extras["SHOWCASE_UITEST"] = "1"
    launch(extras)

    assertFalse(device.hasObject(byId("log.sheet.title")))
    act(byId("log.openFilter")).click()

    // expect
    assertTrue(device.hasObject(byId("log.sheet.title")))
  }
}
  • ヘルパ byId は、アプリが id に <package>:id/ の接頭辞を付けているかどうかによらずローカル id に一致させます。 これは adb ドライバがその接頭辞を剥がす処理の逆向き なので、ネイティブの android:id と、接頭辞を持たない Compose の testTagtestTagsAsResourceId で露出したもの)の両方が解決されます。
  • ヘルパ act は、要素を返す前にその出現を待ちます。findObject 単体は暗黙の待機を持たない単発クエリです。 launch() の直後や画面遷移の直後に操作すると、描画に先行してしまう恐れがあります。下表の生成アクションのうち、要素を 対象とするものはすべてこのヘルパを経由します。wait の各ステップは、操作対象の要素ではなく条件を対象とするので、 代わりに awaitPresentawaitGone を経由します(relaunch は直接呼びます)。待機がタイムアウトすると、 act と 2 つの await… ヘルパは、素の NullPointerException や無記名の AssertionError ではなく、 セレクタとタイムアウト値を名指しした AssertionError を送出します。relaunch が生成する launch(extras) は、セレクタではなくパッケージと試行回数の上限を名指しして失敗します。 読み取り専用のアサーションは、従来どおり device.findObject / device.findObjects を直接呼び、待機しません。これはドライバ自身のアサーションと同じ挙動です。
  • 各メソッドは extras マップ(config の launchEnv < シナリオの preconditions.launchEnv)を組み立て、 launch(extras) を呼びます。この関数は env を intent extra として渡します。adb backend の am start --es の 逆向きです。
  • launch はアプリの最初のウィンドウを待ってから、そのウィンドウの描画が落ち着くまで待ちます (device.waitForIdle)。ウィンドウ待機だけでは「パッケージの何らかのウィンドウが存在する」ことしか 確認できません。最初のフレームが描画を終えたことまでは保証しません。CI ランナーが混雑していると、直後の act() が描画の途中の画面と競合することがあります。waitForIdle はこの隙間を埋め、テスト自身の各アクション の待機時間が始まる前に描画を落ち着かせます。この待機は成功時の経路に置きます。ウィンドウが見つからなかった 待機のあとに実行しても、落ち着かせる対象がないからです。
  • launch はウィンドウ待機の結果を検査し、一致しなければ intent を再送します。 セレクタの照合先は アクセシビリティツリーです。この待機は、そのツリーにアプリのウィンドウが現れたことを確かめます。ツリーに 現れないウィンドウは最初のフレームの描画が遅いのとは違うので、同じ起動を長く待っても回復しません。待機の 結果を捨てると、起動が成立しなかったまま最初の act へ抜け落ち、アプリが到達していない画面に対して act がタイムアウトします。失敗が報告するのは起動ではなくセレクタになってしまいます。
  • 各試行は LAUNCH_TIMEOUT_MS を丸ごと待ちます。 単に起動が遅いだけの場合は、再起動せずに待ち切る ためです。FLAG_ACTIVITY_CLEAR_TASK は Activity を破棄するので、1回の試行の上限を短くすると、まだ 起動途中のアプリを最初からやり直させてしまいます。それを繰り返すと、1回の長い待機なら間に合った起動を 枯渇させかねません。上限いっぱいウィンドウが現れなかった場合だけを、遅いのではなく詰まったものとして扱います。 LAUNCH_ATTEMPTS 回の試行を終えると、launch は実際に見えていたウィンドウを名指しして失敗します。 intent は試行ごとに組み立て直します。これにより launchEnvonCreate に届き続けます。既存のタスクを 再開する形にすると、extra は onNewIntent に回ってしまいます。
  • wait は待機時間を複数回の読み取りに分け、その合間にキャッシュを破棄します。 理由は2つあります。 1つは、hasObjectfindObjectUntil の条件が、接続ごとに保持される AccessibilityNodeInfo のキャッシュを通して解決されることです。もう1つは、このキャッシュを 無効化する手段がアクセシビリティイベントしかないことです。したがってイベントが1つ落ちると、 読み取りは遅れるのではなく固定されます。1回の device.wait は、何度ポーリングしても同じ古い ツリーを読み直します。そして、待機が始まる前に変わっていた画面に対してタイムアウトします。自力では 変わらない読み取りは、待機時間を延ばしても回復しません。上のウィンドウ一覧が詰まる場合と同じ形です。 そこで waitSliced は 1 回の device.waitCACHE_REREAD_SLICE_MS で区切り、区切りの合間に clearAccessibilityCache を呼びます。区切りは呼び出し側の待機時間を分け合うだけで、延長はしません。 各区切りは、期限を見る前にまず読みます。timeout: 0 のステップでも木を 1 回は読むためです。 device.wait(condition, 0) 自体も、そのように振る舞います。 条件が成立した瞬間に返るので、健全な待機の所要時間はこれまでと変わりません。増える費用は毎秒数回の キャッシュ破棄だけです。固定スリープではなく、あくまで条件待機のままです。

キャッシュの破棄は API 34 以降に限ります。破棄する手段は、そこで初めて用意されました。 androidx.test.uiautomator 2.3.0 は AccessibilityInteractionClient#clearCache() に リフレクションで到達します。ただし API 32 を超えると諦め、clearCache() reflection is not available on API >= 33 と記録するだけになります。置き換えとして用意された UiAutomation.clearCache() が入ったのは API 34 です。それより前では、waitSliced は 1 回の device.wait で待機時間を使い切ります。区切ったところで、何も変わらない読み取りが増えるだけです。

API 34 のエミュレータで動く CI が、まさにこの形で失敗しました。Until.gone は、絞り込みで消えた はずの行を 5 秒間ポーリングし続けました。その失敗の 100 ミリ秒後に TestWatcher ルールが記録した スクリーンショットと階層のダンプは、どちらもその行がすでに消えていて、画面がアサーションの期待どおりの 状態になっていたことを示していました。

  • どちらの失敗も、照合先のウィンドウを名指しします。 原因は2通りあります。id がまだ描画されていないか、 アプリのウィンドウがツリーに存在しないかです。「no element matched」だけでは区別できず、対処は正反対です。
  • アクセシビリティツリーにウィンドウが現れない起動には、待機を延ばすのではなくウィンドウ変更を 起こして再試行します。 UI Automator がセレクタを照合する先は、アクセシビリティ機構が報告する ウィンドウの一覧です。ですから、その一覧にウィンドウが載っていないアプリは、どれだけ待っても 到達できません。そこで kickWindowTracking が HOME キーを押し、そのうえで intent が再送されます。 HOME はアクセシビリティ機構ではなく入力の経路で配送されるので、一覧が今どうなっていようと届き、 フォーカスを握っているものを片付けます。キー入力自体が、それによって発生するイベントを待つので、 この回復に sleep は要りません。このウィンドウ変更の回数は LAUNCH_ATTEMPTS より1つ少ない値までで、 生成される値の2なら1度です。ただし、1回の起動で起こすウィンドウ変更はこれだけではありません。次の 箇条書きにある起動前の確認が、試行ごとに TRACKING_KICK_ATTEMPTS の予算を別に持つので、launch 1回あたりの最悪値は LAUNCH_ATTEMPTS × TRACKING_KICK_ATTEMPTS + (LAUNCH_ATTEMPTS - 1) 回、 生成される値では7回です。ただしこの回数に届くのは、起動前の確認のたびに一覧が空だった端末だけです。 起動前の確認は、予算を使い切っても次の箇条書きにあるとおり記録するだけなので、試行ごとに3回まで 押しうるからです。下の実行例のように一覧が生きている端末なら、押すのは1回にとどまります。 なお pressHome は、イベントが届かなかったことを例外ではなく false の返り値で報告するので、 どちらの結果も記録されます。 この処理は UiAutomation.executeAndWaitForEvent の外に置きます。pressHome がすでに同じ呼び出しで 待機しており、入れ子にすると外側の待機が見ているイベントキューを内側が空にしてしまうためです。
  • ウィンドウが現れない道筋は2つあり、どちらも CI で観測されていて、対処は共通です。 1つめは一覧に 何も入っていない場合で、各試行の前に一覧を読む ensureWindowTracking が捉えます。ある実行では no accessibility windows reported と記録され、ウィンドウ変更で回復しました。2つめは この確認では見えません。一覧は生きていて、アプリのウィンドウだけが載っていないからです。ですから 試行が失敗した後のウィンドウ変更は、一覧を読まずに起こします。下の実行例が必要としたのは、 この一覧を読まないウィンドウ変更でした。事前に一覧を読む価値は、1つめの場合をキー入力1回で 片付け、起動の待機時間を丸ごと使わずに済ませられる点にあります。

起動前の予算を使い切っても一覧が空のままなら、AssertionError を投げずに、記録するだけに とどめます。アプリがまだ起動していない時点で押す HOME は、実際に届く先がランチャーですから、 この生成コードが打てる手のなかではいちばん弱い刺激です。一方、アクティビティを実際に起動すれば、 ウィンドウそのものが増えます。ここで AssertionError を投げてしまうと、弱い刺激だけに ウィンドウ変更の予算を使い切り、強い刺激をいちばん必要としている端末に対して、 startActivity を一度も呼ばないまま中断してしまいます。アクティビティの起動でも 回復しない場合は、launch 自身の再試行が ウィンドウ一覧を添えて失敗を報告します。 - 最後の試行では、待機が尽きた後にウィンドウ変更を起こしません。 その後に再送する intent はもう ありませんし、HOME を押すと、これから集める証跡がすべて上書きされてしまいます。AssertionError 自身のウィンドウ一覧も、階層のダンプも、スクリーンショットも、ランチャーを写したものになります。 健全なランチャーのウィンドウ一覧は、証跡が説明しようとしている失敗とは正反対のことを語って しまいます。なお、起動前の確認 ensureWindowTracking は、最後の試行でも HOME を押しますが、そちらは 起動をやり直す前なので、証跡は失われません。

この失敗を特定したのは、Gradle がテストごとに収集する logcat です。CI は下の証跡と一緒に、これも アップロードします。決め手になったのは、試行ごとのウィンドウ一覧の記録でした。ある実行では、20秒の起動待機の 終わりにこう記録されていました。

W BajutsuCodegen: launch attempt 1 saw no com.bajutsu.showcase.android.compose window in 20000ms; windows:
W BajutsuCodegen: root=com.android.systemui AccessibilityWindowInfo[title=null, type=TYPE_SYSTEM, layer=1, …]
W BajutsuCodegen: root=android AccessibilityWindowInfo[title=Pixel Launcher isn't responding, type=TYPE_SYSTEM, focused=true, active=true, …]
W BajutsuCodegen: kicking accessibility window tracking with pressHome(): launch attempt 1 timed out

一覧は生きており、内容も正しいものでした。2つのウィンドウがあり、うち1つは待機中に現れた、 フォーカスを持つ「応答なし」ダイアログです。載っていなかったのはアプリ自身のウィンドウでした。 しかも ActivityTaskManager がその Activity を Displayed と報告してから19秒後の時点です。 フォーカスを持つシステムウィンドウは、UiAutomation が報告する内容からアプリのウィンドウを 外してしまいます。 アプリは描画されて前面にあるのに、どのセレクタも、アプリのウィンドウが 載っていない一覧を照合先にするのです。HOME でダイアログが片付き、2回目の試行が立ち上がって、テストは通りました。

この事実は、それ以前の証跡も説明します。以前の証跡は、経路そのものが報告をやめたことを示唆して いました。実行 30899952762 では、同じジョブの CI 再実行が3回とも失敗しました(launch 自身の 2回の試行とは別です)。いずれも Activity は RESUMEDDisplayed の両方に達していながら、 20秒ほどで153回、168回、171回の照合を重ねています。また7回の実行を通して、 通った実行はいずれも起動中の一時的な null ルート(Active window root not foundSkipping null root node for window)を10〜24回の照合のうち2〜7回記録し、失敗した実行は1回も 記録しませんでした。アプリのウィンドウが載っていないという事実だけで、経路の凍結を持ち出さずに この相関を説明できます。アプリのウィンドウが一覧に加わらないのであれば、UiDevice が観測できる起動の 遷移も存在せず、生きた起動が生む変化はどれも現れないからです。待機の上限を5秒から15秒、さらに20秒へ 引き上げても何も変わらなかったのは、これと同じ理由です。一覧に加わらないウィンドウは、待っても加わりません。

失敗時の証跡

待機のタイムアウトが伝えるのは、何も一致しなかったという事実だけです。1回失敗して再実行すると通るような 実行は、それだけでは原因を突き止められません。そこで生成テストは JUnit の TestWatcher ルールを備え、 失敗時に codegen-diagnostics ディレクトリへ次の3つの証跡を書き出します。

ファイル 判別できること
<test>-windows.txt 各アクセシビリティウィンドウとそのルートのパッケージ、および By.res が現に一致させられる id の一覧。「アプリのウィンドウが無い」と「id が無い」を切り分けられます
<test>-hierarchy.xml device.dumpWindowHierarchy によるツリー全体。各ノードのクラス、テキスト、境界、パッケージを含みます
<test>-screen.png device.takeScreenshot による、実際に画面に出ていた内容

ウィンドウの一覧は BajutsuCodegen タグで logcat にも出します。logcat は Gradle がテストごとに収集するので、 ディレクトリが回収されなかった場合でも一覧は残ります。

このディレクトリは、Android Gradle Plugin が additionalTestOutputDir という instrumentation 引数で渡す パスの中に置きます。Gradle Plugin は実行後にそのパスをデバイスから build/outputs/connected_android_test_additional_output/ へ回収します。アプリ自身の external files ディレクトリでは証跡がデバイスに取り残されます。Android 11 以降、adb/sdcard/Android/data/<package> を読めないからです。Gradle を介さない実行ではこの引数が渡らないため、 アプリの external files ディレクトリへ書き出します。デバイスに残る出力でも、何も無いよりは役に立ちます。

3つの書き出しは互いに独立に行います。階層ダンプが例外を投げても、スクリーンショットを失うわけには いかないからです。例外を黙って飲み込むこともしません。成果物がただ存在しないだけでは、失敗を説明する ためのパスで何も説明できないので、ファイル名と理由を logcat に出します。device.takeScreenshot は 失敗を例外ではなく false の返り値で伝えるため、生成コードはそれを例外に変えて同じログに載せます。 このリポジトリでは android-e2e.ymluiautomator (codegen) ジョブが、 Gradle 自身のレポートとあわせて回収済みのディレクトリをアップロードします。

セレクタのマッピング(UI Automator)

単一フィールド のセレクタだけが BySelector に対応します。複合セレクタ(traitswithinindex、または 複数フィールドの組み合わせ)は、忠実な単一セレクタの形が無いため、制約を落とした広すぎる一致にせず // TODO の まま残します。

Selector フィールド UI Automator
id byId('…')By.res(Pattern.compile("(.*:id/)?" + Pattern.quote(id)))
label By.text('…')
value By.desc('…')(ドライバが読む content-desc のチャネル)
idMatches(fnmatch glob) By.res(Pattern.compile('…'))。glob は全文一致なので Pattern の全文一致で忠実(*.*?.[…] クラスは // TODO
labelMatches(メタ文字なし) By.textContains('…')。DSL の re.search に合う部分文字列。実正規表現は // TODO(下記参照)
traits / within / index / 複合 // TODO

labelMatches は Python の re.search(部分一致)のパターンですが、UI Automator の By.text(Pattern)全文一致を要求します。そのため、メタ文字を含まないパターン(単純な部分文字列)だけが忠実にマッピングでき (By.textContains 経由)、実正規表現には忠実な単一セレクタの形がありません(XCUITest エミッタが NSPredicate の MATCHES で突き当たるのと同じ限界)。この場合は // TODO にとどめます。

アクションのマッピング(UI Automator)

シナリオ要素 UI Automator
tap act(<by>).click()
typeinto あり) act(<by>).text = '…'
typeinto なし) // TODO(解決対象の要素が無い)
longPress .longClick()(プラットフォームの長押しタイムアウトを使い、シナリオの duration はパラメータが無い)
clear act(<by>).clear()。ドライバ自身の clear(BE-0265)を忠実に再現します
delete { count } .click() + device.pressKeyCode(KeyEvent.KEYCODE_DEL)count 回(BE-0265)
select .click() + device.pressKeyCode(KeyEvent.KEYCODE_A, KeyEvent.META_CTRL_ON)(BE-0265)
copy device.pressKeyCode(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON)
back device.pressBack()。UI Automator ネイティブのシステム戻る操作で、adb ドライバの keyevent 4 に対応します(BE-0210)
swipe { on, direction } .swipe(Direction.<UP/DOWN/LEFT/RIGHT>, 0.75f)
swipe { from, to } // TODO(座標スワイプは生成しない)
drag { on, direction } swipe { on, direction } と同じ基本操作です。UiObject2.swipe は実際のドラッグなので、要素起点の drag は Android でもスクロールとハンドル移動の両方を兼ねます(BE-0227)
scroll { to } UiScrollable(UiSelector().scrollable(true)).<setAsHorizontalList/setAsVerticalList>().setMaxSearchSwipes(<max>).scrollIntoView(<selector>)。UI Automator ネイティブの要素までスクロールする機能で、maxScrolls で上限を付けます(BE-0326)
pinch .pinchOpen(0.5f) / .pinchClose(0.5f)(scale ≥ 1 で拡大)
wait { for } awaitPresent(<by>, <ms>L)(区切りながら待つ device.wait(Until.hasObject(…))。失敗時はセレクタを名指しします)
wait { until: gone } awaitGone(<by>, <ms>L)(同じものを Until.gone に対して行います)
wait { until: screenChanged/settled } device.waitForIdle(<ms>L)findObject は auto-wait しないので、裸コメントではなく実際の条件待機にする
relaunch launch(extras)(起動 intent を再発行)
doubleTap / rotate // TODO(対応する UI Automator ジェスチャが無い)
handleSystemAlert // TODO(iOS 専用。Android ではシステムダイアログを直接 tap する)

アサーションのマッピング(UI Automator)

アサーション UI Automator
exists assertTrue(device.hasObject(<by>))negateassertFalse
value (equals/contains/matches) assertEquals/…(… , device.findObject(<by>).contentDescription)
label (equals/contains/matches) 同じものを .text に対して(matches.contains(Regex('…'))
enabled / disabled assertTrue/False(device.findObject(<by>).isEnabled)
selected assertTrue(device.findObject(<by>).isSelected)
count (equals/atLeast/atMost) device.findObjects(<by>).sizeassertEquals / assertTrue(size >= n) / assertTrue(size <= n)

adb backend にはネットワーク傍受の面が無いので、ネットワークのアサーション(request / requestSequence / responseSchema)とデバイス制御の一群(setLocation / push / setClipboard など)はすべて、理由を明記した // TODO を出力します。XCUITest 出力先が自身のギャップに対して行うのと同じです。

名前の生成

  • メソッド名は test_<sanitized> です(シナリオ名を [^0-9a-zA-Z]+_ に正規化し、数字始まりなら _ を前置)。 XCUITest と UI Automator の出力先で共通です。
  • クラス名は stem を Title ケースにして接尾辞を付けたものです。XCUITest は UITests、UI Automator は UITest を 付加します。CLI はこれを、出力ファイル名(-o の stem)から、なければシナリオファイル名から決めます。

実コンパイル検証

ユニットテストは出力ソースを文字列として検査するだけで、生成ファイルがビルドできるか、参照している API が 固定した SDK バージョンに存在するかは証明しません。各デバイスターゲットは、CI が再生成・コンパイル・実機実行 するチェックイン済みのフィクスチャで、この隙間を埋めます。

  • XCUITestmake -C demos/showcase ui-test がシナリオから ComponentsUITests.swift を再生成し、 xcodebuild test で iOS レーン(必須チェック)に対して実行します。
  • Playwrightmake -C demos/web codegen-e2escenarios/smoke.yaml から codegen/smoke.spec.ts を再生成し、実物の Chromium に対して実物の @playwright/test ランナーで 実行します(BE-0293)。CI で安定を証明したため、web レーンの必須チェック(web-e2e.ymlcodegen (playwright))です。
  • UI Automatormake -C demos/showcase/android e2e-codegencodegen_android.yaml から CodegenAndroidUITest.kt を再生成し、Gradle の connectedAndroidTest で起動済みエミュレータに対して Android レーン(BE-0294)で実行します。まずはゲート対象外のシグナルとして着地させ、安定後に必須化します。 ビルド前にチェックイン済みの .kt を再生成するので、古いチェックインがエミッタや androidx.test.uiautomator API のドリフトを覆い隠すことはありません。

どちらもテスト時に bajutsu ランタイムも、私たちのドライバも、AI も使いません。下流のチームが実行するのと 同じ codegen の出力経路です。実走は showcase を参照してください。