モック フレームワークを使用したテスト

Note

EF6 以降のみ - このページで説明する機能、API などは、Entity Framework 6 で導入されました。 以前のバージョンを使用している場合、一部またはすべての情報は適用されません。

アプリケーションのテストを作成するときに、データベースまで到達しないようにすることが望ましい場合がよくあります。 Entity Framework では、インメモリ データを使用するコンテキスト (テストで定義された動作) を作成することによってこれを実現できます。

テスト ダブルを作成するためのオプション

コンテキストのインメモリ バージョンを作成するには、2 つの異なる方法を使用できます。

  • 独自のテスト ダブルを作成する - この方法には、コンテキストおよび DbSet の独自のインメモリ実装を記述することが含まれます。 これにより、クラスの動作を細かく制御できますが、かなりの量のコードを記述して所有する必要があります。
  • モック フレームワークを使用してテスト ダブルを作成する - モック フレームワーク (Moq など) を使用すると、実行時に動的に作成されるコンテキストとセットのインメモリ実装を使用できます。

この記事では、モック フレームワークの使用方法について説明します。 独自のテスト ダブルを作成する場合は、「独自のテスト ダブルを使用したテスト」を参照してください。

モック フレームワークで EF を使用する方法を示すために、ここでは Moq を使用します。 Moq を入手する最も簡単な方法は、NuGet から Moq パッケージをインストールすることです。

EF6 以前のバージョンでのテスト

この記事に示すシナリオでは、EF6 の DbSet にいくつかの変更を加えて使用しています。 EF5 以前のバージョンでのテストについては、フェイク コンテキストを使用したテストに関するページを参照してください。

EF インメモリ テスト ダブルの制限事項

インメモリ テスト ダブルは、EF を使用するアプリケーションについて、単体テスト レベルのビット カバレッジを提供するのに適した方法です。 ただし、これを行う場合、LINQ to Objects を使用してインメモリ データに対してクエリを実行する必要があります。 これにより、EF の LINQ プロバイダー (LINQ to Entities) を使用して、データベースに対して実行される SQL にクエリを変換する場合とは動作が異なることがあります。

このような違いの一例として、関連データの読み込みがあります。 それぞれが関連する投稿が含まれる一連のブログを作成した場合、インメモリ データを使用すると、関連する投稿が常にブログごとに読み込まれます。 ところが、データベースに対して実行する場合は、Include メソッドを使用するとデータのみが読み込まれます。

このため、アプリケーションがデータベースに対して正しく動作することを確認するには、(単体テストに加えて) 一定レベルのエンド ツー エンド テストを常に含めることをお勧めします。

この記事の進め方

この記事では、理解しやすくするために必要に応じて Visual Studio にコピーできる完全なコード リストを提供しています。 単体テスト プロジェクトを作成するのが最も簡単です。また、非同期を使用するセクションを完了するには、.NET Framework 4.5 をターゲットにする必要があります。

EF モデル

テストするサービスでは、BloggingContext および Blog and Post クラスで構成される EF モデルを使用します。 このコードは、EF デザイナーによって生成されたか、Code First モデルである可能性があります。

using System.Collections.Generic;
using System.Data.Entity;

namespace TestingDemo
{
    public class BloggingContext : DbContext
    {
        public virtual DbSet<Blog> Blogs { get; set; }
        public virtual DbSet<Post> Posts { get; set; }
    }

    public class Blog
    {
        public int BlogId { get; set; }
        public string Name { get; set; }
        public string Url { get; set; }

        public virtual List<Post> Posts { get; set; }
    }

    public class Post
    {
        public int PostId { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }

        public int BlogId { get; set; }
        public virtual Blog Blog { get; set; }
    }
}

EF Designer を使用した virtual DbSet プロパティ

コンテキストの DbSet プロパティは virtual としてマークされることに注意してください。 これにより、モック フレームワークをコンテキストから派生させ、これらのプロパティをモック実装でオーバーライドすることができます。

Code First を使用している場合は、クラスを直接編集できます。 EF Designer を使用している場合は、T4 テンプレートを編集してコンテキストを生成する必要があります。 edmx ファイルの下に入れ子になっている <model_name>.Context.tt ファイルを開き、次のコード フラグメントを検索して、次に示すように virtual キーワードを追加します。

