独自のテスト ダブルを使用したテスト
Note
EF6 以降のみ - このページで説明する機能、API などは、Entity Framework 6 で導入されました。 以前のバージョンを使用している場合、一部またはすべての情報は適用されません。
アプリケーションのテストを作成するときに、データベースまで到達しないようにすることが望ましい場合がよくあります。 Entity Framework では、インメモリ データを使用するコンテキスト (テストで定義された動作) を作成することによってこれを実現できます。
テスト ダブルを作成するためのオプション
コンテキストのインメモリ バージョンを作成するには、2 つの異なる方法を使用できます。
- 独自のテスト ダブルを作成する - この方法には、コンテキストおよび DbSet の独自のインメモリ実装を記述することが含まれます。 これにより、クラスの動作を細かく制御できますが、かなりの量のコードを記述して所有する必要があります。
- モック フレームワークを使用してテスト ダブルを作成する - モック フレームワーク (Moq など) を使用すると、実行時に動的に作成されるコンテキストとセットのインメモリ実装を使用できます。
この記事では、独自のテスト ダブルを作成する方法について説明します。 モック フレームワークの使用方法の詳細については、「モック フレームワークを使用したテスト」を参照してください。
EF6 以前のバージョンでのテスト
この記事で示すコードは、EF6 と互換性があります。 EF5 以前のバージョンでのテストについては、フェイク コンテキストを使用したテストに関するページを参照してください。
EF インメモリ テスト ダブルの制限事項
インメモリ テスト ダブルは、EF を使用するアプリケーションについて、単体テスト レベルのビット カバレッジを提供するのに適した方法です。 ただし、これを行う場合、LINQ to Objects を使用してインメモリ データに対してクエリを実行する必要があります。 これにより、EF の LINQ プロバイダー (LINQ to Entities) を使用して、データベースに対して実行される SQL にクエリを変換する場合とは動作が異なることがあります。
このような違いの一例として、関連データの読み込みがあります。 それぞれが関連する投稿が含まれる一連のブログを作成した場合、インメモリ データを使用すると、関連する投稿が常にブログごとに読み込まれます。 ところが、データベースに対して実行する場合は、Include メソッドを使用するとデータのみが読み込まれます。
このため、アプリケーションがデータベースに対して正しく動作することを確認するには、(単体テストに加えて) 一定レベルのエンド ツー エンド テストを常に含めることをお勧めします。
この記事の進め方
この記事では、理解しやすくするために必要に応じて Visual Studio にコピーできる完全なコード リストを提供しています。 単体テスト プロジェクトを作成するのが最も簡単です。また、非同期を使用するセクションを完了するには、.NET Framework 4.5 をターゲットにする必要があります。
コンテキスト インターフェイスの作成
EF モデルを使用するサービスのテストについて見ていきます。 EF コンテキストをテスト用のインメモリ バージョンに置き換えることができるようにするために、EF コンテキスト (およびそのインメモリ ダブル) が実装するインターフェイスを定義します。
テストするサービスでは、コンテキストの DbSet プロパティを使用してデータのクエリと変更を行います。また、SaveChanges を呼び出して、変更をデータベースにプッシュします。 そのため、これらのメンバーをインターフェイスに含めます。
using System.Data.Entity;
namespace TestingDemo
{
public interface IBloggingContext
{
DbSet<Blog> Blogs { get; }
DbSet<Post> Posts { get; }
int SaveChanges();
}
}
EF モデル
テストするサービスでは、BloggingContext および Blog and Post クラスで構成される EF モデルを使用します。 このコードは、EF デザイナーによって生成されたか、Code First モデルである可能性があります。
using System.Collections.Generic;
using System.Data.Entity;
namespace TestingDemo
{
public class BloggingContext : DbContext, IBloggingContext
{
public DbSet<Blog> Blogs { get; set; }
public 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 デザイナーを使用したコンテキスト インターフェイスの実装
このコンテキストでは、IBloggingContext インターフェイスが実装されることに注意してください。
Code First を使用している場合は、コンテキストを直接編集してインターフェイスを実装できます。 EF Designer を使用している場合は、T4 テンプレートを編集してコンテキストを生成する必要があります。 edmx ファイルの下に入れ子になっている <model_name>.Context.tt ファイルを開き、次のコード フラグメントを検索して、次に示すようにインターフェイスを追加します。
<#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#> : DbContext, IBloggingContext
テストするサービス
インメモリ テスト ダブルを使用してテストを実行するために、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 IBloggingContext _context;
public BlogService(IBloggingContext context)
{
_context = context;
}
public Blog AddBlog(string name, string url)
{
var blog = new Blog { Name = name, Url = url };
_context.Blogs.Add(blog);
_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();
}
}
}
インメモリ テストダブルの作成
実際の EF モデルとそれを使用できるサービスが用意されたので、テストに使用できるインメモリ テスト ダブルを作成します。 ここでは、コンテキストを表す TestContext テスト ダブルを作成しました。 テスト ダブルでは、実行するテストをサポートするために必要な動作を選択できます。 この例では、SaveChanges が呼び出された回数をキャプチャするだけですが、テストするシナリオを検証するために必要な任意のロジックを含めることができます。
また、DbSet のインメモリ実装を提供する TestDbSet も作成しました。 DbSetのすべてのメソッド (Findを除く) の完全な実装を提供していますが、実装する必要があるのは、テスト シナリオで使用するメンバーだけです。
TestDbSet では、非同期クエリを処理できるようにするために用意されている他のインフラストラクチャ クラスをいくつか使用します。
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
namespace TestingDemo
{
public class TestContext : IBloggingContext
{
public TestContext()
{
this.Blogs = new TestDbSet<Blog>();
this.Posts = new TestDbSet<Post>();
}
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
public int SaveChangesCount { get; private set; }
public int SaveChanges()
{
this.SaveChangesCount++;
return 1;
}
}
public class TestDbSet<TEntity> : DbSet<TEntity>, IQueryable, IEnumerable<TEntity>, IDbAsyncEnumerable<TEntity>
where TEntity : class
{
ObservableCollection<TEntity> _data;
IQueryable _query;
public TestDbSet()
{
_data = new ObservableCollection<TEntity>();
_query = _data.AsQueryable();
}
public override TEntity Add(TEntity item)
{
_data.Add(item);
return item;
}
public override TEntity Remove(TEntity item)
{
_data.Remove(item);
return item;
}
public override TEntity Attach(TEntity item)
{
_data.Add(item);
return item;
}
public override TEntity Create()
{
return Activator.CreateInstance<TEntity>();
}
public override TDerivedEntity Create<TDerivedEntity>()
{
return Activator.CreateInstance<TDerivedEntity>();
}
public override ObservableCollection<TEntity> Local
{
get { return _data; }
}
Type IQueryable.ElementType
{
get { return _query.ElementType; }
}
Expression IQueryable.Expression
{
get { return _query.Expression; }
}
IQueryProvider IQueryable.Provider
{
get { return new TestDbAsyncQueryProvider<TEntity>(_query.Provider); }
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return _data.GetEnumerator();
}
IEnumerator<TEntity> IEnumerable<TEntity>.GetEnumerator()
{
return _data.GetEnumerator();
}
IDbAsyncEnumerator<TEntity> IDbAsyncEnumerable<TEntity>.GetAsyncEnumerator()
{
return new TestDbAsyncEnumerator<TEntity>(_data.GetEnumerator());
}
}
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; }
}
}
}
Find の実装
Find メソッドは、一般的な方法で実装するのが困難です。 Find メソッドを使用するコードをテストする必要がある場合は、Find をサポートする必要があるエンティティ型ごとにテスト DbSet を作成するのが最も簡単です。 その後で、次に示すように、特定のエンティティ型を検索するロジックを記述できます。
using System.Linq;
namespace TestingDemo
{
class TestBlogDbSet : TestDbSet<Blog>
{
public override Blog Find(params object[] keyValues)
{
var id = (int)keyValues.Single();
return this.SingleOrDefault(b => b.BlogId == id);
}
}
}
一部のテストの記述
テストを開始するために必要な操作はこれだけです。 次のテストでは、このコンテキストに基づいて TestContext とサービスを作成します。 次に、このサービスを使用して新しいブログを作成します。ここでは、AddBlog メソッドを使用します。 最後に、このテストでは、サービスがコンテキストで新しいブログを追加し、SaveChanges を呼び出したことを確認します。
これは、インメモリ テスト ダブルを使用してテストできることの一例にすぎません。テスト ダブルと検証のロジックを調整することで、ご自身のさまざまな要件を満たすことができます。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
namespace TestingDemo
{
[TestClass]
public class NonQueryTests
{
[TestMethod]
public void CreateBlog_saves_a_blog_via_context()
{
var context = new TestContext();
var service = new BlogService(context);
service.AddBlog("ADO.NET Blog", "http://blogs.msdn.com/adonet");
Assert.AreEqual(1, context.Blogs.Count());
Assert.AreEqual("ADO.NET Blog", context.Blogs.Single().Name);
Assert.AreEqual("http://blogs.msdn.com/adonet", context.Blogs.Single().Url);
Assert.AreEqual(1, context.SaveChangesCount);
}
}
}
テストの例をもう 1 つ示します。これは、クエリを実行するテストです。 このテストは、Blog プロパティにデータを含むテスト コンテキストを作成することから開始します。データがアルファベット順ではないことに注意してください。 次に、テスト コンテキストに基づいて BlogService を作成し、GetAllBlogs から返されるデータが名前順に並んでいることを確認します。
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace TestingDemo
{
[TestClass]
public class QueryTests
{
[TestMethod]
public void GetAllBlogs_orders_by_name()
{
var context = new TestContext();
context.Blogs.Add(new Blog { Name = "BBB" });
context.Blogs.Add(new Blog { Name = "ZZZ" });
context.Blogs.Add(new Blog { Name = "AAA" });
var service = new BlogService(context);
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);
}
}
}
最後に、非同期メソッドを使用して、TestDbSet に含めた非同期インフラストラクチャが動作していることを確認するテストをもう 1 つ作成します。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace TestingDemo
{
[TestClass]
public class AsyncQueryTests
{
[TestMethod]
public async Task GetAllBlogsAsync_orders_by_name()
{
var context = new TestContext();
context.Blogs.Add(new Blog { Name = "BBB" });
context.Blogs.Add(new Blog { Name = "ZZZ" });
context.Blogs.Add(new Blog { Name = "AAA" });
var service = new BlogService(context);
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);
}
}
}
.NET