使用模拟框架进行测试
注意
仅限 EF6 及更高版本 - 此页面中讨论的功能、API 等已引入实体框架 6。 如果使用的是早期版本,则部分或全部信息不适用。
为应用程序编写测试时,通常需要避免触及数据库。 你可以借助实体框架,通过创建使用内存中数据的上下文(其行为由你的测试定义)来实现这一点。
用于创建测试替身的选项
可通过两种不同的方法创建上下文的内存中版本。
- 创建你自己的测试替身 - 此方法涉及编写你自己的上下文和 DbSet 的内存中实现。 使用此方法,你可以很大程度上控制类的行为方式,但可能需要编写和拥有合理数量的代码。
- 使用模拟框架创建测试替身 - 使用模拟框架(例如 Moq),你可以在运行时动态创建上下文和 DbSet 的内存中实现。
本文将讨论如何使用模拟框架。 若要创建你自己的测试替身,请参阅使用你自己的测试替身进行测试。
为了演示如何将 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 以及 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 设计器编辑虚拟 DbSet 属性
请注意,上下文中的 DbSet 属性标记为虚拟。 这将允许模拟框架从上下文派生并使用模拟实现替代这些属性。
如果使用 Code First,则可以直接编辑类。 如果使用的是 EF 设计器,则需要编辑生成上下文的 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);
}
}
}
使用异步查询进行测试
实体框架 6 引入了一组可用于异步执行查询的扩展方法。 这些方法的示例包括 ToListAsync、FirstAsync、ForEachAsync 等。
由于实体框架查询使用 LINQ,因此扩展方法在 IQueryable 和 IEnumerable 上定义。 但是,由于它们设计为只能与实体框架一起使用,因此,如果尝试在不是实体框架查询的 LINQ 查询上使用它们,你可能会收到以下错误消息:
源 IQueryable 未实现 IDbAsyncEnumerable{0}。 只有实现 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);
}
}
}