public string DbSet(EntitySet entitySet)
{
    return string.Format(
        CultureInfo.InvariantCulture,
        "{0} virtual DbSet\<{1}> {2} {{ get; set; }}",
        Accessibility.ForReadOnlyProperty(entitySet),
        _typeMapper.GetTypeName(entitySet.ElementType),
        _code.Escape(entitySet));
}

テストするサービス

インメモリ テスト ダブルを使用してテストを実行するために、BlogService に対していくつかのテストを記述します。 このサービスは、新しいブログを作成したり (AddBlog)、すべてのブログを名前順に返したり (GetAllBlogs) できます。 GetAllBlogs に加え、名前順にすべてのブログを非同期的に取得するメソッド (GetAllBlogsAsync) も用意されています。

using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    public class BlogService
    {
        private BloggingContext _context;

        public BlogService(BloggingContext context)
        {
            _context = context;
        }

        public Blog AddBlog(string name, string url)
        {
            var blog = _context.Blogs.Add(new Blog { Name = name, Url = url });
            _context.SaveChanges();

            return blog;
        }

        public List<Blog> GetAllBlogs()
        {
            var query = from b in _context.Blogs
                        orderby b.Name
                        select b;

            return query.ToList();
        }

        public async Task<List<Blog>> GetAllBlogsAsync()
        {
            var query = from b in _context.Blogs
                        orderby b.Name
                        select b;

            return await query.ToListAsync();
        }
    }
}

非クエリ シナリオのテスト

これで、非クエリ メソッドのテストを開始するために必要なものがすべて揃いました。 次のテストでは、Moq を使用してコンテキストを作成します。 次に、DbSet<Blog> を作成し、コンテキストの Blogs プロパティから返されるように接続します。 次に、コンテキストを使用して新しい BlogService を作成し、これを使用して新しいブログを作成します。このとき、AddBlog メソッドを使用します。 最後に、このテストでは、サービスによって新しいブログが追加され、コンテキストで SaveChanges が呼び出しされことを確認します。

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Data.Entity;

namespace TestingDemo
{
    [TestClass]
    public class NonQueryTests
    {
        [TestMethod]
        public void CreateBlog_saves_a_blog_via_context()
        {
            var mockSet = new Mock<DbSet<Blog>>();

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(m => m.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            service.AddBlog("ADO.NET Blog", "http://blogs.msdn.com/adonet");

            mockSet.Verify(m => m.Add(It.IsAny<Blog>()), Times.Once());
            mockContext.Verify(m => m.SaveChanges(), Times.Once());
        }
    }
}

クエリ シナリオのテスト

DbSet テスト ダブルに対してクエリを実行できるようにするには、IQueryable 実装を設定する必要があります。 最初の手順は、インメモリ データを作成することです。ここでは、List<Blog> を使用しています。 次に、コンテキストと DBSet<Blog> を作成し、DbSet の IQueryable 実装に接続します。これらは、List<T> で動作する LINQ to Objects プロバイダーへの委任を行うだけです。

次に、テスト ダブルに基づいて BlogService を作成し、GetAllBlogs から返されたデータが名前順に並んでいることを確認します。

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;

namespace TestingDemo
{
    [TestClass]
    public class QueryTests
    {
        [TestMethod]
        public void GetAllBlogs_orders_by_name()
        {
            var data = new List<Blog>
            {
                new Blog { Name = "BBB" },
                new Blog { Name = "ZZZ" },
                new Blog { Name = "AAA" },
            }.AsQueryable();

            var mockSet = new Mock<DbSet<Blog>>();
            mockSet.As<IQueryable<Blog>>().Setup(m => m.Provider).Returns(data.Provider);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            var blogs = service.GetAllBlogs();

            Assert.AreEqual(3, blogs.Count);
            Assert.AreEqual("AAA", blogs[0].Name);
            Assert.AreEqual("BBB", blogs[1].Name);
            Assert.AreEqual("ZZZ", blogs[2].Name);
        }
    }
}

非同期クエリを使用したテスト

Entity Framework 6 では、非同期的にクエリを実行するために使用できる一連の拡張メソッドが導入されました。 これらのメソッドの例としては、ToListAsync、FirstAsync、ForEachAsync などがあります。

Entity Framework クエリは LINQ を利用するので、拡張メソッドは IQueryable と IEnumerable で定義されます。 ただし、これらは Entity Framework でのみ使用するように設計されているので、Entity Framework クエリではない LINQ クエリで使用しようとすると、次のエラーが発生する可能性があります。

ソース IQueryable は IDbAsyncEnumerable{0} を実装していません。 Entity Framework 非同期操作に使用できるのは IDbAsyncEnumerable を実装するソースのみです。 詳細については、 http://go.microsoft.com/fwlink/?LinkId=287068 を参照してください。

非同期メソッドは EF クエリに対して実行する場合にのみサポートされます。一方、DbSet のインメモリ テスト ダブルに対して実行する場合は、単体テスト内で使用する必要があります。

非同期メソッドを使用するには、インメモリ DbAsyncQueryProvider を作成して非同期クエリを処理する必要があります。 Moq を使用してクエリ プロバイダーを設定することもできますが、コードでテスト ダブル実装を作成する方がはるかに簡単です。 この実装のコードは次のとおりです。

using System.Collections.Generic;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

namespace TestingDemo
{
    internal class TestDbAsyncQueryProvider<TEntity> : IDbAsyncQueryProvider
    {
        private readonly IQueryProvider _inner;

