65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Effort 库进行内存实体框架测试,实践篇

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2017年9月20日

CPOL

4分钟阅读

viewsIcon

19101

关于在单元测试中如何使用 Effort 库的实践指南,包括与视图和触发器的解决方法以及一些陷阱

引言

这是一个非常古老的问题:“如何测试那些以某种方式处理数据库的代码?”有两种方法:在测试中使用真实数据库,但这会遇到很多问题(创建、性能、相互依赖等等),或者模拟相应的数据库层(Repository 模式)。对我来说,后一种方法更可取,但如果你不遵循Repository模式,而是直接使用DbContext实例,并且/或者你的单元测试逻辑足够复杂,那么模拟就会变得非常繁琐和复杂:例如,你不想手动模拟唯一约束异常抛出,当尝试添加已存在的条目时——自动抛出异常会更好、更受欢迎。这时,满足我们所有需求的Effort 库就派上用场了。

Effort 是一个库,它允许你编写代码的单元测试,即使你直接使用DbContext实例,而无需中间的Repository层。Effort 会创建你的自定义上下文实例,在其构造函数中提供一个特殊的连接(阅读此文)

using (var ctx = new PeopleDbContext(Effort.DbConnectionFactory.CreateTransient()))
{
    ctx.People.Add(new Person() { Id = 1, Name = "John Doe" });
    ctx.SaveChanges();
}

结果是,你处理的不是真实数据库,而是某种形式的内存中数据库。这样你将获得几个优势

  1. 你不必创建真实数据库。
  2. 你的测试将完全独立,因为很明显,在真实数据库的情况下,你不会为每个测试创建一个单独的数据库——只有一个为所有测试共享。但现在,为每个测试拥有一个独立的内存中数据库是一个合适、简单且不错的决定。
  3. 测试将更快通过。
  4. 如果你遵循持续集成范例,这将有助于解决在云环境中创建或获取真实数据库的问题。
  5. 内存中数据库并不意味着它只是某种“虚拟”数据容器,而是一个非常体面的真实关系数据库的模拟,几乎拥有所有相应的特性、限制和约束。

所以这篇文章是关于:如何在实践中实现 Effort:如何将其注入到你的主要和测试基础设施中,解释在存在视图和触发器的情况下该怎么做,以及其他一些有趣的点。

前提条件和解决方案

让我们来看一个简单的演示场景。是的,我知道,这是一个糟糕的 ER 架构,所有这些都只是为了举例。我们有三个带有 POCOs 的表

public class Stock : BaseModel
{               
    public virtual ICollection<Product> Products { get; set; }               
    public decimal TotalAmount { get; set; }
}

public class Product : BaseModel
{        
    public decimal Price { get; set; }
    [StringLength(32)]
    public string Category { get; set; }   

    public virtual Stock Stock { get; set; }
    public int StockId { get; set; }
}

public class CategoryBalance
{        
    [Key]
    [StringLength(32)]
    public string Category { get; set; }
    public int Quantity { get; set; }
    public decimal Amount { get; set; }
}

public abstract class BaseModel
{
    public int Id { get; set; }        
    [Required]
    [StringLength(32)]
    //[Column(TypeName = "varchar")]
    public string Name { get; set; }
}

每个产品都位于特定的仓库 (StockId),拥有Price并属于特定的CategoryTotalAmount - 是位于特定Stock的所有产品的总和,值得注意的是,这个列是通过触发器 (ProductTrigger) 计算并分配的,当我们对products表执行insert/delete/update操作时。CategoryBalance - 不是一个表,而是一个基于products表的“聚合”视图,视图的查询将如下所示

select Category, Sum(Price) Amount, Count(*) Quantity
from Products
group by Category

因此,用于视图的 T-SQL 脚本(而不是用于表创建的自动生成代码)和触发器,很可能将位于 Migration 的Up方法中的Sql调用中。

我们有一个简单的服务,包含两个例行方法:AddDelete产品

public class Service
{
    private IDataContextFactory Factory { get; set; }
    public Service(IDataContextFactory factory)
    {
        Factory = factory;
    }

