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>]
--emit は xcuitest(既定)、playwright、uiautomator のいずれかです。-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 は上記のヘルパをそのまま使います。複合セレクタ
(value、traits、index、または複数フィールドの組み合わせ)は、// 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 のクエリはツリーベースで幾何的ではありません。- 未知の trait:
button/link/notEnabled/selectedの語彙の外。
マッピング表¶
| シナリオ要素 | 生成される XCUITest |
|---|---|
tap |
el(id).tap() / byLabel(...).tap() |
longPress |
.press(forDuration: <sec>) |
type(into あり) |
el(id).tap() + .typeText(...) |
type(into なし) |
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)(negate で XCTAssertFalse) |
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.gotoがlaunchに相当)へ遷移します。config のlaunchEnv< シナリオのpreconditions.launchEnvはpage.addInitScript(() => localStorage.setItem(...))で シードします。別のチャネル(クエリパラメータやクッキー)を期待するアプリには// TODOを出します。 - 待機はすべて Playwright の自動待機を使います。出力する固定の時間は ジェスチャの長さ(
longPressのdelay、方向スワイプのドラッグ)だけで、これはジェスチャ固有のものです。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() |
type(into あり) |
await loc.fill('…') |
type(into なし) |
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 を通して見ます。そのため生成テストは、ドライバ自身によるツリーの読み取りを忠実に逆向きにした
ものになります。UiDevice と UiObject2 を駆動し 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 のtestTag(testTagsAsResourceIdで露出したもの)の両方が解決されます。 - 各メソッドは
extrasマップ(config のlaunchEnv< シナリオのpreconditions.launchEnv)を組み立て、launch(extras)を呼びます。この関数は env を intent extra として渡します。adb backend のam start --esの 逆向きです。
セレクタのマッピング(UI Automator)¶
単一フィールド のセレクタだけが BySelector に対応します。複合セレクタ(traits、within、index、または
複数フィールドの組み合わせ)は、忠実な単一セレクタの形が無いため、制約を落とした広すぎる一致にせず // 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() |
type(into あり) |
device.findObject(<by>).text = '…' |
type(into なし) |
// 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>))(negate で assertFalse) |
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>).size を assertEquals / 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 にあります。