使用自己的测试替身进行测试

注意

仅限 EF6 及更高版本 - 此页面中讨论的功能、API 等已引入实体框架 6。 如果使用的是早期版本,则部分或全部信息不适用。

为应用程序编写测试时,通常需要避免触及数据库。 你可以借助实体框架,通过创建使用内存中数据的上下文(其行为由你的测试定义)来实现这一点。

用于创建测试替身的选项

可通过两种不同的方法创建上下文的内存中版本。

  • 创建你自己的测试替身 - 此方法涉及编写你自己的上下文和 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 以及 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 设计器,则需要编辑生成上下文的 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 的次数,但你可以包括所需的任何逻辑来验证测试的场景。

我们还创建了 TestDbSet 来提供 DbSet 的内存中实现。 我们已经为 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 方法创建新的博客。 最后,此测试将验证服务是否已将新的博客添加到上下文的 Blogs 属性,并对上下文调用 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);
        }
    }
}

下面是测试的另一个示例 - 这次的测试是执行查询。 首先,通过在其 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 中包含的异步基础结构正常运行。

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