1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlaUI + Claude Code で「手順をコードに書かない」WPF の UI テストをやってみた

1
Last updated at Posted at 2026-10-20

1. この記事について

こんにちは。開発部の雨です。今回はWindows デスクトップアプリテストの自動化について話したいと思います。

Windows デスクトップアプリ (WPF / WinForms) の UI テストを自動化するとき、C# なら FlaUI がよく使われます。
ただ、FlaUI で書くテストは「どのボタンを押して、何を入力して、何を確認するか」を 1 ステップずつコードで書く 必要があり、テストケースが増えるほど書くのも直すのも大変になります。

そこで業務 (WPF 製の POS レジアプリ) で、次の組み合わせを試しました。

  • FlaUI を 「クリック」「入力」「画面の要素ツリーを見る」だけの小さな CLI に包む
  • テストケースは 日本語の表 (手順 / 想定結果) のまま渡す
  • Claude Code が画面のツリーを見ながら、手順を解釈して CLI を叩き、合否を判定する

結果、Excel のテストケース表をほぼそのまま渡すだけで、Claude Code が人間のテスターのようにアプリを操作して判定してくれるようになりました。

この記事では、実案件のコードではなく 説明用の小さなサンプル で、その考え方と最小の実装を紹介します。

環境: Windows 11 / .NET 8 / FlaUI 4.0.0 / Claude Code

2. サンプルプログラム「ミニレジ」

テスト対象として、商品コードを入れると合計が出るだけの WPF アプリを作りました。

T2.png

機能 内容
商品コード入力 1001 りんご ¥120 / 1002 バナナ ¥98 / 1003 牛乳 ¥198。Enter か「追加」で登録
エラー表示 存在しないコードなら「商品が見つかりません」
全削除 カートを空にする
会計 「会計完了」ダイアログを出してカートを空にする

コードはほぼこれだけです。

MainWindow.xaml (抜粋)
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
    <Label Content="商品コード" Target="{Binding ElementName=CodeBox}" />
    <TextBox x:Name="CodeBox" Width="160" KeyDown="CodeBox_KeyDown" />
    <Button Content="追加" Width="70" Margin="8,0,0,0" Click="Add_Click" />
</StackPanel>
<TextBlock x:Name="MessageText" DockPanel.Dock="Top" Foreground="Red" Margin="0,6" />

<StackPanel DockPanel.Dock="Bottom">
    <TextBlock x:Name="TotalText" Text="合計: ¥0" FontSize="24" FontWeight="Bold"
               HorizontalAlignment="Right" Margin="0,8" />
    <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
        <Button Content="全削除" Width="90" Click="Clear_Click" />
        <Button x:Name="CheckoutButton" Content="会計" Width="90" Margin="8,0,0,0" Click="Checkout_Click" />
    </StackPanel>
</StackPanel>

<ListBox x:Name="CartList" />
MainWindow.xaml.cs (抜粋)
private void Checkout_Click(object sender, RoutedEventArgs e)
{
    // わざと残しているバグ: カートが空でも会計できてしまう
    MessageBox.Show(this, $"会計が完了しました。合計 ¥{_cart.Sum(x => x.Price)}", "会計完了");
    _cart.Clear();
    Refresh();
}

わざとバグを 1 つ入れています。 カートが空でも「会計」が押せて、¥0 の会計が成立してしまいます。
後で Claude Code がこれを見つけられるかを確認します。

3. FlaUI とは

FlaUI は、Windows の UI Automation (UIA) を C# から扱いやすくしたライブラリです。

UIA は、Windows がスクリーンリーダー等のために提供している仕組みで、起動中のアプリの画面を 要素のツリー として取得できます。ボタン・テキストボックス・ラベルなどが、それぞれ次のような情報を持っています。

プロパティ 例 由来
Name "追加", "合計: ¥0" 画面に表示されている文字
ControlType Button, Edit, Text コントロールの種類
AutomationId CodeBox, TotalText WPF なら x:Name
IsEnabled true / false 押せるかどうか

FlaUI を使うと、アプリを起動して、要素を探して、クリック・入力・値の確認ができます。
ミニレジに対する、よくある FlaUI + xUnit のテストはこうなります。

ClassicTests/MiniRegisterTests.cs
public class MiniRegisterTests : IDisposable
{
    private const string ExePath = @"..\..\..\..\MiniRegister\bin\Release\net8.0-windows\MiniRegister.exe";

