リポジトリを利用するクラスに対する Moq を利用したメソッドの引数のテスト

リポジトリを利用するクラスで、データ更新行うメソッド void Update(int Id, int quantity) (キーが Id のレコードの hoge 数値フィールドに quantity の値を加算する)について、hoge 数値フィールドに quantity の値が加算された数値で更新が行われることをテストする場合、リポジトリの更新メソッド呼び出しの引数をチェックすることが考えられます。ただし、本物のリポジトリを呼び出すと DB が更新されてしまうので、モック・ライブラリを利用しますよね。モック・ライブラリの一つである Moq を利用した場合、モックインスタンス.VerifyAll() メソッドでメソッドの呼び出しと引数の確認が行えますが、引数がクラスの場合、参照するインスタンスについての比較になってしまうので、何もしなければ引数のインスタンスと比較用のインスタンスは別物ということで、検証失敗になります。
これを回避するため、比較用のインスタンスを Equals メソッドをオーバーライドしたクラスから生成させ、比較するプロパティをリフレクションを用いることで、明示的に指定しないようにしてみました(モデルを変更した時のテストコードの比較部分の更新し忘れ対策 😛 )。

まずは、テストを行うプロジェクトです。クラスライブラリのプロジェクトとして作成しています。

サンプルのモデルは、次のとおり。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MoqTest
{
    public class Model
    {
        public int Id { get; set; }
        public string name { get; set; }
        public int Num { get; set; }
    }
}

リポジトリのインターフェイスです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MoqTest
{
    public interface IRepository
    {
        /// <summary>
        /// 指定された Id の Model を取得する
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        Model GetModel(int id);

        /// <summary>
        /// 引数の num で何かを更新する ;-)
        /// </summary>
        /// <param name="num"></param>
        void Update1(int num);

        /// <summary>
        /// model を更新する
        /// </summary>
        /// <param name="model"></param>
        void Update3(Model model);
    }
}

リポジトリです。メソッドは実装していません 😉

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MoqTest
{
    class Repository : IRepository
    {
        public Model GetModel(int id)
        {
            throw new NotImplementedException();
        }

        public void Update1(int num)
        {
            throw new NotImplementedException();
        }

        public void Update3(Model model)
        {
            throw new NotImplementedException();
        }
    }
}

リポジトリを利用するクラスです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MoqTest
{
    public class Sample
    {
        private IRepository _repository;

        public Sample() : this(new Repository()) { }
        public Sample(IRepository repository)
        {
            _repository = repository;
        }

        public void Method1(int p1, int p2)
        {
            _repository.Update1(p1 + p2);
        }

        public void Method3(int id, int quantity)
        {
            var model = _repository.GetModel(id);
            model.Num += quantity;
            _repository.Update3(model);
        }
    }
}

Method3 の model.Num の更新後のリポジトリの Update メソッドの呼び出しについて、本来追跡中のエンティティを変更した場合、DbContext.SaveChanges() メソッドを呼び出すだけで DB が更新されますが、更新する値のテストをするためにリポジトリの Update メソッドを呼び出しています(ここらへんはリポジトリの実装コードにも依存することになりますが(リポジトリからの返却値を追跡しないオブジェクトにすることもできます))。

続いて、テスト用のコードです。テスト用のフレームワークは MS Test を使用しています。
ソリューションにテストプロジェクトを追加し、参照に先ほどのクラスライブラリのプロジェクトを追加します。Moq を利用するので、NuGet を利用してプロジェクトに追加します。

準備ができたら、テスト用のクラスです。

using System;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.VisualStudio.TestTools.UnitTesting;

using Moq;

using MoqTest;

namespace MoqTest.Test
{
    [TestClass]
    public class SampleTest
    {
        // モック・オブジェクト の Update1 メソッドへの引数のチェック
        [TestMethod]
        public void TestMethod1()
        {
            var mock = new Mock<IRepository>();
            mock.Setup(e => e.Update1(5));

            var target = new Sample(mock.Object);
            target.Method1(2, 3);

            // モック・オブジェクト の Update1 メソッドの呼び出しとの引数のチェックを行う
            mock.VerifyAll();
        }

        // モック・オブジェクト の Update3 メソッドへの引数のチェック
        [TestMethod]
        public void TestMethod3()
        {
            var mock = new Mock<IRepository>();

            var model = new Model { Id = 1, name = "Sample", Num = 5 };
            mock.Setup(e => e.GetModel(1))
                .Returns(model);
            // 引数の値比較を行うために Model クラスの Equals メソッドをオーバーライドした 
            // TestModel1 クラスを利用する
            var expected = new TestModel1 { Id = 1, name = "Sample", Num = 10 };
            mock.Setup(e => e.Update3(expected));

            var target = new Sample(mock.Object);
            target.Method3(1, 5);

            mock.VerifyAll();
        }

