概要
これはもともとあったMVVMなWPFアプリに自動テストとDependency-Injection(DI)とCI/CDを実装してみた記録です。変更点はWPFとはあまり関係ないので、C#であれば他のフレームワークにも参考になるかもしれません。
前回は自動テストの追加までやりました。
今回はDIを追加してみます。
WPFアプリの中身はファイルリネーマーです。アプリの詳細はこちらで。コード行数850行ぐらい、クラス数40個ぐらいのサンプルアプリに毛の生えたぐらいのコードサイズです。
TestフレームワークはxUnit、DIコンテナはMicrosoft.Extensions.DependencyInjectionを使用しました。
DIコンテナの追加
nugetからDIコンテナを追加します。
dotnet add package Microsoft.Extensions.DependencyInjection
アプリケーションのスタートアップでModel層の大本のクラスをDIコンテナに登録します。
public partial class App : Application
{
public static IServiceProvider Services { get; } = ConfigureServices();
private static IServiceProvider ConfigureServices()
{
IServiceCollection? services = new ServiceCollection()
.AddSingleton<Model>();
return services.BuildServiceProvider();
}
...
次に登録したクラスを取得します。
/// <summary>
/// アプリケーション全体シングルトンモデル
/// </summary>
public class Model : NotificationObject
{
private readonly IFileSystem fileSystem;
/// <summary>
/// シングルトンなインスタンスを返す
/// </summary>
public static Model Instance { get; } = App.Services.GetService<Model>()!;
...
//コンストラクタ
public Model()
{
...
}
Modelの大本のクラスModel
は実行時のインスタンスは1つですが、テスト時は複数並列に動かすため、インスタンスが複数できます。
なので、厳密にはこのクラス自体はシングルトンではない?(よくわかっていない)
今は何も依存するサービスが無いので、コンストラクタ引数がありません。
この段階で実行して、DIコンテナからインスタンスを取得して無事に動作することを確認しておきます。
ファイルシステムのモックライブラリ、System.IO.Abstractionsの導入
次にファイルシステムをDIできるように、ファイルシステムのモックライブラリ、System.IO.Abstractionsをアプリケーションプロジェクトとテストプロジェクトにnugetで追加します。
dotnet add package System.IO.Abstractions
dotnet add package System.IO.Abstractions.TestingHelpers
System.IO.Abstractionsの簡単な説明は以前の記事を参照ください。
ファイルシステムをDIコンテナに登録します。
public partial class App : Application
{
public static IServiceProvider Services { get; } = ConfigureServices();
private static IServiceProvider ConfigureServices()
{
IServiceCollection? services = new ServiceCollection()
.AddTransient<IFileSystem, FileSystem>() //追加部分
.AddSingleton<Model>();
return services.BuildServiceProvider();
}
...
前回の記事でテストが正しくできなかったクラスFileElementModel
を変更して、実際のファイルを直接操作せず、外部から注入したファイルシステムに対して操作するように変更します。前回から数行しか変更していないので、変更行は//☆変更
がついています。
/// <summary>
/// リネーム前後のファイル名を含むファイル情報モデル
/// </summary>
public class FileElementModel : NotificationObject
{
private readonly IFileSystem fileSystem; //☆変更
private readonly IFileSystemInfo 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(IFileSystem fileSystem, string filePath) //☆変更
{
this.fileSystem = fileSystem; //☆変更
this.fsInfo = fileSystem.FileInfo.FromFileName(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);
}
}
コンストラクタでファイルシステムのインターフェースIFileSystem
を引数で受け取っています。
FileElementModel
を生成するModel
クラスもコンストラクタ引数でIFileSystem
を受け取り、アプリケーション実行時はDIコンテナから取得します。
//コンストラクタ
public Model(IFileSystem fileSystem)
{
this.fileSystem = fileSystem;
...
}
...
private FileElementModel[] LoadFileElementsCore(SettingAppModel setting)
{
...
IEnumerable<string> fileEnums = sourceFilePaths
.SelectMany(x => fileSystem.Directory.EnumerateFileSystemEntries(x, "*", option))
.Distinct();
var loadedFileList = fileEnums
.ToList();
return loadedFileList
.Select(x => new FileElementModel(fileSystem, x)) //DIからもらったファイルシステムを受け渡し
.ToArray();
}
DIコンテナから注入されたファイルシステムをフィールドに保持し、子Modelに受け渡しています。
子Modelの生成もDIコンテナにまかせたほうがいい?よくわかっていないので詳しい人いたら指摘ほしいです。
アプリケーション側はこれで動作します。
モックを使ったテスト
本題のファイルシステムのモックを使ったテストです。こちらも前回から数行しか変更していないので、変更行は//☆変更
がついています。
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; //☆変更
string expectedRenamedFilePath = targetDir + expectedRenamedFileName; //☆変更
var fileSystemMock = new MockFileSystem(new Dictionary<string, MockFileData>() //☆変更
{
[targetFilePath] = new MockFileData("UNIT TEST"),
});
var fileElem = new FileElementModel(fileSystemMock, 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, "リネーム保存後のファイル名になったはず");
fileSystemMock.File.Exists(targetFilePath) //☆変更
.Should().BeFalse("元のファイルはないはず");
fileSystemMock.File.Exists(expectedRenamedFilePath) //☆変更
.Should().BeTrue("リネーム後のファイルはあるはず");
}
}
変更点は2つ
- 最初のファイル準備部分がモックになった。既存のファイルをクリーンする必要がないので、行数はむしろ減った。
- Fileに直接アクセスしていた部分をファイルシステム経由に変更した。
fileSystemMock.
を先頭にコピペするだけですむ。
というわけで思ってたよりも簡単でした。
次にもうひとつのテストを書きます。置換文字列以外は同じなので省略します。
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";
...
前回は2つ目のテストを追加したら、テスト実行時にどちらかのテストが失敗していましたが、今回はどうでしょうか。。。
無事に成功しました。
実際のファイルにアクセスせず、メモリ上でテストが完結するのでテストが早く終わります。テストごとに環境が独立しているのも良い点です。
次回はこの自動テストをGitHub Actionsで実行してみます。
後書き
DI・自動テストはやった方が良いと思いつつ、ついつい後回しにしていましたが、なんとかできました。
終わってみたら、変更行はそれほど多くなく、テストを書く面倒さも、「思ったほど」では無かったです。
ただ、複雑なアプリケーションに後から追加するのは難易度が高いので、そもそも最初からDI・テストファーストで開発をしたほうがよいです。
参考
環境
VisualStudio 2019
C# 9
.NET 5
Microsoft.NET.Test.Sdk 16.9.4
xunit 2.4.1
xunit.runner.visualstudio 2.4.3
coverlet.collector 3.0.2
System.IO.Abstractions 13.2.38
System.IO.Abstractions.TestingHelpers 13.2.38