概要
これはもともとあったMVVMなWPFアプリに自動テストとDependency-Injection(DI)とCI/CDを実装してみた記録です。変更点はWPFとはあまり関係ないので、C#であれば他のフレームワークにも参考になるかもしれません。
今回は自動テストの追加までです。
WPFアプリの中身はファイルリネーマーです。アプリの詳細はこちらで。コード行数850行ぐらい、クラス数40個ぐらいのサンプルアプリに毛の生えたぐらいのコードサイズです。
TestフレームワークはxUnit、DIはMicrosoft.Extensions.DependencyInjectionを使用しました。
自動テストの導入
自動テストプロジェクトの追加
WPFのアプリを含んだソリューションにxUnitのプロジェクトを追加します。
WPFアプリをテストするために、テストプロジェクトの.csprojファイルを編集します。
TargetFramework
をnet5.0-windows
に、UseWPF
を追加しました。
そしてテストしたいプロジェクトへの参照も追加します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
...
</ItemGroup>
</Project>
👇
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
...
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileRenamerDiff\FileRenamerDiff.csproj" />
</ItemGroup>
</Project>
テストを書きやすくするために、nugetでFluentAssertionsパッケージも入れて、ついでに既存のパッケージも更新しておきます。
テストする対象にinternalメソッド・プロパティなどがある場合は、テストされる側のプロジェクト(WPFアプリ側)に、単体テストプロジェクトへの許可を書く必要があります。
AssemblyInfo.csを新しく作る方法もありますが、csprojに以下の5行を足すほうが簡単です。
...
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
...
VisualStudioを使っているなら、テストエクスプローラーを表示しておきます。
プロジェクト生成時にデフォルトで以下のようなテストが作成されます。
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
ここにもろもろ足していきます。
この状態で試しにテスト実行すると、デフォルトのテストが成功します。

これで自動テストを書く準備ができました。
テストできるパターン
まず、この段階でもテストできるパターンを書いてみます。
以下のようなクラスをテストします。
public class ValueHolder<T> : NotificationObject
{
private T _Value;
public T Value
{
get => _Value;
set => RaisePropertyChangedIfSet(ref _Value, value);
}
public ValueHolder(T value)
{
this._Value = value;
}
}
public static class ValueHolderFactory
{
public static ValueHolder<T> Create<T>(T value) => new(value);
}
ValueHolder
クラスは値を内部に値を一つ保持して、変更されたらPropertyChanged
イベントを発生させるだけのクラスです。いわば、ReactiveProperty
からreactive要素を抜いたようなものです。
LivetのNotificationObject
を継承することで、INotifyPropertyChanged
の通知が使えるようになっています。
このクラスは他のクラスに依存していないので、この段階でもテストすることができます。
[Fact]
public void Test_ValueHolder()
{
var queuePropertyChanged = new Queue<string?>();
var holder = ValueHolderFactory.Create(string.Empty);
holder.PropertyChanged += (o, e) => queuePropertyChanged.Enqueue(e.PropertyName);
holder.Value
.Should().BeEmpty("初期値は空のはず");
queuePropertyChanged
.Should().BeEmpty("まだ通知は来ていないはず");
const string newValue = "NEW_VALUE";
holder.Value = newValue;
holder.Value
.Should().Be(newValue, "新しい値に変わっているはず");
queuePropertyChanged.Dequeue()
.Should().Be(nameof(ValueHolder<string>.Value), "Valueプロパティの変更通知があったはず");
}
ValueHolder
の変更通知を貯めておいて、変更前後に適切な通知が来るか確認しています。
テスト実行して、テストが成功したことを確認します。
FluentAssertionsを使うことでTest対象.Should().Be..
のようにテストしたい対象に対して、メソッドチェーンにテストが書けます。
テストできないパターン
次に以下のようなクラスをテストします。
/// <summary>
/// リネーム前後のファイル名を含むファイル情報モデル
/// </summary>
public class FileElementModel : NotificationObject
{
private readonly FileSystemInfo fsInfo;
/// <summary>
/// リネーム前 フルファイルパス
/// </summary>
public string InputFilePath => fsInfo.FullName;
/// <summary>
/// リネーム前 ファイル名
/// </summary>
public string InputFileName => fsInfo.Name;
private string outputFileName = "--.-";
/// <summary>
/// リネーム後 ファイル名
/// </summary>
public string OutputFileName
{
get => outputFileName;
set => RaisePropertyChangedIfSet(ref outputFileName, value, new[] { nameof(IsReplaced) });
}
/// <summary>
/// リネーム後 ファイルパス
/// </summary>
public string OutputFilePath => Path.Combine(DirectoryPath, outputFileName);
/// <summary>
/// リネーム前後で変更があったか
/// </summary>
public bool IsReplaced => InputFileName != OutputFileName;
/// <summary>
/// ファイルの所属しているディレクトリ名
/// </summary>
public string DirectoryPath => fsInfo.GetDirectoryPath() ?? string.Empty;
public FileElementModel(string filePath)
{
this.fsInfo = new FileInfo(filePath);
this.outputFileName = InputFileName;
}
/// <summary>
/// 指定された置換パターンで、ファイル名を置換する(ストレージに保存はされない)
/// </summary>
internal void Replace(IReadOnlyList<ReplaceRegex> repRegexes)
{
string outFileName = InputFileName;
foreach (var reg in repRegexes)
{
outFileName = reg.Replace(outFileName);
}
OutputFileName = outFileName;
}
/// <summary>
/// リネームを実行(ストレージに保存される)
/// </summary>
internal void Rename()
{
fsInfo.Rename(OutputFilePath);
fsInfo.Refresh();
//rename時にFileInfoが変更されるので、通知を上げておく
foreach (var name in new[] { nameof(InputFileName), nameof(InputFilePath), nameof(IsReplaced) })
RaisePropertyChanged(name);
}
}
FileElementModel
クラスは1つのファイルに対してリネームプレビュー・リネーム実行をするクラスです。
そして、これに対するテストを書いてみます。
public class UnitTest1
{
[Fact]
public void Test_FileElement()
{
string targetFileName = "coopy -copy.txt";
string targetDir = @"D:\FileRenamerDiff_TestTarget\";
string expectedRenamedFileName = "coopy -XXX.txt";
string regexPattern = "-copy";
string replaceText = "-XXX";
//ファイル準備
string targetFilePath = targetDir + targetFileName;
File.Delete(targetFilePath);
string expectedRenamedFilePath = targetDir + expectedRenamedFileName;
File.Delete(expectedRenamedFilePath);
File.WriteAllText(targetFilePath, "UNIT TEST");
var fileElem = new FileElementModel(targetFilePath);
//ファイル名の一部を変更する置換パターンを作成
var regex = new Regex(regexPattern, RegexOptions.Compiled);
var rpRegex = new ReplaceRegex(regex, replaceText);
//リネームプレビュー実行
fileElem.Replace(new[] { rpRegex });
fileElem.OutputFileName
.Should().Be(expectedRenamedFileName, "リネーム変更後のファイル名になったはず");
fileElem.IsReplaced
.Should().BeTrue("リネーム変更されたはず");
//リネーム保存実行
fileElem.Rename();
fileElem.InputFileName
.Should().Be(expectedRenamedFileName, "リネーム保存後のファイル名になったはず");
File.Exists(targetFilePath)
.Should().BeFalse("元のファイルはないはず");
File.Exists(expectedRenamedFilePath)
.Should().BeTrue("リネーム後のファイルはあるはず");
}
}
このまま、テスト実行してみましょう。
なんと無事に成功します!よかったよかった。。。

