Claude Media
Claude CodeでWPFアプリを作る — dotnet buildとViewModelテストで検証する

Claude CodeでWPFアプリを作る — dotnet buildとViewModelテストで検証する

Claude CodeはWPFの画面を見られません。ViewModelを別プロジェクトに切り出し、dotnet buildとdotnet testを完了条件にする設計と、XAMLのBinding誤記を拾うテストを示します。

Claude CodeにWPFアプリを任せるときの最大の制約は、出来上がった画面をClaudeが目で確かめられないことです。ボタンが押せるか、文字が切れていないかは、ターミナルの出力には現れません。

そこで検証の足場を画面の外に置きます。ロジックはViewModelに寄せて単体テストで確かめ、画面側はdotnet buildが通ることとBindingの名前の整合までを機械で確かめます。残る見た目の確認は人が1回見る、という分担です。

WPFは何をWindows上で、何を画面なしで確かめられるか

WPFは.NET上で動きますが、動くのはWindowsだけです。ドキュメントにも「.NETはクロスプラットフォームだが、WPFはWindowsでしか動かない」と明記されています。

プロジェクトファイルの要点は3つです。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
  </PropertyGroup>
</Project>

UseWPFをtrueにすると、WPFライブラリへの参照が入り、MSBuildのパイプラインもWPFのファイルを処理する形に変わります。このとき対象にできるのはWindowsだけです。

この制約が設計に効きます。検証の層ごとに「画面を出さずにできるか」を分けると、次のようになります。

確かめたいこと画面なしでできるか手段
XAMLとC#がコンパイルできる画面なしでできるかできる(Windows上のビルド)手段dotnet build
ボタンの処理や計算の結果画面なしでできるかできる手段ViewModelの単体テスト
BindingのパスがViewModelにある画面なしでできるか工夫すればできる手段XAMLを読むテスト(後述)
レイアウトの崩れ・色・フォーカス移動画面なしでできるかできない手段人が起動して目で見る

見た目だけが人の領域に残ります。Claudeに任せる範囲は、上の3行までです。

この表は、dotnetがWindows上で動く前提です。Claude CodeはネイティブWindowsでも、WSLの中でも動かせます。ネイティブWindowsは、Git for Windowsを入れればBashツールを、入れなければPowerShellツールを使います。WSLは、Linux側にインストールしてclaudeもそこで起動する形です。この記事のdotnet buildとdotnet testは、ネイティブWindowsで動かす構成を想定しています。macOSやLinuxからWPFのビルドを回す構成は扱いません。

ロジックをViewModelに寄せて別プロジェクトにする

画面を見られないなら、画面に載るロジックを画面から切り離します。WPFのコードビハインドは、x:ClassでXAMLと結び付いたクラスで、ボタンのクリックなどに反応する処理を書く場所です。ここに計算や状態を書くと、テストするにはウィンドウを生成するしかなくなります。

構成は次の3プロジェクトです。

src/
  MyApp.ViewModels/   net8.0 のクラスライブラリ(WPFを参照しない)
  MyApp/              net8.0-windows の WPF アプリ(ViewModels を参照)
tests/
  MyApp.Tests/        ViewModels を参照するテスト

ViewModelsにはWPFの型を一切持ち込みません。WPFの画面は、データバインディングでViewModelの値を表示し、編集をViewModelに書き戻すだけの薄い層になります。バインディングは、コントロール(バインディングの対象)とデータ(ソース)を結ぶ仕組みで、マークアップに{Binding ...}と書いて宣言します。

ソースの変更を画面に反映するには、ソースがINotifyPropertyChangedのような変更通知の仕組みを実装している必要があります。自前で書くと定型コードが多いので、Microsoftが保守するCommunityToolkit.MvvmのObservableObjectを使います。このパッケージは.NET Standardを対象にしていて、WPFを含む複数のUIフレームワークで使えます。

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
 
namespace MyApp.ViewModels;
 
public class CounterViewModel : ObservableObject
{
    private int count;
 
    public CounterViewModel()
    {
        IncrementCommand = new RelayCommand(Increment, () => Count < 10);
    }
 
    public int Count
    {
        get => count;
        private set
        {
            if (SetProperty(ref count, value))
                IncrementCommand.NotifyCanExecuteChanged();
        }
    }
 
    public RelayCommand IncrementCommand { get; }
 
    private void Increment() => Count++;
}

SetPropertyは値が変わったときに変更通知を発行します。RelayCommandはコマンドの実行可否が変わったことをNotifyCanExecuteChangedで知らせます。どちらもToolkitのドキュメントに載っている使い方です。

