この記事は 2026 年 10 月時点の情報です。Maestro CLI 2.8.0、Xcode 26.6、iOS シミュレータで確認しています。公式ドキュメントの記述は本文中にリンクを付けています。
はじめに
Maestro の台本(Flow)は、手元で 1 回通すだけなら簡単に書けます。難しいのは、CI で毎回通すことです。
前回の記事(Maestro で iOS の E2E テストを運用して困ったこと 7 つと対策)では、つまずいた現象を 1 つずつ書きました。この記事はその続きで、「だから台本をどう設計するか」を 6 つにまとめます。
Maestro の仕組み(XCUITest のランナーを通して操作する、アクセシビリティ情報で要素を探す)は、1 本目の記事(AIにモバイルアプリの動作確認をさせるなら、毎回操作させるより Maestro の台本を書かせよう)に書いています。
対象は、オンボーディングやツールチップ、ログイン画面の割り込みがある、ふつうのアプリです。台本の例は説明用に単純化しています。
1. 本体とサブフローを分ける
台本は 2 種類に分けます。
- 本体(flows/): 「何を確かめるか」を書く。画面ごと、または目的ごとに 1 ファイル
- サブフロー(subflows/): 「繰り返す操作」を書く。ログイン、オンボーディングの通過、割り込みの閉じ方など
.maestro/
├── flows/
│ └── tab_navigation.yaml # 主要画面を一通り開けるか
└── subflows/
├── complete_onboarding.yaml # 初回起動の画面を通過する
├── dismiss_tooltip.yaml # ツールチップが出ていれば閉じる
└── dismiss_login_sheet.yaml # ログイン画面が出ていれば閉じる
本体からは runFlow で呼びます。パスは呼ぶ側のファイルからの相対パスです。
- runFlow: ../subflows/complete_onboarding.yaml
公式ドキュメントも、subflows/ や common/ を分ける構成と、「1 つのサブフローは 1 つの仕事だけ(Keep flows atomic)」を勧めています(Nested flows)。
関数を切り出すのと同じ理由です。オンボーディングの画面が 1 つ増えたとき、直す場所が 1 ファイルで済みます。
リポジトリに残す台本と、残さない台本
AI に台本を書かせる運用をすると、「台本が差分としてどんどん増えていくのではないか」という疑問が出ます。これは置き場所を分けることで解決します。
-
残す台本: 主要画面の巡回のように、変更のたびに毎回動かすもの。
.maestro/にコミットして、人が資産として管理します - 残さない台本: 「この変更で、この画面がこう変わったか」を確かめる、その変更のためだけの台本。リポジトリの外(一時フォルダなど)に書き出して動かし、コミットしません
台本は YAML のテキストファイルなので、Maestro はどこに置いてあっても maestro test <パス> で動かせます。リポジトリの中に置くかどうかは、Maestro の都合ではなく運用の判断です。AI が書いた台本を毎回 .maestro/ に入れると、使い捨ての台本がたまり、どれが資産なのか分からなくなります。
2. 初回起動の通過はサブフローにして、出たときだけ通す
アプリを初期状態から起動すると、オンボーディングが出ます。2 回目以降は出ません。台本は、どちらの状態からでも動く必要があります。
そこで、オンボーディングを通過する操作をサブフローにまとめ、ウェルカム画面が見えているときだけ呼びます。
# 起動直後は「ウェルカム画面」か「ホーム」のどちらかに落ち着くまで待つ
- extendedWaitUntil:
visible: "(はじめる|今日のおすすめ)"
timeout: 30000
# ウェルカム画面ならオンボーディングを通過する。ホームなら何もしない
- runFlow:
when:
visible: "はじめる"
file: ../subflows/complete_onboarding.yaml
2 つの道具を使っています。
-
when: visible: 要素が見えているときだけ実行する条件です。公式ドキュメントも、用途の例として「オンボーディング画面や許可ダイアログのような動的な UI」を挙げています(Conditions) -
(はじめる|今日のおすすめ): Maestro の文字の指定は正規表現として扱われるので、|で「どちらか」が書けます。「どちらの画面に落ち着いてもよい」という待ち方です。ホーム側は、タブバーの「ホーム」ではなく、ホーム画面にしかない文字を選んでいます(理由は 5 番で書きます)
extendedWaitUntil を使うのは、待ち時間を自分で決めるためです(extendedWaitUntil)。初回起動は時間がかかるので、長めに待ちます。なお、assertVisible の既定の待ち時間は、公式ドキュメントでは 7 秒と書かれていますが、手元の 2.8.0 のソースでは 17 秒でした。既定値に頼らず、長く待つ所は明示するのが安全です。
公式ドキュメントには「条件分岐を多用すると台本が読みにくくなる。大きく違うシナリオは別の Flow にする」という注意があります。「初回起動かどうか」のように、同じ検証の入口が 2 つあるだけの場合に使い、「ログイン済みと未ログインで確かめる内容が違う」なら台本を分けます。
3. 割り込みを閉じる処理はサブフローにして、操作の前後に挟む
ツールチップ、キャンペーンの案内、ログインを促す画面。いつ出るか分からない表示は、どのアプリにもあります。
これらは「出ていれば閉じる、出ていなければ何もしない」サブフローにします。
# subflows/dismiss_tooltip.yaml
appId: com.example.app
---
- runFlow:
when:
visible: "閉じる"
commands:
- tapOn: "閉じる"
本体では、割り込みが出そうな操作の前と後に挟みます。
- retry:
maxRetries: 2
commands:
- runFlow: ../subflows/dismiss_login_sheet.yaml # 前: 出ていたら閉じる
- tapOn: "履歴"
- runFlow: ../subflows/dismiss_login_sheet.yaml # 後: タップで出たら閉じる
- extendedWaitUntil:
visible: "編集"
timeout: 15000
後にも挟むのは、タップした瞬間に割り込みが出ることがあるからです。前だけ閉じても、タップで出た割り込みが次の確認を邪魔します。
代わりに、時間がかかります。when: visible は、要素が出ていないとき、すぐに「出ていない」と判断するわけではありません。出てくるのを少し待ってから諦めます(2.8.0 のソースでは最大 7 秒。直前の操作からの経過時間は差し引かれます)。閉じる処理を挟むほど、何も出ていないときの待ち時間が積み上がります。挟むのは、実際に割り込みが出る場所だけにします。
retry で包んでいるのは、確認の途中で割り込みが出た場合に、閉じる所からやり直すためです。maxRetries は 0〜3 の範囲で、既定は 1 です。公式ドキュメントは「台本の大部分を retry で包むのはアンチパターン。アプリの本当の不安定さを隠してしまう」と注意しています(retry)。包む範囲は「1 つの画面の操作と確認」までにとどめます。
iOS では、画面の上に重なる表示(オーバーレイ)が、下にある要素をアクセシビリティの階層から見えなくすることがあります。画面には見えていても、Maestro には「存在しない」扱いになります。前回の記事の 1 番と 5 番で書いた現象で、「前と後の両方で閉じる」はその対策です。
4. 台本の冒頭に「前提の状態」を書く
台本は、ある状態の端末を前提にしています。未ログインか、オンボーディング済みか、初期化直後か。この前提を、ファイルの冒頭にコメントで書きます。
# 主要 5 画面の巡回確認
#
# 前提:
# - アプリがインストール済み
# - 未ログイン状態
# - 初期化直後(オンボーディング未通過)でも、通過済みでも実行可
#
# 実行:
# maestro --device <UDID> test .maestro/flows/tab_navigation.yaml
appId: com.example.app
---
書く理由は、台本を呼ぶ側が端末を用意するからです。6 番で書くように、端末を初期化してから動かすのか、ログイン済みの端末のまま動かすのかは、台本の外で決まります。前提が書いてあれば、呼ぶ側はそれに合わせられます。書いていなければ、「手元では通るが CI では落ちる」の原因を、台本の中で探すことになります。
5. 画面の到着は「その画面にしかない要素」で待つ
タブを切り替えた直後、画面の中身はまだ作られている途中です。そこで「画面ができるまで待つ」のですが、何を待つかで台本の安定さが変わります。
# 悪い例: タブバーの文字は、どの画面でも見えている
- tapOn: "お気に入り"
- assertVisible: "お気に入り" # タブバー自身に一致して、常に成功する
# 良い例: その画面にしかない要素を待つ
- tapOn: "お気に入り"
- extendedWaitUntil:
visible: "お気に入りはまだありません"
timeout: 15000
悪い例は、画面が切り替わっていなくても成功します。タブバーの「お気に入り」の文字に一致するからです。これは「通ったが、何も確かめていない」台本です。
待つ要素は、見出し、空状態の文言、その画面だけにあるボタンなど、ほかの画面には出ないものを選びます。公式ドキュメントも「visible の指定は一意で安定したものにする」と書いています(Conditions)。
6. 始める前に、端末を決まった状態に戻す
ここまでの 5 つは台本の中の話でした。最後は台本の外、台本を動かす前の準備です。
CI で台本が落ちたとき、原因は 2 つに分かれます。アプリの変更が原因か、端末の状態が原因か。前回の実行でログインしたまま、前回のツールチップを閉じたまま、という「前回の残り」があると、同じ台本が通ったり落ちたりします。
そこで、始める前に端末を決まった状態に戻します。戻し方は 2 段階あります。
軽い方法: アプリの状態だけ消す
launchApp の clearState: true で、アプリのデータを消してから起動します(launchApp)。
- launchApp:
clearState: true
オンボーディングやログイン状態は、たいていアプリのデータに保存されているので、これで初回起動の状態に戻ります。まずはこれで十分なことが多いです。
重い方法: シミュレータごと初期化する
アプリの外にある状態(OS の許可ダイアログの履歴、キーチェーン、ほかのアプリ)まで戻したいときは、シミュレータを初期化します。
xcrun simctl shutdown "$UDID" # 起動中は erase できない
xcrun simctl erase "$UDID" # 工場出荷状態に戻す
xcrun simctl bootstatus "$UDID" -b # 起動して、起動完了まで待つ
xcrun simctl install "$UDID" "$APP_PATH"
maestro --device "$UDID" test .maestro/flows/tab_navigation.yaml
手元で確かめた点を 3 つ書きます。
-
erase は起動中だと失敗します。
Unable to erase contents and settings in current state: Bootedと出ます。先にshutdownが要ります -
起動完了を待ってからインストールします。
bootはコマンドが先に返るので、完了前にinstallすると、成功と出るのに実体が入らないことがあります。bootstatus -bは「起動していなければ起動し、完了まで待つ」ので、これ 1 つにまとめます(xcrun simctl help bootstatusに「You can safely call this before you attempt to start booting the device」とあります) -
erase すると言語設定も初期値に戻ります。 手元の日本語の Mac では、erase 直後の設定は Mac と同じ
ja-JPでした。英語の設定で動く CI では、シミュレータが英語になり、日本語の文字で書いた台本が全部落ちました。OS の通知許可ダイアログの文言も変わります
言語は、Maestro の台本の中では指定できません。公式ドキュメントに「Flow の中(launchApp や config.yaml)に locale を定める場所はない」とあり、--device-locale は maestro start-device で端末を作るときにだけ使えます(Test in different locales)。既にある端末を erase して使うなら、起動する前に設定ファイルを直接書き換えます。
PREFS="$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Preferences/.GlobalPreferences.plist"
plutil -replace AppleLanguages -json '["ja-JP"]' "$PREFS"
plutil -replace AppleLocale -string "ja_JP" "$PREFS"
erase の後、bootstatus -b の前に実行します。動いているプロセスは起動したときの言語を使い続けるので、起動前に書き換えるのが確実です。使い捨てのシミュレータを作って確かめたところ、この順番で、起動後に defaults read で ja-JP が読めました。
.GlobalPreferences.plist の場所はシミュレータの内部構造で、Apple が公開している約束ではありません。将来変わる可能性があります。plutil はファイルがなければ失敗を返すので、静かに無効になることはありませんが、Xcode を上げたときは確認してください。
どちらを選ぶか
clearState: true |
シミュレータの erase | |
|---|---|---|
| 戻るもの | アプリのデータ | 端末全体 |
| 時間 | 数秒 | 数十秒〜(起動待ちを含む) |
| 言語の再設定 | 不要 | 必要 |
| 向いている場面 | 手元、台本の開発中 | CI、許可ダイアログや他アプリの状態も毎回同じにしたいとき |
CI で「落ちたらアプリの変更が原因だと言い切りたい」なら、重い方法を選ぶ価値があります。時間を払って、確実さを買う判断です。
逆に、ログイン済みの状態を人が作り込んだ端末で、ログイン後の画面を確かめたいこともあります。その場合は初期化を飛ばす選択肢を残し、4 番の「前提の状態」に合わせて呼ぶ側が決めます。
まとめ
| 設計 | 一言で |
|---|---|
| 1. 本体とサブフローを分ける | 繰り返す操作は関数のように切り出す。残す台本と残さない台本も分ける |
| 2. 初回起動の通過はサブフローに |
when: visible で、出たときだけ通す |
| 3. 割り込みを閉じる処理はサブフローに | 操作の前と後に挟む。retry は画面 1 つ分まで |
| 4. 冒頭に前提の状態を書く | 端末を用意する側への約束 |
| 5. 到着は固有の要素で待つ | タブバーの文字で待つと、何も確かめていない |
| 6. 始める前に端末を戻す |
clearState か erase。erase なら言語を再設定 |
1〜5 は台本の中、6 は台本の外の話です。手元で 1 回通る台本と、CI で毎回通る台本の差は、ほとんどこの 6 つで埋まります。
参考
- Nested flows - Maestro
- Conditions - Maestro
- runFlow - Maestro
- extendedWaitUntil - Maestro
- retry - Maestro
- launchApp - Maestro
- Test in different locales - Maestro
-
xcrun simctl help erase/bootstatus