        // 確認するプロパティを個別に指定
        class TestModel1 : Model
        {
            public override bool Equals(object obj)
            {
                var compObj = obj as Model;
                if (compObj == null) return false;

                return isEquals(Id, compObj.Id) && isEquals(name, compObj.name) && isEquals(Num, compObj.Num);
            }

            private bool isEquals(object c1, object c2)
            {
                if (c1 == null || c2 == null)
                {
                    if (c1 == c2)
                        return true;
                    else
                        return false;
                }

                // Equals メソッドは仮想メソッドなので、動的な型に基づいて呼び出される
                return c1.Equals(c2);
            }
        }

        // モック・オブジェクト の Update3 メソッドへの引数のチェック
        [TestMethod]
        public void TestMethod4()
        {
            var mock = new Mock<IRepository>();

            var model = new Model { Id = 1, name = "Sample", Num = 5 };
            mock.Setup(e => e.GetModel(1))
                .Returns(model);
            var expected = new TestModel2 { Id = 1, name = "Sample", Num = 10 };
            mock.Setup(e => e.Update3(expected));

            var target = new Sample(mock.Object);
            target.Method3(1, 5);
            mock.VerifyAll();
        }

        // 確認するプロパティをリフレクションを利用して取得
        class TestModel2 : Model
        {
            public override bool Equals(object obj)
            {
                var compObj = obj as Model;
                if (compObj == null) return false;

                return isEqualProperties(this, compObj);
            }

            // リフレクションを用いてプロパティ値が同じか確認
            private bool isEqualProperties(Model obj, Model compObj)
            {
                var t = typeof(Model);
                var properties = t.GetProperties(
                    BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance);
                foreach (var n in properties)
                {
                    // isEqual メソッドには、System.Reflection.PropertyInfo.GetValue メソッドの
                    // 戻り値(静的な型は object、動的な型が基底クラスへダウンキャストされます)を
                    // 渡しています。
                    if (isEquals(n.GetValue(obj), n.GetValue(compObj)) == false)
                        return false;
                }
                return true;
            }

            private bool isEquals(object c1, object c2)
            {
                if (c1 == null || c2 == null)
                {
                    if (c1 == c2)
                        return true;
                    else
                        return false;
                }

                // Equals メソッドは仮想メソッドなので、動的な型に基づいて呼び出される
                return c1.Equals(c2);
            }
        }
    }
}

TestMethod1 は、値型(int)の引数のテストです。これは特に触れるところもないかと。

TestMethod3 は、参照型(Model)の引数のテストで、比較するプロパティを指定しているパターンです。コメントにあるように、変数 expected は Model クラスの Equals メソッドをオーバーライドした TestModel1 クラスから生成して mock にセットしています。TestModel1 クラスの Equals メソッドでは、プロパティの比較を個別に明示して行っています。

TestMethod3 は、参照型(Model)の引数のテストで、比較するプロパティをリフレクションで取り出すことで動的に比較するパターンです。TestMethod3 同様に 変数 expected は Model クラスの Equals メソッドをオーバーライドしたクラスから生成していますが、Equals メソッドが異なるので、 TestModel2 クラスから生成しています。リフレクションを用いたプロパティの取り出しは、 Type.GetProperties メソッドで行っています(取り出し条件は引数で指定(Model クラスで宣言された、public で、インスタンス化されたプロパティ))。isEquals メソッドにプロパティ値を渡す部分は、コメントに書いたとおりです。

静的な型、動的な型について、よくわからない方は ++C++ さんのところの「多態性」をご覧いただくとよいかと。


リポジトリを利用するクラスに対する Moq を利用したメソッドの引数のテスト」への2件のフィードバック

  1. はじめまして。
    わたしもユニットテストにはMoqを多用しています。
    makさんと同じようにオブジェクトのマッチングには苦労しました。
    最近、Moqのクイックスタートを読んでいて、カスタムのMatcherがあることを知りました。デリゲートだけでテストメソッドが書けるので、テストコードの記述がより容易になると思います。

    既にご存知かもしれませんが、お伝えします。
    https://github.com/Moq/moq4/wiki/Quickstart
    「Advanced Features」ブロックの中ほどにcustom matchersの説明があります。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です