    private readonly Application _app;
    private readonly UIA3Automation _automation = new();
    private readonly Window _window;

    public MiniRegisterTests()
    {
        _app = Application.Launch(Path.GetFullPath(ExePath));
        _window = _app.GetMainWindow(_automation);
    }

    [Fact]
    public void 商品を2つ追加すると合計が表示される()
    {
        var codeBox = _window.FindFirstDescendant(cf => cf.ByAutomationId("CodeBox")).AsTextBox();
        var addButton = _window.FindFirstDescendant(cf => cf.ByName("追加")).AsButton();

        codeBox.Enter("1001");
        Keyboard.Type(VirtualKeyShort.ENTER);
        codeBox.Enter("1003");
        addButton.Click();

        var total = _window.FindFirstDescendant(cf => cf.ByAutomationId("TotalText"));
        Retry.WhileFalse(() => total.Name == "合計: ¥318", TimeSpan.FromSeconds(3));
        Assert.Equal("合計: ¥318", total.Name);
    }

    public void Dispose()
    {
        _app.Close();
        _automation.Dispose();
        _app.Dispose();
    }
}
> dotnet test ClassicTests
成功!   -失敗:     0、合格:     2、スキップ:     0、合計:     2、期間: 2 s - ClassicTests.dll (net8.0)

ちゃんと動きます。テストの数が少ないうちは、これで十分です。

4. FlaUI の弱点と、今回の解決策

弱点: 手順が長いテストは、全部コードで定義しないといけない

テストケースの例

No 手順 想定結果
12 商品を 2 点登録し、1 点を削除にして精算する お客様側画面の支払金額が…
13 ポイントを利用して… …

これを FlaUI で書こうとすると、次の問題にぶつかります。

  1. 1ケース = 数十ステップのコード。 「商品(バナナ)を登録する」という一言でも、実際には「商品コードの入力欄を探す → 入力欄にフォーカスする → バナナの商品コードを入力する → Enter を押す → 明細に追加されたか確認する」と、画面操作を一つずつ書く必要がある。これがたった 1 商品分で、実際のケースでは複数の商品登録に小計や支払いの操作も続くため、すぐに数十ステップになる
  2. 画面が変わるたびにテストが壊れる。 ボタンの文言や配置、途中に 1 つダイアログが増えただけで、そこから先のステップが全滅する
  3. 判定が文字列の完全一致になりがち。 「合計が ¥318 であること」は書けても、「エラーが分かる形で表示されること」「押せないこと (無効 or エラー)」のような、人間なら判断できる曖昧な想定結果を書くのが難しい
  4. テストケース表 → コード の翻訳コストがずっと残る。 仕様変更でテストケース表が更新されるたびに、誰かがコードに翻訳し直す

つまり、FlaUI は「操作する手」としてはとても優秀だけど、「手順を考えて判断する頭」はテストコードを書く人間が全部やっている 状態です。

解決策: 単純なアクション + UI ツリー問い合わせ + Claude Code

そこで役割を分けました。

┌──────────────┐  「T2: りんごとバナナを登録」  ┌──────────────┐
│ testcases.md │ ─────────────────────────▶ │  Claude Code │  ← 頭: 手順の解釈・判定
└──────────────┘                            └──────┬───────┘
                                                   │ uibot tree / click / type ...
                                                   ▼
                                            ┌──────────────┐
                                            │ uibot (FlaUI)│  ← 手: 単純な操作だけ
                                            └──────┬───────┘
                                                   │ UI Automation
                                                   ▼
                                            ┌──────────────┐
                                            │  対象アプリ   │
                                            └──────────────┘
役割 担当 中身
単純なアクション uibot (FlaUI) click / type / key / screenshot。どれも「1 回呼ぶと 1 つ操作して終わる」だけ。テスト固有の知識を持たない
UI ツリーの問い合わせ uibot (FlaUI) tree で、今の画面の要素ツリーをテキストで返す。Claude Code の「目」
手順の解釈と判定 Claude Code テストケースを読み、tree で画面を見て、次に押すべきものを決める。最後に想定結果と 意味で 比較して合否を判定

ポイントは、テストケースごとのコードを 1 行も書かない ことです。
画面にダイアログが 1 つ増えても、Claude Code は tree でそれを見て「閉じる」なり「決定」なりを選ぶので、テストが壊れません。

5. 実装

