[Hands-On Guide with Screenshots and Videos] Practical Test-Driven Development in C# Using Moq
[Screenshots & Video Tutorial] A Practical Guide to Test-Driven Development in C# with Moq
[Complete Tutorial with Screenshots and Videos] Practicing TDD in C# with Moq
スライドモード対応。折り畳みの展開は出来ないのでご注意ください。
私自身が動画やスクショがある記事じゃないと好きじゃないの(爆)で、後学のためにも執筆することにしました。
というか何でそれぐらいのことを誰もしないのかと聞いたら単に面倒だかららしい。
いいね3以下で削除。期限は1年後。
Gitリポジトリの追加
4いいね以上で追加
Visual Studio2026 .net10
Git : https://github.com/Sheephuman/WpfSheepAnimationMoqComparison/blob/master/README.md
以下続編
- 統合テストで実際に鳴るかどうかをテストしてみる
※3ストックで追加 - Moqと非Moqで比較してみる
※6ストックで追加
前提として
- WPFを使用し、Winformは一切仕様致しません(爆下げ)
- ゴリゴリViewModelとか使います
- .net10を使用し、レガシー環境には一切配慮致しません(どれぐらい後方互換があるかぐらいは提示致します)
はじめに:なぜ今、単体テストが必要なのか?
だって書くのめんどいじゃん
-
あなたはこんな経験ありませんか?※1
新機能追加したら、意図せず他の部分が壊れた
DBや外部APIに依存したコードをテストしようとしたら、テスト実行ごとに結果が変わる
リファクタリングしただけでテストが全部落ちてメンテナンス地獄
※1 僕の個人的な経験 サブWindowの動作テストにめっちゃ時間かかる(爆)
AIが書いた経験談とかはどうでもいいので(爆)、私の個人的な経験談を優先することにしました。
...まあネタ切れになったら採用はするかもしれないですが。
Case1 : Sub Windowに機能を追加したはいいけどテストにめっちゃ時間掛かっちゃう
テストしようと思ったら……ウィンドウを毎回手動で開く必要がある
実行ボタンを押すまでテストが始まらない
これはマジであるある だと思います。例えばですがゲーム開発者さんでも似たような経験はあるんじゃないでしょうかね。そのシーンまで行くのに時間が掛かるとか....
試しに即興でサブウィンドウにボタンと機能を追加してみる
とりあえず実行結果
Grokに作ってもらったヒツジ画を使い、サブWindow上でヒツジ絵をアニメーションさせます。
以下を参考にしました。
参考記事:[C#/WPF] Storyboardの動かし方(Startのさせ方) あれこれ
④ViewModel等のプロパティの変化でStartが特に単体テストの上では有用でしたが、実装をより簡単に書けるように簡略化してあります。
今回使用するフレームワーク
- Communitytoolkit.MVVM(https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/mvvm/) を使用することにしました。
Prismを使うまでもないという判断からです。
(というかまだ全部把握してない)
コマンド:https://www.nuget.org/packages/CommunityToolkit.Mvvm
dotnet add package CommunityToolkit.Mvvm --version 8.4.2
Sheepアニメーションを実装する
- AnimationViewModel.cs
Click to Open
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace WpfSheepAnimationMoqComparison.ViewModel;
public partial class AnimationViewModel : ObservableObject
{
private readonly ISoundService _soundService;
[ObservableProperty]
private bool isAnimationRunning = true; // 起動時はアニメON
public AnimationViewModel(ISoundService soundService)
{
_soundService = soundService;
}
// 画像クリックで音を鳴らす
[RelayCommand]
private void ImageClick()
{
_soundService.PlaySound("Sounds/click.wav"); // プロジェクトにSoundsフォルダ作成推奨
}
// アニメのON/OFFトグル
[RelayCommand]
private void ToggleAnimation()
{
IsAnimationRunning = !IsAnimationRunning;
}
}
- Inerfaceの追加
Click to Open
namespace WpfSheepAnimationMoqComparison.Interface
{
// ISoundService.cs
public interface ISoundService
{
void PlaySound(string soundFile);
}
// SoundService.cs(本番用)
public class SoundService : ISoundService
{
public void PlaySound(string soundFile)
{
if (System.IO.File.Exists(soundFile))
{
using var player = new System.Media.SoundPlayer(soundFile);
player.Play(); // 非同期で鳴らす(PlaySyncだとUIフリーズ注意)
}
}
}
}
- ViewModelLocator.cs
Click to open
using WpfSheepAnimationMoqComparison.Interface;
namespace WpfSheepAnimationMoqComparison.ViewModel
{
/// <summary>
/// ViewModelLocator(簡易版)
///
/// MVVMパターンにおいて、XAMLからViewModelを簡単に取得するためのクラスです。
///
/// 【役割】
/// - View(XAML)からViewModelを解決する「サービスロケーター」的な役割
/// - シングルトン的な挙動で同じViewModelインスタンスを共有
/// - 依存性注入(DI)の簡易版として機能(本格的なDIコンテナを使う前の段階でよく使われる)
///
/// 【使用例(XAML)】
/// <Window.Resources>
/// <vm:ViewModelLocator x:Key="SubLocator"/>
/// </Window.Resources>
///
/// <Window.DataContext>
/// <Binding Source="{StaticResource SubLocator}" Path="AnimationVM"/>
/// </Window.DataContext>
/// </summary>
public sealed class ViewModelLocator
{
/// <summary>
/// AnimationViewModelのインスタンスを保持するプライベートフィールド
/// null許容型(?)を使用し、遅延初期化(Lazy Initialization)に対応
/// </summary>
private AnimationViewModel? _main;
/// <summary>
/// Mainプロパティ
///
/// このプロパティにアクセスした時点でAnimationViewModelが生成されます。
/// C# 8.0以降の「Null-coalescing assignment operator(??=)」を使って
/// 遅延初期化(Lazy Initialization)を実現しています。
///
/// 一度生成されたインスタンスは再利用され、シングルトン的な挙動になります。
/// </summary>
public AnimationViewModel Main =>
_main ??= new AnimationViewModel(new SoundService());
/// <summary>
/// AnimationVMプロパティ
///
/// XAMLからバインドされやすい名前(AnimationVM)を提供。
/// 内部的には Main プロパティと同じインスタンスを返します。
///
/// 【理由】
/// - XAML側で Path="AnimationVM" と記述しやすくするため
/// - コードの可読性向上(Main という名前より意図が明確)
/// </summary>
public AnimationViewModel AnimationVM => Main;
// ===================================================================
// 将来的に拡張する場合の例(コメントのみ)
// ===================================================================
// public AnimationViewModel SubWindowVM => _sub ??= new AnimationViewModel(new SoundService());
// private AnimationViewModel? _sub;
}
}
実装と動作確認が終わったら、次は機能を追加してみませう。
既存のアニメーション実装に [Clickすると音声が鳴る] 実装を追加します。
単体テストプロジェクトを作成する
[Clickすると音声が鳴る] 追加にあたり、機能をいきなり追加してもいいのですが、今回は単体テストをテーマとしているのでテストプロジェクトを作成します。
今回は[ひつじアニメーション]にClickするとWavサウンドが鳴るという機能を追加します。
次に
テストプロジェクト内にxUnitとmoqをインストールしてください。
本番用のProJectにインストールしないでください。
- moq(https://www.nuget.org/packages/moq/)
パッケージマネージャコンソールで
Install-Package Moqするか
「ソリューションのnuget Packageの管理」でmoqを選択します。
NuGet\Install-Package Moq -Version 4.20.72

10億回以上downloadされています(1.09B回download)。
- xunit(https://www.nuget.org/packages/xunit)
moqと併せて良く採用される定番のテストフレームワークで、[fact]属性などを認識
NuGet\Install-Package xunit -Version 2.9.3
Test ExplorerでxUnitテストを実行するには、以下のパッケージが必要です(もちろんソリューション管理側で検索しても出ます)。
実装用のプロジェクトに入れないで
→間違って入れた場合はアンインストールしてください。
必ずテストプロジェクトに入れること
Package Manager Console で
Install-Package xunit.runner.visualstudio
# または dotnet CLI で
dotnet add package xunit.runner.visualstudio
test内容を実装する
テストしているもの(ロジック):
- ImageClick メソッドが正しく呼ばれるか
- その中で soundService.PlaySound() が正しい引数で呼ばれるか
-
クリック座標が正しいか→ Click座標は意図して省かれています。これはUIに関わる部分は一切テストしない というテスト駆動開発の文化、ないし考え方によるものです。
- AnimationViewModelTests.cs
Click to Open
using Moq;
using WpfSheepAnimationMoqComparison;
using WpfSheepAnimationMoqComparison.Interface;
using WpfSheepAnimationMoqComparison.ViewModel;
using Xunit;
namespace ClickLambVoiceTest.Tests
{
/// <summary>
/// AnimationViewModel のユニットテストクラス
///
/// 【テストの目的】
/// - ViewModelのコマンド(ImageClickCommand / ToggleAnimationCommand)が正しく動作するか検証
/// - ISoundServiceのモックを使って外部依存(音声再生)を分離し、純粋にViewModelのロジックをテスト
/// - Moq + xUnit を使用した典型的なMVVM ViewModelテスト例
///
/// 【テスト方針】
/// - コンストラクタでMoqを使ってISoundServiceをモック化
/// - 各テストは独立性が高く、状態をクリーンに保つ
/// </summary>
public sealed class AnimationViewModelTests
{
/// <summary>
/// ISoundServiceのモックオブジェクト
/// 実際の音声再生を呼ばずに、呼び出し確認(Verify)を行うために使用
/// </summary>
private readonly Mock<ISoundService> _soundMock;
/// <summary>
/// テスト対象のViewModelインスタンス
/// 各テストで共通して使用する
/// </summary>
private readonly AnimationViewModel _vm;
/// <summary>
/// テストクラスのコンストラクタ
/// xUnitではテストクラスがインスタンス化されるたびに1回だけ実行されます。
/// </summary>
public AnimationViewModelTests()
{
// ISoundServiceをモック化
_soundMock = new Mock<ISoundService>();
// モックを注入してViewModelを生成
// これにより実際の音声ファイル再生をテスト実行時に行わなくて済む
_vm = new AnimationViewModel(_soundMock.Object);
}
/// <summary>
/// ImageClickCommandを実行すると、指定された音声ファイルの再生が1回呼ばれることを検証
/// </summary>
[Fact]
public void ImageClick_ShouldCallPlaySound()
{
// Act(実行)
// ImageClickCommandを実行(引数はnullでOK)
_vm.ImageClickCommand.Execute(null);
// Assert(検証)
// PlaySoundメソッドが「Sounds/sheep_bleat.wav」という引数で、ちょうど1回だけ呼ばれたことを確認
_soundMock.Verify(
s => s.PlaySound("Sounds/sheep_bleat.wav"),
Times.Once(),
"ImageClickCommand実行時に sheep_bleat.wav を1回だけ再生するはずです。");
}
/// <summary>
/// ToggleAnimationCommandを実行すると、IsAnimationRunningのbool値が反転することを検証
/// </summary>
[Fact]
public void ToggleAnimation_ShouldFlipIsAnimationRunning()
{
// Arrange(準備)
// 初期状態を記録
var initial = _vm.IsAnimationRunning;
// Act(実行)
_vm.ToggleAnimationCommand.Execute(null);
// Assert(検証)
// 初期値と現在の値が異なることを確認(=反転した)
Assert.NotEqual(initial, _vm.IsAnimationRunning);
}
// ===================================================================
// 以下に追加でよく書くテスト例(参考・今後拡張用)
// ===================================================================
// [Fact]
// public void IsAnimationRunning_WhenToggled_ShouldRaisePropertyChanged()
// {
// var eventRaised = false;
// _vm.PropertyChanged += (s, e) =>
// {
// if (e.PropertyName == nameof(AnimationViewModel.IsAnimationRunning))
// eventRaised = true;
// };
//
// _vm.ToggleAnimationCommand.Execute(null);
//
// Assert.True(eventRaised, "IsAnimationRunning変更時にPropertyChangedイベントが発行されていません。");
// }
}
}
testを実行する
errorの対処
- Microsoft.NETCore.App.Runtime.win-x64 のランタイム パックがダウンロードされませんでした。RuntimeIdentifier 'win-x64' で NuGet 復元を実行してみてください。
参考URL
Clearing NuGet Caches
# 1. キャッシュをクリアして復元
dotnet nuget locals all --clear
# 2. RuntimeIdentifierを指定して復元
dotnet restore --runtime win-x64
# 3. ソリューション全体をリビルド
dotnet build --runtime win-x64
- Namespace "WpfSheepAnimationMoqComparison.UnitTest" はフォルダー構造と一致しません。"ClickLambVoiceTest.UnitTest" が必要です
名前空間をフォルダ構造と一致させる必要があった。
Testに失敗する
問題のまとめと対応表
1. 発生した主なエラーと原因
※膨大なログをAIに解析させた
| エラー内容 | 原因 | 詳細説明 |
|---|---|---|
| Could not find testhost(WpfSheepAnimationMoqComparison) | WPFのWinExeに対してVSTestがdotnet test用のtesthostを探している | テスト用のMicrosoft.NET.Test.Sdk構成ではないため失敗 |
| xunit.execution.dotnet.dll が見つからない | xUnit用の実行アセンブリが検出出力と一致しない | WinUIのAppXレイアウト等と噛み合っていない |
| ClickLambVoiceTest.exe で使用できるテストはありません | WinUIアプリ本体exeをテストホストとして起動している | xUnitのテストDLLではないため、検出・実行の役割が一致しない |
| Could not determine target device configuration (COMException) | Visual Studio側のデバイス設定読み込み時の不整合 | テストロジック自体とは無関係なエラーが多い |
2. 実施した対応
| 対応項目 | 内容 |
|---|---|
| テストプロジェクトの分離 |
ClickLambVoiceTest.Tests(Microsoft.NET.Test.Sdk + xUnit + Moq)を新規作成ViewModelの単体テストをすべてこちらへ移行 |
| 動作確認 |
dotnet test で2件成功を確認AnimationViewModelに合わせて音ファイル検証を Sounds/sheep_bleat.wavに修正 |
| 本プロジェクトのクリーンアップ |
ClickLambVoiceTest(WinUI本体)からxUnit / NUnit / Moq と TestContainer機能をすべて削除 (混在がテスト探索を破壊していたため) |
| MSTest関連の残置 |
UnitTestApp.xaml.csが必要とするMSTest + TestHostは残してビルドを通す |
| プロジェクト設定変更 |
WpfSheepAnimationMoqComparison.csproj に IsTestProject = false を明示 |
| ソリューション更新 |
WpfSheepAnimationMoqComparison.slnx に ClickLambVoiceTest.Tests を追加 |
| 不要ファイル削除 | 空の UnitTests.cs と移設済みの UnitTest/AnimationViewModelTests.cs を削除 |
3.対応 まとめ
本実装のProjectにMoqやXunitを入れてはいけない。
TestProject内の名前空間とフォルダ構造も一致させる必要がある。
これでTestが正常に完了した。
FAQ(AI出力)
Q.Test Code内で座標が反映されていない
「座標が反映されていない」は意図的な設計です。
現在のテストコードでは
_vm.ImageClickCommand.Execute(null); // ← ここで座標情報は一切渡していない
これは意図的に座標(MouseButtonEventArgs などの位置情報)を無視しています。
正しい考え方単体テストの目的:ViewModelのロジックだけを検証すること
検証したいこと:ImageClickCommand が実行されたときに、PlaySound() が正しく呼ばれるかどうか
検証したくないこと:実際のマウスクリック位置、座標、ボタンの状態など UIレイヤーの詳細
→ だから Execute(null) で十分です。
Q. テストは通るようになったが、本実装にスムーズに移行できるのか?
はい、非常にスムーズに移行できます。
むしろこの構成にしたことで移行がかなり楽になります。
移行の流れ(実際の開発でよくやる順番)ViewModelの単体テストが全部通る(現在完了)
→ 本番のWPFプロジェクトにViewModelを組み込む
→ AnimationViewModel をメインアプリのDIコンテナに登録
→ XAML側で DataContext にバインド
→ ImageClickCommand と ToggleAnimationCommand をBinding
移行がスムーズな理由:ViewModelは純粋なC#クラス(POCO寄り)なので、UIに依存していない
依存関係は ISoundService だけ → 本番用 SoundService をDIで切り替えるだけ
[ObservableProperty] と [RelayCommand] はそのまま使える
Q. エラーが頻出し対応を迫られたが、割に合うのか?
最初は割に合わないと感じるかもしれませんが、長期的に見ると「大幅に割に合います」。
A. テストは通るようになったが、本実装にスムーズに移行できるのか?
はい、非常にスムーズに移行できます。
むしろこの構成にしたことで移行がかなり楽になります。
移行の流れ(実際の開発でよくやる順番)
- ViewModelの単体テストが全部通る(現在完了)
-
本番のWPFプロジェクトにViewModelを組み込む
-
AnimationViewModelをメインアプリのDIコンテナに登録 - XAML側で
DataContextにバインド -
ImageClickCommandとToggleAnimationCommandをBinding
-
-
動作確認
- 画像クリック → 音が鳴る
- トグルボタン → アニメーション開始/停止
移行がスムーズな理由:
- ViewModelは純粋なC#クラス(POCO寄り)なので、UIに依存していない
- 依存関係は
ISoundServiceだけ → 本番用SoundServiceをDIで切り替えるだけ -
[ObservableProperty]と[RelayCommand]はそのまま使える
実際に私がこれまで作ったサンプルでも、テスト通過後 → 本実装への移行は30分以内で完了することがほとんどです。
B. エラーが頻出し対応を迫られたが、割に合うのか?
最初は割に合わないと感じるかもしれませんが、長期的に見ると「大幅に割に合います」。
現実的なコスト・リターン分析
| 項目 | 初期コスト | 長期的なリターン |
|---|---|---|
| エラー対応時間 | 高い(今回のようにセットアップでハマる) | かなり低い(慣れればほぼゼロ) |
| テスト作成時間 | 普通 | 後で仕様変更時の修正が劇的に楽 |
| デバッグ時間 | 減る | 大幅に減少 |
| コードの信頼性 | - | 大幅に向上 |
| 保守性 | - | 大幅に向上 |
特にあなたのケースで割に合うポイント:
- 「画像クリック → 音が鳴る」という副作用のある処理は、手動テストだけだと「音が鳴ったかどうか」を毎回確認する必要があり面倒
- テストを書いたおかげで 「ロジックは大丈夫」という確信を持ってViewに組み込める
- 今後「連打対策を追加したい」「ログを出したい」となったときに、テストを少し修正するだけで済む
テストを実行しても実際に音が鳴ったりはしないようだが、やはりUIでやるしかない?
はい、その通りです。
単体テストでは音が鳴らないのが正しい動作です。
なぜ単体テストで音が鳴らないのか?
あなたが書いているテストは以下のようにモックを使っています:
var soundMock = new Mock<ISoundService>();
var vm = new AnimationViewModel(soundMock.Object);
vm.ImageClickCommand.Execute(null);
soundMock.Verify(s => s.PlaySound("Sounds/click.wav"), Times.Once());
-
ISoundServiceは本物のSoundServiceではなく**偽物(モック)**になっている - モックは「
PlaySoundが呼ばれた」という事実だけを記録する -
実際の
SoundPlayer.Play()は実行されない(意図的)
各テストの役割と音の鳴らし方
| テストの種類 | 音が鳴るか | 目的 | おすすめのやり方 |
|---|---|---|---|
| 単体テスト (ViewModel) | 鳴らない | ロジックが正しく動くか | MoqでVerify確認(現在やっている方法) |
| 統合テスト | 鳴らせる | ViewModel + Serviceの連携 | 本物の SoundService を使ってテスト |
| UIテスト / 手動確認 | 鳴る | 実際のユーザー操作で確認 | 普通にアプリを起動してクリック |
実際に音を鳴らしてテストしたい場合の方法
- 統合テストとして別テストを作る(おすすめ)
[Fact]
public void ImageClick_ShouldActuallyPlaySound()
{
var realService = new SoundService(); // 本物を使う
var vm = new AnimationViewModel(realService);
vm.ImageClickCommand.Execute(null);
// ここではVerifyではなく、実際に鳴ったことを人間が確認するか、
// 音声ファイルの再生時間を待つなどの工夫が必要
Assert.True(true); // 音が鳴ったことを目視 or ログで確認
}
-
手動テスト / UIテスト
- アプリを普通に起動(F5)
- 画像をクリックして音が鳴るかを確認
まとめ
- 単体テスト → 音は鳴らさない(これが正しい)
- 実際に音を聞きたい → UIで起動するか、統合テストで本物を使う
単体testをパスしたので、本番Projectに適用してみる
実際に追加された機能
- 本番プロジェクトへの適用手順
準備(1分)本番プロジェクト(WpfSheepAnimationMoqComparison)に以下のNuGetを追加powershell
Install-Package CommunityToolkit.Mvvm は適用済みとして
Install-Package Microsoft.Extensions.DependencyInjection
を入れます。
テストプロジェクト内のAnimationViewModelTests.cs を本番プロジェクトの ViewModels フォルダにコピー(または移動)
15分程度。
// 画像クリックで音を鳴らす
[RelayCommand]
private void ImageClick()
{
_soundService.PlaySound("Sounds/sheep_bleat.wav"); // プロジェクトにSoundsフォルダ作成推奨
}
using System.IO;
using System.Media;
using System.Windows;
using System.Windows.Resources;
namespace WpfSheepAnimationMoqComparison.Interface
{
public interface ISoundService
{
void PlaySound(string soundFile);
}
public sealed class SoundService : ISoundService
{
public void PlaySound(string soundFile)
{
if (string.IsNullOrWhiteSpace(soundFile))
{
return;
}
if (TryPlayFromFile(soundFile))
{
return;
}
var baseDir = Path.Combine(AppContext.BaseDirectory, soundFile);
if (TryPlayFromFile(baseDir))
{
return;
}
TryPlayFromPackResource(soundFile);
}
private static bool TryPlayFromFile(string path)
{
if (!File.Exists(path))
{
return false;
}
// SoundPlayer.Play() は非同期で、using で即 Dispose すると再生が始まらないことがある。
// UI の短い効果音は PlaySync で確実に鳴らす。
using var player = new SoundPlayer(path);
player.PlaySync();
return true;
}
private static void TryPlayFromPackResource(string soundFile)
{
try
{
var relative = soundFile.Replace('\\', '/');
var uri = new Uri($"pack://application:,,,/{relative}", UriKind.Absolute);
StreamResourceInfo? info = Application.GetResourceStream(uri);
if (info?.Stream == null)
{
return;
}
using var copy = new MemoryStream();
info.Stream.CopyTo(copy);
copy.Position = 0;
using var player = new SoundPlayer(copy);
player.PlaySync();
}
catch
{
// 無音で失敗してもアプリは継続
}
}
}
}
音が鳴らない場合
原因がWavファイルそのものにあるのかロジックにあるのか切り分け。
どうもWaveファイルそのものにあったようなので、これを再生可能な形式にAudioCity(https://www.audacityteam.org/) で変換しました。
音がなるようになりました(前記した動画参照)。
統合テストで実際に鳴るかどうかをテストしてみる
※3ストックで追加
Moqと非Moqで比較してみる
※6ストックで追加
あとがき
僕としてもテスト駆動開発は最後のワンピースみたいな感があったんで、どうしても書いておこうと思ったというか。
例によってググって出る奴って殆どチラ裏みたいな代物で、Codeだけみても全くイメージ湧かないと思うんですよね。
実のところ、慣れるまでが結構な手間です。
初見・初作業ではあるあるですが。
WPFと同じで「慣れれば楽になる」って類だと思います。








