使用模拟框架进行测试

注意

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