必要なのは 3 つだけです。

  1. FlaUI の薄い CLI (uibot)
  2. Claude Code への指示 (CLAUDE.md)
  3. テストケースの記述 (testcases.md)

5-1. uibot: FlaUI の薄い CLI

設計方針は 1 つだけで、「状態を持たない」 です。
毎回 PID でアプリにアタッチし、1 つ操作して、結果を 1 行出して終了します。状態はアプリ側にあるので、Claude Code は Bash からコマンドを 1 回ずつ呼ぶだけで済みます。

UiBot/Program.cs (抜粋)
switch (cmd)
{
    case "launch":
    {
        // UseShellExecute=true にしないと、アプリがこの CLI の stdout を握ったままになり呼び出し側が終わらない
        var path = Path.GetFullPath(flags["path"]);
        var app = Application.Launch(new ProcessStartInfo(path) { UseShellExecute = true });
        app.WaitWhileMainHandleIsMissing(TimeSpan.FromSeconds(10));
        return Ok($"pid={app.ProcessId}");
    }
    case "tree":
        return WithWindow(w => { PrintTree(w, 0); return 0; });
    case "click":
        return WithWindow(w =>
        {
            var e = Find(w) ?? throw new Exception("要素が見つかりません");
            var name = e.Name;
            try { e.Click(); }
            // ダイアログの OK 等、クリックで要素ごとウィンドウが消えると COM 例外になるが、操作自体は成功している
            catch (System.Runtime.InteropServices.COMException) { }
            return Ok($"clicked \"{name}\"");
        });
    case "type":
        return WithWindow(w =>
        {
            var e = Find(w) ?? throw new Exception("要素が見つかりません");
            e.AsTextBox().Enter(flags["value"]);
            return Ok($"typed \"{flags["value"]}\"");
        });
    // key / screenshot / close も同じ形
}

要素の探し方は 画面に表示されている文字 (Name) が基本です。AutomationId は任意。
こうしておくと、開発者が新しい画面に ID を振らなくても、Claude Code は「見えている文字」で操作できます。

// 画面に表示されている文字 (--name) か AutomationId (--id) で要素を探す。
// 名前は完全一致 → 部分一致の順で探す。
AutomationElement? Find(Window w)
{
    if (flags.TryGetValue("id", out var id))
        return w.FindFirstDescendant(cf => cf.ByAutomationId(id));
    var name = flags["name"];
    return w.FindFirstDescendant(cf => cf.ByName(name))
        ?? w.FindAllDescendants().FirstOrDefault(e => e.Properties.Name.ValueOrDefault?.Contains(name) == true);
}

そして一番大事な tree。Claude Code が画面を「見る」ためのコマンドです。
JSON でも良いのですが、トークン節約と読みやすさのためインデント付きテキストにしました。

