デスクトップアプリ(WPFやWinForms)のUI自動テスト、毎回テストコードをガリガリ書くのって正直めちゃくちゃ大変ですよね。
「ボタンを押してダイアログが出るか」「重い処理中に画面が固まらないか」「例外で突然クラッシュしないか」といった検証をするために、FlaUIやWinAppDriverなどのテスト自動化フレームワークを使ってテストスクリプトを組むわけですが、セレクターのメンテナンスや非同期のタイミング同期に追われ、「テストコードを保守するために開発してるのか?」と本末転倒になることもしばしばです。
「それなら、AIに画面を見せて、能動的に考えて触らせて、勝手にバグを見つけてもらえばいいんじゃないか?」
そう思い立ち、今回はC#で独自のLocal MCP(Model Context Protocol)サーバーを立ち上げ、AIエージェントに「目(画面キャプチャ)」と「手(UI操作)」と「診断器(プロセス監視・イベントログ)」を渡して自律検証させるシステムを一から設計・構築してみました。
結論ですが、テストコードを1行も書くことなく、AIが画面を見て各ボタンの役割を推論し、自律的に操作してUIフリーズや未処理例外によるクラッシュをイベントログのスタックトレース付きで特定・レポートすることに成功しました。
本記事では、作成したWPF検証アプリの構成から、C#製Local MCPサーバーのアーキテクチャ、実装中に直面した「WPF特有の画面キャプチャ真っ白問題」や「オフスクリーン50×50ダミーウィンドウの罠」などの泥臭い現場知見、そして実際のAIの探索出力ログまで、余すところなく徹底解説します!

