UnitTestでDependencyInjectionを利用する

もくじ
https://tera1707.com/entry/2022/02/06/144447

やりたいこと

昔から、UnitTestせねば、自動テストせねば、そのためにDI(DependencyInjection)せねば、、、と思い続けてたのに、どんどん後回しになって全然やってなかったので、まずは簡単にDIの練習をしてみる。

前提

  • Windows10 Home 21H1 19043.1706
  • VisualStudio2022 Community 17.1.4
  • WinUI3.0
  • Windows App SDK 1.0
  • NUnit単体テスト実施
  • 2022年5月の時点の調査

今回使用したnugetパッケージ

CommunityToolkit.Mvvm
Ioc のために使用。

Microsoft.Extensions.DependencyInjection
→ ServiceCollection のために使用。

DIコンテナにはいろいろある様子だが、今知ってるもので、一番簡単に使えそうなものを選んだ。

サンプルコード

今回は実験のため、

  • 現在時刻を取ってくるModelがあるとする。
  • そいつを使う親Model(ParentModel)がいて、そいつの持ってるメソッドがテスト対象とする。
  • 本番用のコードではそのParentModelを画面のコードが、テスト用のコードでは、ParentModelをUnitTestのコードが呼ぶものとする。
  • ParentModelは、現在時刻を返してくるGetCurrentTimeModeというクラスを本番では使うが、UnitTest時に、どんな値が来るかわからない「現在時刻」を返されてしまうと期待値が決められずテストできないので、UnitTest時は、ParentModelが呼ぶのを固定の日付を返すGetCurrentTimeModeMockというModelクラスに差し替えてテストする。
  • UnitTestコードでは、その固定の日付を元に期待値を決めて、それと実際のテスト対象のParentModelのメソッドの処理の結果が一致するかを確認するコードを書く。
  • 図にすると下記のようなイメージ。

本番コード

App.xaml.cs

using CommunityToolkit.Mvvm.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;

namespace DependencyInjectionJikken
{
    public partial class App : Application
    {
        private Window m_window;
        public App()
        {
            this.InitializeComponent();

            var services = new ServiceCollection();
            services.AddSingleton<IGetCurrentTimeModel, GetCurrentTimeModel>();
            var provider = services.BuildServiceProvider();
            Ioc.Default.ConfigureServices(provider);
        }

        protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
        {
            m_window = new MainWindow();
            m_window.Activate();
        }
    }
}

MainWindow.xaml.cs

using Microsoft.UI.Xaml;

namespace DependencyInjectionJikken
{
    public sealed partial class MainWindow : Window
    {
        public MainWindow()
        {
            this.InitializeComponent();
        }

        private void myButton_Click(object sender, RoutedEventArgs e)
        {
            var pm = new ParentModel();
            myButton.Content = pm.GetCurrentTime().ToString();
        }
    }
}

GetCurrentTimeModel

using System;

namespace DependencyInjectionJikken
{
    public class GetCurrentTimeModel : IGetCurrentTimeModel
    {
        public DateTime Get() => DateTime.Now;
    }
}

UnitTestコード

UnitTest1.cs

using CommunityToolkit.Mvvm.DependencyInjection;
using DependencyInjectionJikken;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using System;

namespace TestProject1
{
    public class Tests
    {
        [SetUp]
        public void Setup()
        {
            var services = new ServiceCollection();
            services.AddSingleton<IGetCurrentTimeModel, GetCurrentTimeModelMock>();
            var provider = services.BuildServiceProvider();
            Ioc.Default.ConfigureServices(provider);
        }

        [Test]
        public void Test1()
        {
            var pm = new ParentModel();
            var answer = pm.GetCurrentTime();

            var expect = new DateTime(2000, 1, 1, 0, 0, 0);

            Assert.AreEqual(expect.ToString(), answer.ToString());
        }
    }
}

GetCurrentTimeModelMock

using System;

namespace DependencyInjectionJikken
{
    public  class GetCurrentTimeModelMock : IGetCurrentTimeModel
    {
        public DateTime Get() => new DateTime(2000, 1, 1, 0, 0, 0);
    }
}

本番/UnitTest共通コード

ParentModel.cs

using CommunityToolkit.Mvvm.DependencyInjection;
using System;

namespace DependencyInjectionJikken
{
    public class ParentModel
    {
        IGetCurrentTimeModel gctm;

        public ParentModel() => gctm = Ioc.Default.GetRequiredService<IGetCurrentTimeModel>();

        public DateTime GetCurrentTime() => gctm.Get();
    }
}

IGetCurrentTimeModel

using System;

namespace DependencyInjectionJikken
{
    public interface IGetCurrentTimeModel
    {
        DateTime Get();
    }
}

UnitTestについて

WinUI3のプロジェクトに対してMSTestでテストしようとすると、どうしてもテストが実行できなかった。
代わりに、今回はNUnitでテストを実施。(MSTestのバグ??)

やったことポイント

本番では、テスト対象のParentModelGetCurrentTimeModelを使い、テストではGetCurrentTimeModelMockを使う。

nugetしたMicrosoft.Extensions.DependencyInjectionがもつServiceCollectionに、本番画面コードでは本番用Modelを登録し、テストコードではテスト用ModelMockをserviceとして登録する。

本番

var services = new ServiceCollection();
services.AddSingleton<IGetCurrentTimeModel, GetCurrentTimeModel>();
var provider = services.BuildServiceProvider();

テスト

 var services = new ServiceCollection();
 services.AddSingleton<IGetCurrentTimeModel, GetCurrentTimeModelMock>();
 var provider = services.BuildServiceProvider();

そのserviceを、nugetしたCommunityToolkit.Mvvmが持つIocDefaultというコンテナに、登録してやる。

本番/テスト両方

Ioc.Default.ConfigureServices(provider);

それを使う部分で、登録したservice(Model)を取り出してやる。

本番/テスト両方

// 取り出すときは、interfaceで取り出す。
IGetCurrentTimeModel gctm = Ioc.Default.GetRequiredService<IGetCurrentTimeModel>();

取り出し時に、本番用かMockのどちらが取り出されるかは、登録したときのModel次第なので、 本番時は本番用Modelが取り出され、テスト時はMockが取り出される、という仕組み。

その他メモ、、、

同じことをやるために、今回だとParentModelのコンストラクタに、その上の階層でnewしたMockを渡してやって、UnitTestする、とかもできるが、それだと使うModelが増えたときに使う側でParentModelのコンストラクタにたくさんModelを渡してやらないといけなくなる。それが大変なので、こういうDIコンテナを使う、という現状の理解。
(もっとほかにもいろいろ便利ポイントがありそうだが)

参考

MSのDIのやり方解説ページ
まずはここをよく読まねば、、、

https://docs.microsoft.com/ja-jp/dotnet/core/extensions/dependency-injection

Microsoft.Extensions.DependencyInjection

https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection

DIコンテナ「Microsoft.Extensions.DependencyInjection」の紹介記事。 Microsoft.Extensions.DependencyInjectionは、今回やった内容よりも高度なことができる様子。 余裕があればやってみる。

https://qiita.com/okazuki/items/239ca5ef46e5a085e085

Ioc、ServiceCollection を使ってDIをしているコードを解説する動画。わかりやすい。 (本題はテーマカラーの話だが、それのためにDIしてる)

https://www.youtube.com/watch?v=w2XdbyNrXBQ&t=2s