🎼

AI エージェントの UI 実装を確認を Maestro に確認させる

に公開

TL;DR

  • Claude Code が実装した UI の動作確認を、screencap の目視確認から Maestro のフロー実行に置き換えた
  • 同一の検証フローが中央値 70秒から 28秒前後になり、assertVisible による厳密な検証も手に入った
  • Maestro MCP には長時間セッションでデバイス接続が無効になる既知の不具合があるため、エージェントには CLI (maestro test) を使わせるのがおすすめ

はじめに - screencap 目視確認の限界

Claude Code のような AI コーディングエージェントに UI を実装させると、動作確認までエージェントに任せたくなります。最近では特別な指示をしなくとも、実装した UI を自動で確認するようになりました。
Android エミュレータでの確認を素朴にやらせると、次のループになります。

実際にやらせてみると、パフォーマンスと安定性に難がありました。

  • 遅い: 1画面ごとにスクリーンショットの読み込みと判断が挟まり、画面遷移3つの確認で1分以上かかる
  • 甘い: 最後は「画像を見て良さそう」で終わり、テキストの存在確認のような厳密な検証になっていない
  • 不安定: スプラッシュ画面を撮ってしまい撮り直す、といった手戻りが毎回のように起きる

本記事では、この確認作業を Maestro のフロー実行に置き換えて改善した事例と、Flutter 固有のハマりどころを紹介します。

ツール選定 - なぜ Maestro か

エージェントに UI 操作をさせるツールは複数あります。
「Flutter のウィジェットをテキストで特定できるか」「エージェントから CLI や MCP で使いやすいか」を軸に検討しました。

  • 公式 Dart MCP サーバー: Flutter ツールチェインに統合されていて本命ですが、Dart 3.9 / Flutter 3.35 以上が必須です。(対象のプロジェクトの Flutter バージョンをアップデートしたら、こちらも試してみようと思います)
  • mobile-mcp: npx 一発で導入できて手軽ですが、Flutter の空の TextField を検出できない issue が未修正でした
  • Maestro: 宣言的な YAML でフローを書ける E2E テストツールで、Flutter のセマンティクスラベルをそのまま取得できます。CLI に加えて MCP サーバーも内蔵しています

この結果、Android 側は Maestro を採用しました。
なお iOS シミュレータの操作は XcodeBuildMCP の ui-automation で完結するため、本記事では Android に絞ります。

Maestro の基本

https://github.com/mobile-dev-inc/maestro

Maestro はモバイルアプリ向けの E2E テストツールです。
テストシナリオ (フロー) を YAML で宣言的に書き、エミュレータや実機に対して実行します。

# インストール
brew tap mobile-dev-inc/tap
brew trust --formula mobile-dev-inc/tap/maestro
brew install mobile-dev-inc/tap/maestro

# フローの実行
maestro test flow.yaml

# 画面の要素ツリーを取得
maestro hierarchy

フローは「何をタップし、何が見えるべきか」を上から順に並べるだけです。

appId: com.example.app
---
- launchApp
- tapOn: "ログイン"
- assertVisible: "ようこそ"

tapOnassertVisible はアクセシビリティラベルやテキストで要素を特定します。
Flutter アプリの場合、ウィジェットの Semantics 情報がそのままラベルとして見えるため、Text('スキャンを開始') のようなボタンは追加の対応なしで tapOn: "スキャンを開始" と書けます。
座標のハードコードが不要になるので、画面サイズの違うデバイスでも同じフローが動きます。

エージェントとの相性で見ると、この YAML が「検証手順の資産」になる利点があります。
screencap の目視確認はセッションが終わると何も残りませんが、フローはリポジトリにコミットでき、次の確認でも人間の手元でも再利用できます。

デモアプリで検証フローを作る

動作を示すために、小さな Flutter アプリを用意しました。
ホーム画面の「スキャンを開始」を押すとカメラプレビュー風のスキャン画面へ遷移し、右上の閉じるボタンでホームに戻る、という構成です。
QR スキャンやレシート撮影のようなカメラ画面では、全画面のカメラプレビューの上に Stack で閉じるボタンを重ねるレイアウトが定番です。
このデモでは、そうしたボタンのラベルがテストツールから取得できない状況を ExcludeSemantics で再現しています (後述のハマりどころ用です)。