static void PrintTree(AutomationElement e, int depth)
{
    var name = e.Properties.Name.ValueOrDefault ?? "";
    var id = e.Properties.AutomationId.ValueOrDefault ?? "";
    var enabled = e.Properties.IsEnabled.ValueOrDefault ? "" : " [disabled]";
    Console.WriteLine($"{new string(' ', depth * 2)}{e.Properties.ControlType.ValueOrDefault} \"{name}\"{(id == "" ? "" : $" #{id}")}{enabled}");
    foreach (var child in e.FindAllChildren()) PrintTree(child, depth + 1);
}

実行するとこうなります。

> uibot tree --pid 5128
Window "ミニレジ"
  TitleBar "" #TitleBar
    ...
  Text "商品コード"
    Text "商品コード"
  Edit "" #CodeBox
  Button "追加"
    Text "追加"
  Text "" #MessageText
  Text "合計: ¥0" #TotalText
  Button "全削除"
    Text "全削除"
  Button "会計" #CheckoutButton
    Text "会計"
  List "" #CartList

人間が読んでも、どこに何があるか分かりますね。Claude Code にとっても同じです。

5-2. CLAUDE.md: Claude Code への指示

Claude Code はフォルダ直下の CLAUDE.md を自動で読みます。ここに「テストの進め方」を書いておきます。
テストケース固有のことは書かず、進め方だけ を書くのがコツです。

CLAUDE.md
# UI テストの進め方

`testcases.md` のテストを実行してと言われたら、以下の手順で進めること。

## 道具

`UiBot/bin/Release/net8.0-windows/uibot.exe` (FlaUI の薄いラッパー)。
使い方は引数なしで実行すると表示される。推測でフラグ名を作らないこと。

- 起動: `uibot launch --path MiniRegister/bin/Release/net8.0-windows/MiniRegister.exe` → `pid` を控える
- 画面を見る: `uibot tree --pid <pid>` (ダイアログは `--window <タイトル>`)
- 操作: `click` / `type` / `key`
- 証跡: `uibot screenshot --pid <pid> --out results/<ID>.png`

## 1 ケースの流れ

1. 手順を読む。
2. 毎回 `tree` で **今の画面** を確認してから、手順に合う操作を選ぶ。
   手順の文言とボタン名が完全一致しなくても、人間のテスターと同じように解釈してよい。
3. 最後に `tree` と `screenshot` を取り、想定結果と **意味で** 比較して 合格/不合格 を判定する。
4. 結果を `results/report.md` に、ID・判定・実際に見えたもの・根拠 の形で書く。

## ケース間

アプリは再起動しない。ダイアログを閉じ、`全削除` を押して空のカートに戻してから次へ。
全ケース終了後に `close` する。

## 迷ったら

要素が見つからない等で判定に自信がない場合は、無理に 合格/不合格 を付けず「要確認」とし、
何を期待して何が見えたかを書くこと。

最後の「迷ったら」は地味に大事です。AI に無理やり白黒つけさせると、自信満々の誤判定が混ざります。
人間としては「要確認」が数件ある方が、全件を疑うよりずっと楽です。

5-3. testcases.md: テストケース

普段テスターさんが書いている形のままです。コードはありません。

testcases.md
# ミニレジ テストケース

商品マスタ: 1001 りんご ¥120 / 1002 バナナ ¥98 / 1003 牛乳 ¥198

| ID | 手順 | 想定結果 |
|----|------|----------|
| T1 | アプリを起動する | カートが空で、合計が ¥0 と表示されている |
| T2 | りんごとバナナを 1 つずつ登録する | カートに 2 件並び、合計が ¥218 になる |
| T3 | 存在しない商品コード 9999 を入力する | エラーメッセージが表示され、合計は変わらない |
| T4 | 牛乳を登録して会計する | 会計完了のメッセージに合計 ¥198 が表示され、閉じるとカートが空に戻る |
| T5 | カートが空のまま会計ボタンを押す | 会計できない (ボタンが押せない、またはエラーが表示される) |

T2 は「りんごとバナナ」としか書いていません。商品コードは Claude Code が商品マスタから引きます。
T5 の想定結果は「押せない または エラー」と曖昧です。従来の Assert では書きにくいタイプです。

6. 実行してみる

ビルドしてから、サンプルのフォルダで Claude Code を起動し、一言だけ頼みます。

> dotnet build MiniRegister -c Release
> dotnet build UiBot -c Release
> claude
> testcases.md のテストを実行して

Claude Code は CLAUDE.md を読み、uibot を叩き始めます。以下は実際に Claude Code が叩いたコマンドと出力です。

T2: りんごとバナナを登録

手順にはコードが書いていませんが、商品マスタから 1001 と 1002 を選んで入力しています。

> uibot type --pid 5128 --id CodeBox --value 1001
OK typed "1001"
> uibot key --key ENTER
OK pressed ENTER
> uibot type --pid 5128 --id CodeBox --value 1002
OK typed "1002"
> uibot key --key ENTER
OK pressed ENTER
> uibot tree --pid 5128
  ...
  Text "合計: ¥218" #TotalText
  ...
  List "" #CartList
    ListItem "1. りんご  ¥120"
      Text "1. りんご  ¥120"
    ListItem "2. バナナ  ¥98"
      Text "2. バナナ  ¥98"

T2.png

T3: 存在しないコード

面白かったのがここです。「合計は変わらない」を確かめるには、元の合計が ¥0 だと変わったかどうか分かりにくい。
そこで Claude Code は 自分の判断で先にりんごを 1 つ登録してから 9999 を入力しました。

> uibot click --pid 5128 --name 全削除
OK clicked "全削除"
> uibot type --pid 5128 --id CodeBox --value 1001
> uibot key --key ENTER
> uibot tree --pid 5128 | grep 合計
  Text "合計: ¥120" #TotalText
> uibot type --pid 5128 --id CodeBox --value 9999
> uibot key --key ENTER
> uibot tree --pid 5128
  Text "商品が見つかりません: 9999" #MessageText
  Text "合計: ¥120" #TotalText

T3.png

T4: 会計

ダイアログは別ウィンドウなので --window 会計完了 で中を見て、OK を押しています。

> uibot click --pid 5128 --name 会計
OK clicked "会計"
> uibot tree --pid 5128 --window 会計完了
Window "会計完了"
  Button "OK" #2
  Text "会計が完了しました。合計 ¥198" #65535
  TitleBar "" #TitleBar
    Button "閉じる" #Close
> uibot click --pid 5128 --window 会計完了 --name OK
OK clicked "OK"
> uibot tree --pid 5128 | grep 合計
  Text "合計: ¥0" #TotalText

T4_dialog.png

T4_after.png

T5: 空のカートで会計 → バグ発見

> uibot click --pid 5128 --name 全削除
> uibot tree --pid 5128 | grep -E "合計|会計"
  Text "合計: ¥0" #TotalText
  Button "会計" #CheckoutButton        ← [disabled] が付いていない = 押せる
    Text "会計"
> uibot click --pid 5128 --name 会計
OK clicked "会計"
> uibot tree --pid 5128 --window 会計完了
Window "会計完了"
  Button "OK" #2
  Text "会計が完了しました。合計 ¥0" #65535

T5.png

想定結果は「押せない、またはエラーが表示される」。どちらでもなく ¥0 の会計が成立したので、不合格 です。
仕込んだバグをちゃんと見つけてくれました。

結果レポート

最後に Claude Code が書いた results/report.md です。判定だけでなく 「何が見えたか」と「根拠」 が残るので、人間はスクショと照らし合わせて確認するだけで済みます。

ID 判定 実際に見えたもの 根拠
T1 合格 CartList に子要素なし、TotalText = 「合計: ¥0」 想定どおり空のカート・合計 ¥0
T2 合格 1. りんご ¥120 2. バナナ ¥98、合計 ¥218 マスタからコード 1001/1002 を使用。2 件・¥218 で一致
T3 合格 「商品が見つかりません: 9999」、合計 ¥120 のまま 「合計が変わらない」ことを確かめるため、先にりんごを 1 つ登録してから 9999 を入力した
T4 合格 ダイアログ「会計完了」に「会計が完了しました。合計 ¥198」、OK 後に合計 ¥0・カート空 金額・カートのクリアとも一致
T5 不合格 空のカートで 会計 ボタンが有効 ([disabled] なし)。押すと「会計が完了しました。合計 ¥0」 ボタンが押せず、エラーも出ず、¥0 の会計が成立してしまった。想定結果と不一致

注意点・向き不向き

  • 従来型の FlaUI テストを置き換えるものではありません。 毎回同じ結果が必要な回帰テストや CI は、従来のテストの方が速く・安く・確実です
  • 1 ケースごとに何度もコマンドを叩くので、人間のテスターよりは速いですが、従来のテストよりはかなり遅いです
  • 判定には揺らぎがあり得るので、「根拠」と「スクショ」を必ず残させ、人間が最終確認する前提で使っています
  • マウスで実際にクリックするので、画面ロック中や RDP 最小化中は動きません

実案件の POS レジでは、この仕組みに外部機器のモックなどを足して、テストケース表の手動テストのかなりの部分を Claude Code に任せられるようになりました。
「テストケース表はあるけど、自動化するほどの工数はかけられない」という画面系のテストには、かなり相性が良いと感じています。

構造

フォルダ 内容
MiniRegister/ テスト対象の小さな WPF アプリ (わざとバグ入り)
ClassicTests/ 従来型の FlaUI + xUnit テスト
UiBot/ Claude Code から呼ぶ FlaUI の薄い CLI (約 130 行)
CLAUDE.md / testcases.md Claude Code への指示と、日本語で書いたテストケース
results/ Claude Code が実行した結果 (レポートとスクショ)

まとめ

  • FlaUI は「手」としてはとても優秀。でも手順の長いテストを全部コードで書くのはつらい
  • FlaUI を 単純なアクション + UI ツリーの問い合わせ だけの CLI に包み、頭 (手順の解釈と判定) は Claude Code に任せる と、テストケース表をそのまま渡せる
  • テストケースごとのコードはゼロ。画面が少し変わっても、Claude Code が tree を見て合わせてくれる

はつかぜ株式会社では、IT学習や業務に役立つ情報を定期的にお届けしていきたいと思っています。
システム開発のお問い合わせ・ご相談はこちら

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?