クラス外部の非同期メソッドを呼び出す非同期メソッドを対象にした単体テスト

非同期メソッドの単体テストを書いていて、ちょっと考え込んだので、備忘録を兼ねて書いておきます。ネタ的には「リポジトリを利用するクラスに対する Moq を利用したメソッドの引数のテスト」に引き続いての moq を使った単体テストネタということで 😉

考え込んだのは、クラス外部の非同期メソッドを呼び出す非同期メソッドを対象にした単体テストをどう書くかということです。
具体的には「サービス層の public Async Task GetSampleAsync(int id) のテスト」で、この GetSampleAsync がデータアクセス層の public Async Task GetSampleAsync(int id) を内部で呼び出しているというようなものです。

結論としては、以前「プロデューサー/コンシューマー パターン」で利用した TaskCompletionSource クラスを利用することでモックを用いた単体テストを書くことができます。

ということで、実装例です。

まずはモデル。こ~ゆ~モデルがあるとしてということで。

namespace UnitTestAsyncMethod01.Models
{
    public class Sample
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

次に、データアクセスレイヤ(リポジトリ)のインターフェイス。

using System;
using System.Threading.Tasks;

using UnitTestAsyncMethod01.Models;

namespace UnitTestAsyncMethod01.DAL
{
    public interface ISampleRepository : IDisposable
    {
        Task<Sample> GetSampleAsync(int id);
        Task<bool> HaveName(string name);
        void AddSample(Sample sample);
        Task SaveAsync();
    }
}

SampleRepository クラスは、インターフェイスを実装しただけ。

using System;
using System.Threading.Tasks;

namespace UnitTestAsyncMethod01.DAL
{
    public class SampleRepository : ISampleRepository
    {
        #region ISampleRepository メンバー

        public System.Threading.Tasks.Task<Models.Sample> GetSampleAsync(int id)
        {
            throw new NotImplementedException();
        }

        public Task<bool> HaveName(string name)
        {
            throw new NotImplementedException();
        }

        public void AddSample(Models.Sample sample)
        {
            throw new NotImplementedException();
        }

        public Task SaveAsync()
        {
            throw new NotImplementedException();
        }

        #endregion

        #region IDisposable メンバー

        public void Dispose()
        {
            throw new NotImplementedException();
        }

        #endregion
    }
}

サービス層のインターフェイス。サービス層の単体テストでは使いませんけど、いちおう 😛

using System;
using System.Threading.Tasks;

using UnitTestAsyncMethod01.Models;

namespace UnitTestAsyncMethod01.Services
{
    public interface ISampleService : IDisposable
    {
        Task<Sample> GetSampleAsync(int id);
        Tas��������pleAsync(Sample sample);
    }
}

SampleService クラス。

using System;
using System.Threading.Tasks;

using UnitTestAsyncMethod01.DAL;
using UnitTestAsyncMethod01.Models;

namespace UnitTestAsyncMethod01.Services
{
    public class SampleService : ISampleService
    {
        private ISampleRepository _repository;

        public SampleService() : this(new SampleRepository()) { }
        public SampleService(ISampleRepository repository)
        {
            _repository = repository;
        }

        #region ISampleService メンバー

        public async Task<Sample> GetSampleAsync(int id)
        {
            return await _repository.GetSampleAsync(id);
        }

        public async Task AddSampleAsync(Sample sample)
        {
            // 重複する Name の存在チェック
            if (await _repository.HaveName(sample.Name))
            {
                throw new ApplicationException(string.Format("{0} は既に登録されています。", sample.Name));
            }
            _repository.AddSample(sample);
            await _repository.SaveAsync();
        }

        #endregion

        #region IDisposable メンバー

        private bool _disposed = false;

        /// <summary>
        /// リソースの開放を行います。
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        /// <summary>
        /// リソースの開放を行います。
        /// </summary>
        /// <param name="disposing"></param>
        protected virtual void Dispose(bool disposing)
        {
            if (_disposed) return;

            _disposed = true;

            if (disposing)
            {
                // マネージ リソースの解放処理
            }
            // アンマネージ リソースの解放処理
            _repository.Dispose();
        }

        #endregion

        ~SampleService()
        {
            Dispose(false);
        }
    }
}

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

using System;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;

using Moq;

using UnitTestAsyncMethod01.DAL;
using UnitTestAsyncMethod01.Models;
using UnitTestAsyncMethod01.Services;

namespace UnitTestAsyncMethod01.Tests
{
    [TestClass]
    public class TestService
    {
        [TestMethod]
        public async Task GetSample()
        {
            var mock = new Mock<ISampleRepository>();
            var sample = new Sample { Id = 1, Name = "Example1" };
            // Task<Sample> ISampleRepository.GetSampleAsync(1) が sample を返すように設定
            var tcs = new TaskCompletionSource<Sample>();
            tcs.SetResult(sample);
            mock.Setup(e => e.GetSampleAsync(1))
                .Returns(tcs.Task);
            var target = new SampleService(mock.Object);
            var actual = await target.GetSampleAsync(1);
            Assert.AreEqual(sample, actual);

            //モックの振る舞いが呼び出されたかを確認
            mock.VerifyAll();
        }

        [TestMethod]
        public async Task AddSampleOk()
        {
            var mock = new Mock<ISampleRepository>();
            var sample = new Sample { Name = "Example1" };
            // Task<bool> ISampleRepository.HaveName("Example1") が false を返すように設定
            var tcs0 = new TaskCompletionSource<bool>();
            tcs0.SetResult(false);
            mock.Setup(e => e.HaveName("Example1"))
                .Returns(tcs0.Task);
            // void ISampleRepository.AddSample(sample) を受け付けるように設定
            mock.Setup(e => e.AddSample(sample));
            // Task ISampleRepository.SaveAsync() が完了タスクを返すように設定
            var tcs1 = new TaskCompletionSource<bool>();
            tcs1.SetResult(true);
            mock.Setup(e => e.SaveAsync())
                .Returns(tcs1.Task);
            var target = new SampleService(mock.Object);
            await target.AddSampleAsync(sample);

            //モックの振る舞いが呼び出されたかを確認
            mock.VerifyAll();
        }

        [TestMethod]
        public async Task AddSampleNG()
        {
            var mock = new Mock<ISampleRepository>();
            var sample = new Sample { Name = "Example1" };
            // Task<bool> ISampleRepository.HaveName("Example1") が true を返すように設定
            var tcs0 = new TaskCompletionSource<bool>();
            tcs0.SetResult(true);
            mock.Setup(e => e.HaveName("Example1"))
                .Returns(tcs0.Task);
            var target = new SampleService(mock.Object);
            var expected = "Example1 は既に登録されています。";
            try
            {
                await target.AddSampleAsync(sample);
                Assert.Fail("例外が発生しませんでした。");
            }
            catch (ApplicationException ex)
            {
                Assert.AreEqual(expected, ex.Message);
            }

            //モックの振る舞いが呼び出されたかを確認
            mock.VerifyAll();
        }
    }
}

コメントを入れておいたので、見ればだいたい分かるかと思いますが、TaskCompletionSource クラスは、SetResult メソッドで返却値をセットすることで、タスクを完了状態に遷移させます。
逆に言うと、完了状態に遷移させるには返却値をセットすることが必要ということになり、Task SaveAsync() の場合は? となりますが、適当な返却値の型をつけて、その型に合う返却値をセットすれば OK です。

AddSampleNG テストメソッドは、テスト対象の非同期メソッドが例外を投げることを確認するテストです。


コメントを残す

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