main.dart
import 'package:flutter/material.dart';

void main() => runApp(const MaestroDemoApp());

class MaestroDemoApp extends StatelessWidget {
  const MaestroDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Maestro Demo',
      theme: ThemeData(colorSchemeSeed: Colors.teal),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('マイページ')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            FilledButton(
              onPressed: () => Navigator.of(context).push(
                MaterialPageRoute(builder: (_) => const ScanScreen()),
              ),
              child: const Text('スキャンを開始'),
            ),
            const SizedBox(height: 16),
            OutlinedButton(
              onPressed: () {},
              child: const Text('クーポン一覧を見る'),
            ),
          ],
        ),
      ),
    );
  }
}

class ScanScreen extends StatelessWidget {
  const ScanScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: [
          const Center(
            child: Text(
              'QR コードをかざしてください',
              style: TextStyle(color: Colors.white, fontSize: 18),
            ),
          ),
          Positioned(
            top: 48,
            right: 24,
            child: ExcludeSemantics(
              child: IconButton(
                icon: const Icon(Icons.close, color: Colors.white, size: 32),
                onPressed: () => Navigator.of(context).pop(),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
ホーム画面 スキャン画面
デモアプリのホーム画面 デモアプリのスキャン画面

maestro test 実行中のエミュレータ画面録画 (adb screenrecord ~20秒、ホーム→スキャン→ホームが自動で進む様子)

このアプリに対する検証フローは次のとおりです。

appId: com.example.maestro_demo
---
# ホーム画面からスキャン画面へ遷移し、閉じてホームに戻れることを確認する。
- assertVisible: "スキャンを開始"
- tapOn: "スキャンを開始"
- assertVisible: "QR コードをかざしてください"
# 閉じるボタンはセマンティクスから除外されているため座標指定でタップする。
- tapOn:
    point: "90%,7%"
- assertVisible: "スキャンを開始"
- assertVisible: "クーポン一覧を見る"

実行すると各ステップの成否が順に表示されます。

Assert that "スキャンを開始" is visible... COMPLETED
Tap on "スキャンを開始"... COMPLETED
Assert that "QR コードをかざしてください" is visible... COMPLETED
Tap on point (90%,7%)... COMPLETED
Assert that "スキャンを開始" is visible... COMPLETED
Assert that "クーポン一覧を見る" is visible... COMPLETED

実測 - screencap 目視ループとの比較

同じ検証内容 (ホーム表示、スキャン画面遷移、ホーム復帰の3チェックポイント) を、従来の screencap 目視ループと maestro test の両方で Claude Code にやらせて計測しました。

計測条件は次のとおりです。

  • macOS / Pixel 10 Pro AVD (Android 16, API 36, arm64) / Maestro 2.8.0
  • アプリは毎回 force-stop してから再起動し、起動後に計測を開始
  • 手動ループは「screencap で撮影、エージェントが画像を読んで確認、座標を割り出して input tap」を3チェックポイント分繰り返す。エージェントの推論時間も含む
  • maestro test はフローファイルの実行1回。フロー起動時のドライバ準備時間も含む
screencap 目視ループ maestro test
1 70秒 18秒
2 69秒 36秒
3 169秒 [1] 31秒
4 72秒 26秒

中央値で比較すると 71秒に対して 28.5秒、およそ 2.5倍の差になりました。
所要時間より効いたのは検証の質です。
目視ループの結論は「スクリーンショットを見た限り問題なさそう」ですが、Maestro は assertVisible が要素の存在をツリーから検証するため、確認項目が明文化された合否として残ります。
また、目視ループでは3回ともスプラッシュ画面を撮ってしまい撮り直しが発生しましたが、Maestro は要素が現れるまで自動で待つため、この種の手戻りがありません。

Flutter 固有のハマりどころ

セマンティクスに出ない要素は座標で叩く

デモアプリの閉じるボタンは ExcludeSemantics で囲んであり、maestro hierarchy の出力に一切現れません。
カメラプレビューの上に重ねたボタンなど、実際のアプリでもラベルが取得できない要素に出会うことがあります。
このような要素は tapOn: "閉じる" では特定できないので、座標指定にフォールバックします。

- tapOn:
    point: "90%,7%"

パーセント指定なら解像度の違いは吸収できますが、レイアウト変更には追従できません。
フロー内にコメントで理由を残しておくと、後からラベルベースに直す目印になります。
本来はアプリ側に Semantics(label: ...) を足すのが筋なので、テスト都合でセマンティクスを整備するきっかけにもなります。

セマンティクスツリーは要求されるまで構築されない

Flutter はアクセシビリティサービスからの要求を受けて初めてセマンティクスツリーを構築します。
このため、アプリ起動直後に maestro hierarchy を実行すると、初回だけラベルが空で返ってくることがあります。
実際にこの症状に遭遇したときは、2回目以降のクエリで全ラベルが取得できました。
今回のデモアプリでは初回から取得できたため常に起きるわけではありませんが、「初回だけ要素が見つからない」ときはこの仕組みを疑ってみてください。

MCP ではなく CLI を使う理由

Maestro には maestro mcp で起動する MCP サーバーが内蔵されていて、Claude Code から画面の inspect やフロー実行を直接呼べます。
探索的に画面を調べるときは便利なのですが、長時間のセッションで問題が起きました。
MCP サーバーの起動から1時間ほど経つと内部のデバイス接続が無効になり、list_devices は connected を返すのに実際の操作はすべて失敗する状態になります。

調べると upstream で報告済みの既知の不具合でした。

https://github.com/mobile-dev-inc/Maestro/issues/2839

MCP サーバーはデバイスごとの接続セッションをキャッシュしますが、その接続が外的要因で切れても破棄されず、無効になったセッションを使い回し続けることが原因と分析されています。
セッションを再構築する修正 PR (#3385) と gRPC 接続のリトライを入れる修正 PR (#2934) が提出されていますが、本記事執筆時点の最新版 2.8.0 にはどちらも取り込まれていません。

一方、CLI の maestro test はフローごとに新しいプロセスを立てるため、この問題の影響を受けません。
コーディングエージェントのセッションは数時間に及ぶことも珍しくないので、エージェントに使わせるのは CLI 側にしておくのが現状の安全策です。

CLAUDE.md への組み込み

エージェントが毎回 Maestro を使ってくれるように、プロジェクトの CLAUDE.md に運用ルールとして明記しました。

## UI 動作確認

実装した UI の挙動確認・動作確認を行うときは、Android エミュレータ / iOS シミュレータを起動して確認する。
Maestro (CLI または MCP) が使える環境であれば、手動の screencap 目視確認の代わりに Maestro でのフロー実行を優先する。
手動確認より高速かつ厳密に検証できる。

既存のフローは `.maestro/flows/` にある。新規の確認項目は同ディレクトリにフローを追加し、
可能な限り再利用可能な形で残す。

ポイントは、フローを使い捨てにせず .maestro/flows/ に残すよう指示している点です。
エージェントが動作確認のたびに書いたフローが、そのままスモークテストの資産として積み上がっていきます。

まとめ

AI コーディングエージェントの UI 動作確認を screencap の目視から Maestro のフロー実行に置き換えることで、確認時間は半分以下になり、検証内容も明文化された合否として残るようになりました。
「エージェントに実装させて、検証フローも書かせて、フローが資産として残る」という回し方は、E2E テストツールと AI エージェントの組み合わせとしてうまくかみ合っていると思います。
今後はフローのバリエーションを増やしていきたいと思いつつも、公式 Dart MCP サーバーも検証してパフォーマンスが良い方を育てていこうと思います。

エージェントが貼ってくるスクリーンショットを毎回目視で追うのに消耗している方は、ぜひ Maestro を試してみてください。

脚注
  1. 3回目はエージェント側の応答待ちが重なった外れ値です。手動ループの所要時間はエージェントの状態に左右され、この振れ幅自体が目視確認の弱点でもあります。 ↩︎

GENDA

Discussion