【全体像】AI自律検証はどういうフローで行われるのか?
具体的なコードに入る前に、まず「今回どういう仕組みで、どのような流れで検証を回したのか」という全体像のフローを図示します。
AI自律検証システムの全体フロー図
- 観察(目): 画面キャプチャ画像を取得し、ボタンの配置・テキストを視覚認識。
- 思考: 「まずはメッセージを試し、次に重い処理でフリーズを監視しよう」とAI自身が計画。
- 行動(手): UI Automation / クリックでボタンを押下、ダイアログにキー送信。
- 診断(診): プロセスの応答性(IsHung)を監視し、プロセス突然死を検知。
人間があらかじめ「このボタンのセレクターはこれ」「クリック後にダイアログが出るのを待機してアサート」といったテストコードを書く必要は一切ありません。「このアプリを自由に触って、壊れそうなところや使い心地の悪いところを調べて」と伝えるだけで、AIが勝手に探索してバグを見つけ出してくれるのが本システム最大の強みです。
1. 検証対象のWPFアプリ(VeryGoodApp)の仕様
まずは今回の実験台となるWindowsデスクトップアプリケーション(.NET 10 / WPF)を用意しました。プロジェクト名はVeryGoodAppです。
画面構成は非常にシンプルですが、AIの評価能力を試すために「正常系」「パフォーマンス警告系」「致命的クラッシュ系」の要素を意図的に盛り込んでいます。
<Window x:Class="VeryGoodAPp.MainWindow"
Title="MainWindow" Height="450" Width="800">
<Grid>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<Button Name="MessageButton" Click="MessageButton_Click" Width="200" Height="50" FontSize="14" Margin="0,5,0,5">
メッセージボックス表示
</Button>
<Button Name="HeavyProcessButton" Click="HeavyProcessButton_Click" Width="200" Height="50" FontSize="14" Margin="0,5,0,5">
重い処理を実行
</Button>
<Button Name="CrashButton" Click="CrashButton_Click" Width="200" Height="50" FontSize="14" Margin="0,5,0,5">
バグ(クラッシュ)
</Button>
<Button Name="RandomColorButton" Click="RandomColorButton_Click" Width="200" Height="50" FontSize="14" Margin="0,5,0,5">
ランダムカラー
</Button>
</StackPanel>
</Grid>
</Window>
背後のC#コード(MainWindow.xaml.cs)には、以下の4つの振る舞いを仕込みました。
- メッセージボックス表示(正常系):
MessageBox.Show("これはメッセージボックスです!", "情報");を呼び出し、モーダルダイアログをポップアップする。 - 重い処理を実行(UX/パフォーマンス課題): 警告ダイアログを出した後、UIスレッド上で
for (int i = 0; i < 500000000; i++)という5億回の同期ループを回す。非同期化されていないため、UIスレッドが数秒間完全に占有されてウィンドウが無応答(フリーズ)状態になる。 - バグ(クラッシュ)(致命的不具合):
string? nullString = null; int length = nullString!.Length;を実行し、未処理のNullReferenceExceptionを発生させる。WPFでグローバルハンドラがないため、プロセスが即死クラッシュ(終了コード 0xE0434352)する。 - ランダムカラー(動的スタイリング): ランダムなRGB値を生成し、ボタン自身の背景色(
button.Background)を動的に塗り替える。
人間であれば「バグって書いてあるボタンを押したら落ちるな」「重い処理は固まるな」と分かりますが、これをAIにテストケースなしで初見で触らせ、何が起きているかを正確に判断させるのが今回のミッションです。
2. C#によるLocal MCPサーバーの設計と実装
AIエージェントにアプリを自律操作させるため、今回はModel Context Protocol(MCP)に準拠したローカルサーバーを構築しました。
MCPサーバーの実装言語としてはPythonが選ばれることが多いですが、今回は「Windows APIやWPF/.NET環境との親和性」「外部ランタイム不要のスタンドアロン性」を重視し、C#(.NET 10 コンソールアプリケーション)でゼロから構築しました。
MCP通信アーキテクチャ(stdio over JSON-RPC 2.0)
MCPは標準入出力(stdio)を介して1行ずつのJSON-RPC 2.0メッセージをやり取りする非常にクリーンなプロトコルです。外部の重いSDKに依存せず、.NET標準のSystem.Text.Jsonだけで以下のようにシンプルにメインループを組むことができます。
// 標準出力はJSON-RPCレスポンス専用のため、ログは必ず標準エラー出力へ分離
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = new UTF8Encoding(false); // BOMなしUTF-8
var tools = new AppEvaluationTools();
using var reader = new StreamReader(Console.OpenStandardInput(), Encoding.UTF8);
while (true)
{
string? line = await reader.ReadLineAsync();
if (line == null) break; // クライアント切断(EOF)
if (string.IsNullOrWhiteSpace(line)) continue;
var request = JsonSerializer.Deserialize<JsonRpcRequest>(line, JsonOptions);
if (request == null) continue;
// initialize, tools/list, tools/call を振り分けてレスポンスを標準出力へFlush
HandleRequest(request, tools);
}
ここで重要な鉄則は、「標準出力(stdout)にはJSON-RPCメッセージ以外を1文字たりとも流してはならない」という点です。デバッグ用ログや内部エラーは必ずConsole.Error.WriteLine(stderr)に出力するように徹底します。
AIに渡した7つのMCPツール
AIエージェント(GitHub Copilot、Claude Code、Antigravityなど)が能動的にアプリを探索できるよう、以下の7つのツールをMCP経由で提供しました。
| MCPツール名 | AIから見た役割 | 内部実装技術 |
|---|---|---|
launch_target_app |
対象アプリを起動し、PIDとHWNDの監視を開始 | Process.Start + ウィンドウハンドル追跡 |
capture_app_window |
ウィンドウの最新スクリーンショット(Base64 PNG)と座標を取得(AIの「目」) | Win32 PrintWindow / CopyFromScreen |
click_button |
画面に見えるボタンのテキストを指定して直接クリック | UI Automation(InvokePattern) |
click_at |
ウィンドウ内の相対座標 (x, y) をクリック(AIの「手」) |
UIA FromPoint + Win32 mouse_event / PostMessage |
check_app_health |
アプリが生きているか、応答なし(フリーズ)か、メモリ使用量は正常かを診断 | Process.Responding + Win32 IsHungAppWindow |
send_keys |
ダイアログを閉じるためのEnterやEscキー等を送信 | Win32 keybd_event |
get_crash_details |
クラッシュ発生時にWindowsイベントログから未処理例外のスタックトレースを抽出 | System.Diagnostics.EventLog(Applicationログ検索) |
close_target_app |
検証完了後にアプリを安全にクリーンアップ終了 | Process.CloseMainWindow / Kill |
3. 現場で直面した3大ハマりどころと解決策
理論上は「起動して、撮って、クリックして、ログを見る」だけに見えますが、実際にWindows上でWPFアプリを相手に自動化を進めると、OSやフレームワークの深い仕様に起因する罠が次々と立ちはだかりました。ここで得られた3つの知見を共有します。
落とし穴①:WPFのDirectXレンダリングで画面キャプチャが真っ白になる
最初にスクリーンショット機能を実装した際、画像ファイルを開いてみると「タイトルバーだけは写っているのに、肝心のアプリ領域が完全に真っ白」という現象に見舞われました。
原因は、WPFの描画アーキテクチャにあります。WPFはWindowsの標準GDIではなく、Direct3D(DirectX)によるハードウェアアクセラレーションを使ってGPUで直接画面を描画します。そのため、Windows標準のGDI APIであるPrintWindow(hWnd, hdc, PW_RENDERFULLCONTENT)を呼んでも、DirectXサーフェスの描画バッファがGDI側へ渡されず、真っ白な背景だけがコピーされてしまうのです。
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 自動テスト・キャプチャ互換性のためソフトウェアレンダリングモードに切り替える
System.Windows.Media.RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.SoftwareOnly;
}
}
RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; を設定することで、WPFがDirectXではなくCPUベースのソフトウェアレンダリングを行うようになり、PrintWindowでもすべてのボタンやフォント、背景色が鮮明にキャプチャできるようになりました!
落とし穴②:WPFのオフスクリーン50×50ダミーウィンドウに騙される
アプリを起動した直後、.NETのProcess.MainWindowHandleを取得してウィンドウの座標を調べると、なぜかWidth: 50, Height: 50という極小の謎サイズが返ってきました。
調査したところ、WPFは起動プロセス中に内部の初期化用・DWM通信用として画面外(オフスクリーン)に50×50の不可視ダミーHWNDを生成するという仕様があり、.NETランタイムのProcess.MainWindowHandleが本物のメインウィンドウ(800×450)ではなく、そのダミーウィンドウを拾ってしまっていたのです。
public static IntPtr FindMainWindow(Process process)
{
process.Refresh();
IntPtr bestHwnd = IntPtr.Zero;
int maxArea = 0;
foreach (ProcessThread thread in process.Threads)
{
NativeMethods.EnumThreadWindows(thread.Id, (hWnd, lp) =>
{
if (NativeMethods.IsWindowVisible(hWnd) && NativeMethods.GetWindowRect(hWnd, out var r))
{
var sb = new StringBuilder(256);
NativeMethods.GetWindowText(hWnd, sb, 256);
string title = sb.ToString();
// タイトルに MainWindow が含まれるか、面積が最大の可視ウィンドウを選択
if (title.Contains("MainWindow", StringComparison.OrdinalIgnoreCase))
{
bestHwnd = hWnd;
return false; // 確定
}
if (r.Area > maxArea && r.Area > 10000)
{
maxArea = r.Area;
bestHwnd = hWnd;
}
}
return true;
}, IntPtr.Zero);
}
return bestHwnd != IntPtr.Zero ? bestHwnd : process.MainWindowHandle;
}
スレッド内のすべてのウィンドウを列挙し、可視かつ面積が適切(またはタイトルが一致)なウィンドウを特定するようにしたことで、100%確実に本物のMainWindowを掴めるようになりました。
落とし穴③:非対話型・バックグラウンド実行時のマウスイベント不発
CLIやバックグラウンドタスクからMCPサーバーを叩いた際、Win32のmouse_eventによる物理マウスクリックがアプリに届かない現象が発生しました。Windowsのセッション分離やフォーカス制御によって、物理カーソル移動がブロックされるケースがあるためです。
そこで、「UI AutomationのInvokePattern」を最優先にし、フォールバックとして「SendMessage/PostMessage」と「mouse_event」を組み合わせるハイブリッド方式を採用しました。これにより、アプリが非アクティブな状態やバックグラウンドにあっても確実にクリックイベントが発火する堅牢性を実現しました。
4. AIに自律評価ミッションを与えた実際の出力と検証ログ
準備が整ったところで、AIエージェントに自律評価ミッション指示書(ai_evaluator_mission.md)を渡し、実際にアプリを評価させました。
AIは一切の事前スクリプトなしで、自らMCPツールを順次呼び出していきます。
実際の実行ログ(CLIリアルタイム抜粋)
==========================================================
AI EVALUATION MISSION: VeryGoodApp Autonomous Test
==========================================================
[MCP] Connected to: verygoodapp-eval-mcp v1.0.0
--- Step 1: Launch Application ---
Application launched successfully.
PID: 22212, Title: 'MainWindow', Handle: 1508310
--- Step 2: Visual Inspection (Capture Initial Window) ---
Window captured. Geometry: [X: 260, Y: 260, Width: 800, Height: 450].
Saved to: scratch/captures/capture_20260913_225627_327.png
# AIが画像を見て4つのボタンの存在・ラベル・位置関係を視覚認識
--- Step 3: Test 'メッセージボックス表示' Button ---
Click result: Button found and clicked successfully.
Sending {ENTER} to dismiss dialog...
SendKeys: Sent keystrokes: '{ENTER}' -> 正常にダイアログが閉じて復帰 (PASS)
--- Step 4: Test 'ランダムカラー' Button ---
Click result: Button clicked successfully.
Color Changed Capture: ボタン背景色の動的変化を確認 (PASS)
--- Step 5: Test '重い処理を実行' Button (Freeze Detection) ---
Click result: Button clicked successfully.
Dismissing warning dialog...
Monitoring app health during heavy computation...
# 5億回のループ演算中にヘルスチェックを実行
# 結果: UIスレッドが占有されウィンドウが一時的にフリーズ (WARN)
--- Step 6: Test 'バグ(クラッシュ)' Button (Crash Detection) ---
Click result: Button clicked successfully.
Post-Crash Status:
{
"IsRunning": false,
"ExitCode": -532462766
} -> プロセスの突然死を検知! (FAIL)
--- Step 7: Crash Diagnostics via Windows EventLog ---
[22:54:32] [.NET Runtime] Application: VeryGoodApp.exe
CoreCLR Version: 10.0.1226.42308
Description: The process was terminated due to an unhandled exception.
Exception Info: System.NullReferenceException: Object reference not set to an instance of an object.
at VeryGoodAPp.MainWindow.CrashButton_Click(Object sender, RoutedEventArgs e)
in C:\Users\nakajima\workspace\VeryGoodApp\MainWindow.xaml.cs:line 60
以下は、AIが「ランダムカラー」ボタンを実際にクリックし、動的にボタン背景色(モスグリーン)を塗り替えた瞬間をキャプチャした実際の画面です。AIは画像の変化を観測し、「正常にスタイル更新が適用された(PASS)」と自己判断しました。

