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





5.00/5 (1投票)
关于在单元测试中如何使用 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();
}
结果是,你处理的不是真实数据库,而是某种形式的内存中数据库。这样你将获得几个优势
- 你不必创建真实数据库。
- 你的测试将完全独立,因为很明显,在真实数据库的情况下,你不会为每个测试创建一个单独的数据库——只有一个为所有测试共享。但现在,为每个测试拥有一个独立的内存中数据库是一个合适、简单且不错的决定。
- 测试将更快通过。
- 如果你遵循持续集成范例,这将有助于解决在云环境中创建或获取真实数据库的问题。
- 内存中数据库并不意味着它只是某种“虚拟”数据容器,而是一个非常体面的真实关系数据库的模拟,几乎拥有所有相应的特性、限制和约束。
所以这篇文章是关于:如何在实践中实现 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
并属于特定的Category
。TotalAmount
- 是位于特定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
调用中。
我们有一个简单的服务,包含两个例行方法:Add
和Delete
产品
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);
}
}
现在每个测试都有其自己的独立Context
和Service
,其中ProductTrigger
将在后台工作,CategoryBalances
也将像真实数据库视图一样工作,因此我们可以无限制地依赖它们在Service
内部和外部的行为。
结论
在这篇文章中,我展示了如何通过 Effort 测试处理数据库的代码。本文介绍了该库的优点,以及如何规避 Effort 的一些陷阱,如不允许的属性,如何模拟视图和触发器,以及总体方法。你可以在此链接找到所有代码和多个测试。
历史
- 2017年9月20日:初始版本