ViewModelをdotnet testで確かめる

このViewModelは、ウィンドウを作らずにテストできます。

using MyApp.ViewModels;
using Xunit;
 
public class CounterViewModelTests
{
    [Fact]
    public void Incrementで1増える()
    {
        var vm = new CounterViewModel();
        vm.IncrementCommand.Execute(null);
        Assert.Equal(1, vm.Count);
    }
 
    [Fact]
    public void 上限の10に達するとコマンドが無効になる()
    {
        var vm = new CounterViewModel();
        for (var i = 0; i < 10; i++) vm.IncrementCommand.Execute(null);
        Assert.False(vm.IncrementCommand.CanExecute(null));
    }
}

実行はdotnet testです。このコマンドはソリューションをビルドしたうえでテストを走らせるので、コンパイルエラーとテスト失敗が1回の出力に出ます。Claudeにはこの出力だけを読ませれば足ります。

dotnet build MyApp.sln --nologo -v q
dotnet test tests/MyApp.Tests --nologo

テストの書き方も指示に含めます。「ボタンを押したらこの状態になる」という受け入れ条件を、テストの形でClaudeに先に書かせる流れが安定します。実装を書く前にテストを書かせ、失敗を確認してから実装に進ませる順序です。

XAMLのBinding誤記は、テストで拾う

dotnet buildが通っても安心できない箇所があります。Bindingのパスです。

{Binding Count}のCountは、マークアップ内の単なる文字列です。バインディングはDataContext(データの既定の取り出し元)を見て解決されるので、パスに誤記があっても、コンパイルの段階ではエラーになりにくい箇所です。実行して画面を開いたときに、表示が空のまま気づかれずに残ります。ここは画面を見られないClaudeが最も取りこぼしやすい場所です。

対策は、XAMLの{Binding ...}を機械的に読み、ViewModelに同名のプロパティがあるかを確かめるテストを足すことです。次は考え方を示す最小の例です。公式のサンプルではなく、筆者が組んだ書き方です。

using System.Text.RegularExpressions;
using MyApp.ViewModels;
using Xunit;
 
public class BindingPathTests
{
    [Fact]
    public void XamlのBindingパスがViewModelに存在する()
    {
        var xaml = File.ReadAllText(
            "../../../../../src/MyApp/MainWindow.xaml");
        var paths = Regex.Matches(xaml, @"\{Binding\s+(?:Path=)?(\w+)")
            .Select(m => m.Groups[1].Value)
            .Distinct();
 
        foreach (var path in paths)
            Assert.True(
                typeof(CounterViewModel).GetProperty(path) != null,
                $"ViewModelに {path} がありません");
    }
}

制約も明らかです。DataTemplateの中のBindingは、DataContextが別のオブジェクトになるため、正規表現では区別できず誤検知します。画面ごとに検査の対象を絞るか、テンプレート内を除外する工夫が要ります。あくまで「手の届く範囲の誤記だけを拾う網」です。

落とし穴

パスは合っているのに、入力がViewModelに戻らない

BindingのModeやUpdateSourceTriggerの指定、コンバーターの設定は、パス名のテストでは見えません。TwoWayで書き戻すか、OneWayで表示だけかは、既定がプロパティごとに違います。編集フォームを作らせたら、書き戻しの挙動だけは人が1回触って確認します。

CLAUDE.mdで完了条件を固定する

「ビルドが通ったら完了」とClaudeが判断しないよう、プロジェクト直下のCLAUDE.mdに検証コマンドを書きます。

# WPFアプリ開発のルール
 
- ロジックは src/MyApp.ViewModels に書く。WPFの型を参照しない
- コードビハインドには画面固有の処理(フォーカス等)だけを書く
- 変更後は `dotnet build MyApp.sln` と `dotnet test` を実行し、
  両方が成功するまで完了と報告しない
- XAMLに新しい {Binding X} を足したら、ViewModelにXを足す
- レイアウトと見た目は確認できない。変更点を箇条書きで報告し、
  目視確認が必要な箇所を明示する

最後の行が実務で効きます。見られないことを前提に、確認を人に返す項目を出させる指示です。

buildとtestを権限とフックで回す

毎回の確認ダイアログを減らすため、検証用のコマンドだけ許可します。ルールはサブコマンドの後ろに*を置く形です。

{
  "permissions": {
    "allow": [
      "Bash(dotnet build *)",
      "Bash(dotnet test *)"
    ]
  }
}