AIが自動生成した最終評価レポート
探索を終えたAIは、ただ「動いた/落ちた」を報告するだけでなく、以下のようなエンジニアがそのまま修正に使えるレベルの診断レポートを即座に出力してくれました。
# アプリケーション自律評価レポート: VeryGoodApp
総合判定: ❌ FAIL (致命的不具合あり)
【検出された問題の詳細】
1. [致命的バグ] クラッシュボタン押下時の異常終了 (NullReferenceException)
- 現象: プロセスが終了コード -532462766 (0xE0434352) で強制終了
- 原因箇所: MainWindow.xaml.cs:60 (nullString!.Length)
- 改善案: nullチェックの追加、または App.xaml.cs の DispatcherUnhandledException によるグローバルハンドリング
2. [UX/パフォーマンス課題] 重い処理実行時のUIスレッド占有
- 現象: 処理中にウィンドウが一時的に無応答状態になり、ユーザー操作を受け付けない
- 原因箇所: MainWindow.xaml.cs:45 (UIスレッド上の同期5億回ループ)
- 改善案: async / await と Task.Run() を導入してCPU負荷処理をワーカースレッドへ逃がす
ソースコードの該当行数(MainWindow.xaml.cs:line 60)までピンポイントで特定し、改善コードの提案まで自律的に完結しています。
5. 従来手法(FlaUIなどの固定テストスクリプト)との比較表
今回構築した「AI自律型MCP検証」と、従来の「FlaUI / Appium等によるコードベースのE2Eテスト」の違いを表にまとめました。
| 比較項目 | 従来の自動テスト (FlaUI / Appium) | AI自律型MCP検証 (本手法) |
|---|---|---|
| テスト作成工数 | 大(画面ごとにボタンID指定やアサートコードの記述が必須) | 小〜ゼロ(MCPサーバーとミッションプロンプトを渡すだけ) |
| UI変更への耐性 | 低(ボタン位置や名前、IDが変わるとテストが壊れる) | 極めて高い(AIが画面画像を見て柔軟に認識・追従) |
| 探索・バグ発見力 | 書いたシナリオしか検証できない | 未知の不具合やUXの違和感、UIフリーズを能動的に発見可能 |
| 実行速度・確定性 | 非常に高速・確定的(数秒でミリ秒精度の合否判定) | 中速(LLMの思考・API呼び出しオーバーヘッドがある) |
| 最適なユースケース | CI/CDでの毎コミットごとのリグレッションテスト | 開発途中の探索的QA、初見アプリの総当たり健全性診断 |
本手法が向かないケース(アンチパターン)
非常に夢のあるAI自律検証ですが、万能ではありません。以下のケースには向きません。
- CIパイプラインで毎コミット数千件を数十秒で回したい場合: LLMの推論時間とコストがかかるため、固定シナリオはFlaUIなどの軽量テストコードで回すのが正解です。
- ピクセル単位・ミリ秒単位の厳密なリグレッション検証: デザインの1pxのズレやタイマーの微細な精度を保証したい場合は、スナップショットテスト等の確定型ツールを使いましょう。
まとめ:AIに「目」と「手」を渡せば、デスクトップアプリのQAは劇的に変わる
今回の実験を通して、「AIにローカル環境の操作権限(MCPツール)を安全に委ねることで、人間が手作業で行っていた探索的テストを丸ごと肩代わりさせられる」という確かな手応えを得ることができました。
ポイントを振り返ると以下の通りです。
- C#製Local MCPサーバーは依存関係なし・超軽量に作れる。JSON-RPC over stdioで標準出力と標準エラー出力をきっちり分けるのが肝。
- WPFをキャプチャする際は、ソフトウェアレンダリング(
SoftwareOnly)への切り替えとEnumThreadWindowsによる真のHWND特定が必須テクニック。 - AIは画像認識とヘルスチェックを組み合わせることで、テストコードなしでUIの破綻・フリーズ・クラッシュ・スタックトレースまで自律的に特定できる。
「テストコードを書くのが面倒でUIテストが後回しになっている…」という方は、ぜひ一度自作MCPサーバーによるAI自律評価を試してみてはいかがでしょうか?開発体験がガラリと変わるはずです!

