コンテンツにスキップ

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()
longPress .press(forDuration: <sec>)
typeinto あり) el(id).tap() + .typeText(...)
typeinto なし) app.typeText(...)
swipe { on, direction } .swipeUp/Down/Left/Right()
swipe { from, to } coord(x1, y1).press(forDuration: 0.1, thenDragTo: coord(x2, y2))XCUICoordinate のドラッグ。BE-0025)
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、ネットワークの request アサーション、未知の trait、および Playwright 出力先での座標スワイプ)は、失敗させずに // TODO 行を出力します。デバイス制御ステップはレビュー担当が実行する simctl コマンド名を明記します。 出力は常にレビューでき、生成結果を壊しません。生成ファイルの先頭にも「手で編集せず再生成せよ」と明記します。 これはどの出力先にも共通です。

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 アサーションはテストのタイムアウトまで再試行します)。XCUITest 側の切り分けと同じ構図です。実行時には idb が座標タップする一方、生成された XCUITest は 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:) に 対して取るのと同じ扱いです。

セレクタのマッピング(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> })
swipe { on, direction } 要素中心からその方向への page.mouse ドラッグ
swipe { from, to } // TODO(座標スワイプは生成しない)
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 は駆動しない)

アサーションのマッピング(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() を比較

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 の出力先と同じ決定性の切り分けを取ります。

// Generated by bajutsu — do not edit by hand. Re-generate with `bajutsu codegen`.
import android.content.Context
import android.content.Intent
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.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.regex.Pattern

private const val PACKAGE = "com.example.app"
private const val LAUNCH_TIMEOUT_MS = 5000L

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

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

  private fun launch(extras: Map<String, String>) {
    val context = ApplicationProvider.getApplicationContext<Context>()
    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)
    device.wait(Until.hasObject(By.pkg(PACKAGE).depth(0)), LAUNCH_TIMEOUT_MS)
  }

  @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")))
    device.findObject(byId("log.openFilter")).click()

    // expect
    assertTrue(device.hasObject(byId("log.sheet.title")))
  }
}
  • ヘルパ byId は、アプリが id に <package>:id/ の接頭辞を付けているかどうかによらずローカル id に一致させます。 これは adb ドライバがその接頭辞を剥がす処理の逆向き なので、ネイティブの android:id と、接頭辞を持たない Compose の testTagtestTagsAsResourceId で露出したもの)の両方が解決されます。
  • 各メソッドは extras マップ(config の launchEnv < シナリオの preconditions.launchEnv)を組み立て、 launch(extras) を呼びます。この関数は env を intent extra として渡します。adb backend の am start --es の 逆向きです。

セレクタのマッピング(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 device.findObject(<by>).click()
typeinto あり) device.findObject(<by>).text = '…'
typeinto なし) // TODO(解決対象の要素が無い)
longPress .longClick()(プラットフォームの長押しタイムアウトを使い、シナリオの duration はパラメータが無い)
swipe { on, direction } .swipe(Direction.<UP/DOWN/LEFT/RIGHT>, 0.75f)
swipe { from, to } // TODO(座標スワイプは生成しない)
pinch .pinchOpen(0.5f) / .pinchClose(0.5f)(scale ≥ 1 で拡大)
wait { for } assertTrue(device.wait(Until.hasObject(<by>), <ms>L))
wait { until: gone } assertTrue(device.wait(Until.gone(<by>), <ms>L))
wait { until: screenChanged/settled } device.waitForIdle(<ms>L)findObject は auto-wait しないので、裸コメントではなく実際の条件待機にする
relaunch launch(extras)(起動 intent を再発行)
doubleTap / rotate // TODO(対応する UI Automator ジェスチャが無い)

アサーションのマッピング(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)から、なければシナリオファイル名から決めます。

showcase での実走(make -C demos/showcase ui-test)は showcase にあります。