さらに、ファイル編集のたびにビルドさせるなら、PostToolUseフックをEdit|Writeで絞ります。ただし、WPFのビルドは編集のたびに回すと重くなりがちです。テストの自動実行をどのイベントに載せるかは、PostToolUseとStopの使い分けで整理しています。型検査を完了判定にする同じ考え方は、React Router v7のtypegenの記事にもあります。

手順

1回の依頼の流れ

  1. 1

    テストを先に書かせる

    受け入れ条件をViewModelのテストにします。

  2. 2

    実装させる

    ViewModelとXAMLを書かせ、Bindingの名前を揃えさせます。

  3. 3

    build と test を実行させる

    失敗の出力を読ませて直させ、両方が成功するまで繰り返します。

  4. 4

    目視項目を受け取る

    Claudeが挙げた確認項目を、人が実機で見ます。

見た目を直接見られない分、XAMLの書き方で崩れにくくする

画面を確かめられない以上、崩れにくい書き方をあらかじめ指定しておくほうが安全です。WPFのレイアウトは、ウィンドウサイズや表示設定の変化に合わせて、親と子が場所と大きさを折衝する相対配置が土台です。

レイアウト用の部品には、Canvas、DockPanel、Grid、StackPanel、VirtualizingStackPanel、WrapPanelがあります。このうちCanvasは、子のコントロールが自分で位置を決める部品です。座標を直書きしがちなので、Claudeに任せる画面はGridとStackPanelを基本にするよう指示します。

- 画面のレイアウトは Grid と StackPanel で組む
- Canvas と固定のWidth/Heightは使わない(理由を書いた場合のみ可)
- 文字列はリソースにせず直書きでよいが、色はStyleにまとめる

もう一つ、Bindingの向きも指示に含めます。OneWayはソースの変更を画面に反映するだけで、画面側の編集はソースに戻りません。TwoWayは両方向に反映するので、編集できるフォームに向きます。多くのプロパティは既定がOneWayですが、TextBoxのTextのように既定が双方向のものもあります。どれが既定かはプロパティごとに違うため、フォームを作らせるときはModeを明示させ、書き戻しを確かめるテストをViewModel側に置きます。

[Fact]
public void 名前を設定すると変更通知が飛ぶ()
{
    var vm = new ProfileViewModel();
    var changed = new List<string?>();
    vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
 
    vm.Name = "Taro";
 
    Assert.Contains(nameof(vm.Name), changed);
}

通知が飛ぶことをテストで確かめておくと、SetPropertyの呼び忘れという典型的な失敗を、画面を開かずに検出できます。

つまずきやすい点

ViewModelにMessageBoxやWindowを書かせてしまう

テストが走らなくなります。ダイアログを出す処理は、ViewModelが呼ぶインターフェース(例: IDialogService)の背後に置き、テストでは偽物に差し替えます。CLAUDE.mdの「WPFの型を参照しない」は、この予防です。ViewModelsプロジェクトがWPFに依存していなければ、WPFの型を書いた時点でビルドが落ちます。

プロジェクトの対象フレームワークを間違える

WPFアプリはnet8.0-windowsのようなWindows固有の指定が必要です。ViewModelsにUseWPFを付けたりnet8.0-windowsにしたりすると、WPFの型を持ち込める余地が生まれ、切り分けの意味が薄れます。素のnet8.0にしておけば、MessageBoxなどを書いた時点でビルドが落ちます。

コードビハインドを消さずに足し続ける

InitializeComponentはビルド時に生成され、XAMLとコードビハインドを結びます。ボタンのClickをコードビハインドのメソッドに結ぶ書き方は動きますが、テストできません。コマンドとBindingに置き換える方針をCLAUDE.mdに入れておくと、Claudeがイベントハンドラを増やし続けるのを防げます。

MAUIとの違い

同じ.NETの画面系でも、MAUIは複数OS向けのビルドと環境整備が主題になります。その手順はClaude CodeでMAUIアプリを開発するにあります。WPFはWindows専用なので、悩みは環境よりも「画面を見られない」ことに集まります。ここで挙げた設計は、WPFに限らずXAMLを使う他のUIにも持ち込めます。

まとめ

画面が見られないClaudeには、画面の外に検証の足場を置きます。ロジックはViewModelに寄せてdotnet testで確かめ、XAMLはdotnet buildとBindingパスのテストで守ります。見た目だけは人が見ます。

最初の一手は、CLAUDE.mdに「ビルドとテストが両方通るまで完了としない」の1行を入れることです。その1行で、Claudeの「できました」の意味が変わります。

この記事を共有:XはてブLinkedIn