次に別の置換文字列のテストを書いてみましょう。
public class UnitTest2
{
[Fact]
public void Test_FileElement2()
{
string targetFileName = "coopy -copy.txt";
string targetDir = @"D:\FileRenamerDiff_TestTarget\";
string expectedRenamedFileName = "coopy -YYY.txt";
string regexPattern = "-copy";
string replaceText = "-YYY";
...
}
置換する文字列以外はおなじです。
(注 ここでは説明のためにコピペで作っていますが、本当はInlineData
を使用すべき案件です)
するとどういうことでしょうか、元のテストが失敗しました!!
「何もしてないのに壊れた!!💢」(している)

これはテストのために必要な環境(ファイル)が重複している、2つのテストが並列に走っているため、おきます。
具体的にはテスト2で"coopy -copy.txt" → "coopy -YYY.txt"へのファイル名変更中に、テスト1が"coopy -copy.txt"にアクセスしたりすると例外が発生してテストが失敗してしまいます。
つまり、このパターンの問題点は以下の点です。
- テストするために実際のファイルにアクセスしている
- それゆえ、テスト結果が環境に依存する
- つまりCI/CDできない
- テストによって環境が変化してしまう
- それゆえ、テストをやるたびに結果が変わる
この問題だけでいえば、XUnitの並列実行パラメータを修正したり、テストごとにテストするファイルを置く一時フォルダを分けても解決はします。
ただ、たまたま同名のファイルが使用中であったりすればテストは失敗します。
そもそもファイルへのアクセス自体が、メモリ上で完結していた場合と比べると遅いのでテストでは避けたいです。
というわけで、次回はこの問題をDIを使って解決していきます。
参考
https://xunit.net/
https://qiita.com/takutoy/items/84fa6498f0726418825d
環境
VisualStudio 2019
C# 9
.NET 5
xUnit 2.4.1
xunit.runner.visualstudio 2.4.3
FluentAssertions 5.10.3
LivetCask 3.2.3.2