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

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

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

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

コンテキストのメモリ内バージョンを作成するために使用できる方法は 2 つあります。

  • 独自のテスト ダブルを作成する – このアプローチでは、コンテキストと DbSet の独自のメモリ内実装を記述する必要があります。 これにより、クラスの動作を多く制御できますが、適切な量のコードを記述して所有する必要があります。
  • モック フレームワークを使用してテスト ダブルを作成 する – モック フレームワーク (Moq など) を使用すると、コンテキストとセットのメモリ内実装を実行時に動的に作成できます。

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

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

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

この記事で示すシナリオは、EF6 で DbSet に加えたいくつかの変更に依存します。 EF5 以前のバージョンでのテストについては、「 Fake Context を使用したテスト」を参照してください。

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

メモリ内テストダブルは、EF を使用するアプリケーションの特定の部分に対する単体テストレベルのカバレッジを提供するための効果的な方法です。 ただし、これを行う場合は、LINQ to Objects を使用してメモリ内データに対してクエリを実行します。 これにより、EF の LINQ プロバイダー (LINQ to Entities) を使用して、データベースに対して実行される SQL にクエリを変換する場合とは異なる動作が発生する可能性があります。

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

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

この記事と共に次の操作を行います

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

EF モデル

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

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 デザイナーを使用した Virtual DbSet プロパティ

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

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

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));
}

テストするサービス

メモリ内テスト double を使用したテストのデモンストレーションを行うために、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 テスト double に対してクエリを実行できるようにするには、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);
        }
    }
}