        internal TestDbAsyncQueryProvider(IQueryProvider inner)
        {
            _inner = inner;
        }

        public IQueryable CreateQuery(Expression expression)
        {
            return new TestDbAsyncEnumerable<TEntity>(expression);
        }

        public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
        {
            return new TestDbAsyncEnumerable<TElement>(expression);
        }

        public object Execute(Expression expression)
        {
            return _inner.Execute(expression);
        }

        public TResult Execute<TResult>(Expression expression)
        {
            return _inner.Execute<TResult>(expression);
        }

        public Task<object> ExecuteAsync(Expression expression, CancellationToken cancellationToken)
        {
            return Task.FromResult(Execute(expression));
        }

        public Task<TResult> ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken)
        {
            return Task.FromResult(Execute<TResult>(expression));
        }
    }

    internal class TestDbAsyncEnumerable<T> : EnumerableQuery<T>, IDbAsyncEnumerable<T>, IQueryable<T>
    {
        public TestDbAsyncEnumerable(IEnumerable<T> enumerable)
            : base(enumerable)
        { }

        public TestDbAsyncEnumerable(Expression expression)
            : base(expression)
        { }

        public IDbAsyncEnumerator<T> GetAsyncEnumerator()
        {
            return new TestDbAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
        }

        IDbAsyncEnumerator IDbAsyncEnumerable.GetAsyncEnumerator()
        {
            return GetAsyncEnumerator();
        }

        IQueryProvider IQueryable.Provider
        {
            get { return new TestDbAsyncQueryProvider<T>(this); }
        }
    }

    internal class TestDbAsyncEnumerator<T> : IDbAsyncEnumerator<T>
    {
        private readonly IEnumerator<T> _inner;

        public TestDbAsyncEnumerator(IEnumerator<T> inner)
        {
            _inner = inner;
        }

        public void Dispose()
        {
            _inner.Dispose();
        }

        public Task<bool> MoveNextAsync(CancellationToken cancellationToken)
        {
            return Task.FromResult(_inner.MoveNext());
        }

        public T Current
        {
            get { return _inner.Current; }
        }

        object IDbAsyncEnumerator.Current
        {
            get { return Current; }
        }
    }
}

非同期クエリ プロバイダーが用意できたので、新しい GetAllBlogsAsync メソッドの単体テストを記述できます。

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    [TestClass]
    public class AsyncQueryTests
    {
        [TestMethod]
        public async Task GetAllBlogsAsync_orders_by_name()
        {

            var data = new List<Blog>
            {
                new Blog { Name = "BBB" },
                new Blog { Name = "ZZZ" },
                new Blog { Name = "AAA" },
            }.AsQueryable();

            var mockSet = new Mock<DbSet<Blog>>();
            mockSet.As<IDbAsyncEnumerable<Blog>>()
                .Setup(m => m.GetAsyncEnumerator())
                .Returns(new TestDbAsyncEnumerator<Blog>(data.GetEnumerator()));

            mockSet.As<IQueryable<Blog>>()
                .Setup(m => m.Provider)
                .Returns(new TestDbAsyncQueryProvider<Blog>(data.Provider));

            mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            var blogs = await service.GetAllBlogsAsync();

            Assert.AreEqual(3, blogs.Count);
            Assert.AreEqual("AAA", blogs[0].Name);
            Assert.AreEqual("BBB", blogs[1].Name);
            Assert.AreEqual("ZZZ", blogs[2].Name);
        }
    }
}