    public int AddProduct(string name, decimal price, string category, int stockId)
    {
        using (var context = Factory.CreateContext())
        {
            var product = context.Products.Add(new Product { 
                Name = name, Category = category, Price = price, StockId = stockId 
            });
            context.SaveChanges();
            return product.Id;
        }
    }

    public void DeleteProduct(int id)
    {
        using (var context = Factory.CreateContext())
        {
            var product = context.Products.Find(id);
            context.Products.Remove(product);
            context.SaveChanges();
        }
    }
}

Factory是用来注入DbContext创建方式的

public interface IDataContextFactory
{
    DataContext CreateContext();
}

public class DataContextFactory : IDataContextFactory
{
    public DataContext CreateContext()
    {
        return new DataContext();
    }
}

因此,在生产环境中,我们将创建一个普通的上下文实例,但在测试环境中,它将通过 Effort 创建(稍后展示)。现在,让我们看看上下文类

public class DataContext : DbContext
{
    public DataContext()
    {
    }

    //this constructor is needed only for Effort, as attachment point
    //we will pass connection, fetched from this library as argument
    public DataContext(DbConnection connection) : base(connection, true)
    {            
    }
        
    public DbSet<Product> Products { get; set; }
    public DbSet<CategoryBalance> CategoryBalances { get; set; }
    public DbSet<Stock> Stocks { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        OnModelCreatingNotCompatibleWithEffort(modelBuilder);
        base.OnModelCreating(modelBuilder);
    }
        
    protected virtual void OnModelCreatingNotCompatibleWithEffort
                      (DbModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().Property(x => x.Name).HasColumnType("varchar");
        modelBuilder.Entity<Stock>().Property(x => x.Name).HasColumnType("varchar");
    }
}

在这里,我们有一个额外的构造函数,专门用于Effort,以及一个OnModelCreatingNotCompatibleWithEffort方法。有两个类继承自BaseModel,其中ColumnAttribute被提交,但其本质被移到了上述方法中。我们这样做是为了展示Effort 的一个陷阱——它不支持某些功能,比如这个属性。为了解决这个问题,你可以通过 Fluent API 声明你的意图,将其移到一个单独的方法中,然后用一个空白的实现来覆盖它。对于所有与 Effort 不兼容且对测试目的意义不大的东西,你都可以这样做。

public class EffortContext : DataContext
{
    protected override void OnModelCreatingNotCompatibleWithEffort
                       (DbModelBuilder modelBuilder)
    {
        //blank implementation
    }

    public EffortContext(DbConnection connection) : base(connection)
    {
        MockCategoryBalance();
    }

    public override int SaveChanges()
    {
        MockProductTrigger();
        return base.SaveChanges();
    }        

    private void MockCategoryBalance()
    {
        var view = (from product in Products
                    group product by product.Category into sub
                    select new
                    {
                        sub.Key,
                        Amount = sub.Sum(x => x.Price),
                        Quantity = sub.Count()
                    }).AsEnumerable()
                    .Select(x => new CategoryBalance
                    {
                        Category = x.Key,
                        Amount = x.Amount,
                        Quantity = x.Quantity
                    }).AsQueryable();

        var mockSet = new Mock<DbSet<CategoryBalance>>();

        mockSet.As<IQueryable<CategoryBalance>>().Setup
                  (m => m.Provider).Returns(view.Provider);
        mockSet.As<IQueryable<CategoryBalance>>().Setup
                  (m => m.Expression).Returns(view.Expression);
        mockSet.As<IQueryable<CategoryBalance>>().Setup
                  (m => m.ElementType).Returns(view.ElementType);
        mockSet.As<IQueryable<CategoryBalance>>().Setup(m => m.GetEnumerator())
                                                 .Returns(() => view.GetEnumerator());

        //this would allow to write something like this: 
        //CategoryBalances.Include("SomeRef")
        mockSet.Setup(m => m.Include(It.IsAny<string>())).Returns(() => mockSet.Object);

        CategoryBalances = mockSet.Object;
    }

    private void MockProductTrigger()
    {            
        var changes = ChangeTracker.Entries<Product>().Where
                      (x => x.State != EntityState.Unchanged);
        foreach (var item in changes)
        {
            decimal delta = 0;
            var quantityProperty = item.Property(x => x.Price);
            switch (item.State)
            {
                case EntityState.Deleted:
                    delta = -quantityProperty.CurrentValue;
                    break;
                case EntityState.Added:
                    delta = quantityProperty.CurrentValue;
                    break;
                default:
                    delta = quantityProperty.CurrentValue - 
                            quantityProperty.OriginalValue;
                    break;
            }
            var stock = Stocks.Find(item.Entity.StockId);                
            stock.TotalAmount += delta;
        }                            
    }        
}

这里也实现了CategoryBalances视图。我们只需写出相应的查询(view),就像上面所示的 T-SQL 脚本一样,并在指南的帮助下模拟CategoryBalances。值得注意的是,我们不能直接写select new CategoryBalance而无需中间的匿名投影和调用.AsEnumerable(),因为这是 Entity Framework 本身的一个限制,即不能直接手动投影到表的类。

上面提到的ProductTrigger已经实现,并在SaveChanges方法之前调用。我们分析更改并执行必要的修改,模拟真实数据库触发器的行为。

测试

现在让我们看看测试基础设施:IDataContextFactory接口的另一个实现

public class EffortDataContextFactory : IDataContextFactory
{
    private readonly DbConnection Connection;
    //connection we will fetch from Effort stuff
    public EffortDataContextFactory(DbConnection connection)
    {
        Connection = connection;
    }

    public DataContext CreateContext()
    {
        return new EffortContext(Connection);
    }
}

最后,我将展示一个测试

[TestClass]
public class UnitTests
{
    private Service Service { get; set; }
    private DataContext Context { get; set; }
    private Stock Stock1 { get; set; }
    private Stock Stock2 { get; set; }       

    [TestInitialize]
    public void TestInitialize()
    {
        var factory = new EffortDataContextFactory
                      (Effort.DbConnectionFactory.CreateTransient());
        Context = factory.CreateContext();

        //it is implementation of standard, 
        //well known Seed method from Configuration class
        Seeder.Seed(Context);
        //Seed body:
        //context.Stocks.AddOrUpdate(x => x.Name, new Stock { Name = "First" });
        //context.Stocks.AddOrUpdate(x => x.Name, new Stock { Name = "Second" });
        //context.SaveChanges();

        Stock1 = Context.Stocks.Where(x => x.Name == "First").Single();
        Stock2 = Context.Stocks.Where(x => x.Name == "Second").Single();
        Service = new Service(factory);            
    }        

    [TestCleanup]
    public void TestCleanup()
    {
        Context.Dispose();
    }             

    [TestMethod]
    public void AddProducts()
    {
        Service.AddProduct("product1", 10, "category1", Stock1.Id);
        Service.AddProduct("product2", 20, "category1", Stock1.Id);
        Service.AddProduct("product3", 30, "category2", Stock1.Id);
        Service.AddProduct("product4", 40, "category2", Stock2.Id);
        Service.AddProduct("product5", 50, "category2", Stock2.Id);

        Assert.AreEqual(150, Context.Products.Sum(x => x.Price));
        Assert.AreEqual(5, Context.Products.Count());

        //to refresh entities
        Context.Entry(Stock1).Reload();
        Context.Entry(Stock2).Reload();

        Assert.AreEqual(60, Stock1.TotalAmount);
        Assert.AreEqual(90, Stock2.TotalAmount);

        var category = Context.CategoryBalances.Single(x => x.Category == "category1");
        Assert.AreEqual(30, category.Amount);
        Assert.AreEqual(2, category.Quantity);

        category = Context.CategoryBalances.Single(x => x.Category == "category2");
        Assert.AreEqual(120, category.Amount);
        Assert.AreEqual(3, category.Quantity);            
    }       
}

现在每个测试都有其自己的独立ContextService,其中ProductTrigger将在后台工作,CategoryBalances也将像真实数据库视图一样工作,因此我们可以无限制地依赖它们在Service内部和外部的行为。

结论

在这篇文章中,我展示了如何通过 Effort 测试处理数据库的代码。本文介绍了该库的优点,以及如何规避 Effort 的一些陷阱,如不允许的属性,如何模拟视图和触发器,以及总体方法。你可以在此链接找到所有代码和多个测试。

历史

  • 2017年9月20日:初始版本
© . All rights reserved.