如何模拟测试 Entity Framework Model-First 项目
解释了如何使用 ADO.NET Entity Data Model 模板来模拟测试 EF 模型优先项目
引言
在 TDD(测试驱动开发)中,先编写单元测试以测试代码逻辑而不依赖数据库至关重要。即使在开发生命周期的后期,仅仅为了单元测试而维护数据库也可能既麻烦又容易出错。此外,为 CI(持续集成)构建过程的自动化单元测试准备数据库可能会带来不必要的复杂性。本文提供了一个示例,展示如何使用 ADO.NET Entity Data Model 模板来模拟测试 EF 模型优先项目,以加强和简化您的单元测试实现。
背景
我当前的项目使用 .NET 4 + EF 4,并从 ADO.NET Entity Data Model 模板(.edmx)生成代码。在我研究如何很好地模拟测试代码的过程中,我发现大多数可用的示例都集中在 Code First/POCO 项目上,很少提及 Model-First 项目。这可能是由于 Designer.cs 中生成的代码及其灵活性不足,这归因于 MS 在启用代码模拟测试方面的一些疏忽。
不幸的是,要克服这些问题意味着我们需要对生成的代码进行一些更改。但是,由于这些更改相对容易实现,因此可以编写一个脚本来减轻每次更新模型后重复性任务的痛苦。
入门
首先,让我们定义一个非常简单的数据模型
然后,让我们基于此模型创建一个 .edmx 文件
现在,您应该已经将 .edmx 文件及其生成的代码添加到您的项目中了。让我们看一下生成的代码,了解存在哪些问题。
打开 MyModel.Designer.cs,转到类声明,您应该会看到类似以下的行
public partial class MyEntities : ObjectContext
第一个问题是,此类默认继承自 ObjectContext
。ObjectContext
有一个内部构造函数,该构造函数根据连接字符串建立数据库连接。由于这是一个内部构造函数,它无法通过使用 partial
类或定义一个通用的 interface
来覆盖。因此,如果我们想为我们的模拟实体基类使用 interface
,我们就必须修改这些生成的代码。
接下来,Customers
实体集合声明为
public ObjectSet<Customer> Customers
由于 ObjectSet<T>
是一个具体类,我们需要将其更改为一个 ObjectSet<T>
实现的 interface
,以便能够模拟它。总而言之,我们需要对 MyModel.Designer.cs 进行以下更改:
- 将继承自
ObjectContext
替换为您定义的通用interface
(稍后解释) - 将所有
ObjectSet<T>
替换为IObjectSet<T>
现在这应该不那么糟糕了。让我们继续学习如何编写我们的模拟实体和 interface
。
多态性是你的朋友
为了拥有一个具有相同方法集的模拟实体类,并能够使用它进行模拟测试代码,我们需要为真实实体类和模拟实体类定义一个通用的 interface
。
public interface IMyEntities
{
IObjectSet<Customer> Customers { get; }
IObjectSet<Order> Orders { get; }
int SaveChanges();
}
一旦我们定义了这个 interface
,我们就可以让我们的真实实体类和模拟实体类都实现它。以下是模拟实体类的样子
public class MyEntitiesMock : IMyEntities
{
private IObjectSet<Customer> customers;
private IObjectSet<Order> orders;
public IObjectSet<Customer> Customers
{
get { return customers ?? (customers = new MockObjectSet<Customer>()); }
}
public IObjectSet<Order> Orders
{
get { return orders ?? (orders = new MockObjectSet<Order>()); }
}
public int SaveChanges()
{
return 0;
}
}
请注意,由于真实实体类和模拟实体类都应该实现相同的 IMyEntities interface
,我们需要在 MyModel.Designer.cs 中对生成的代码进行第一个上述更改。
public partial class MyEntities : IMyEntities
在 MyEntitiesMock
中,public
属性 Customers
和 Orders
都返回 MockObjectSet<T>
的一个实例,我们稍后会解释它。请注意,SaveChanges()
方法实际上什么都不做,只是返回 0
。您可以在此处添加其他骨架方法,它们执行相同的功能,以模拟 EF 支持的那些方法。
MockObjectSet<T>
类必须实现 IObjectSet
中定义的所有方法,以提供相同的功能。以下是它的样子
using System;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using System.Data.Objects;
namespace MyApplication
{
public partial class MockObjectSet<T> : IObjectSet<T> where T : class
{
private readonly IList<T> collection = new List<T>();
#region IObjectSet<T> Members
public void AddObject(T entity)
{
collection.Add(entity);
}
public void Attach(T entity)
{
collection.Add(entity);
}
public void DeleteObject(T entity)
{
collection.Remove(entity);
}
public void Detach(T entity)
{
collection.Remove(entity);
}
#endregion
#region IEnumerable<T> Members
public IEnumerator<T> GetEnumerator()
{
return collection.GetEnumerator();
}
#endregion
#region IEnumerable Members
IEnumerator IEnumerable.GetEnumerator()
{
return collection.GetEnumerator();
}
#endregion
#region IQueryable<T> Members
public Type ElementType
{
get { return typeof(T); }
}
public System.Linq.Expressions.Expression Expression
{
get { return collection.AsQueryable<T>().Expression; }
}
public IQueryProvider Provider
{
get { return collection.AsQueryable<T>().Provider; }
}
#endregion
}
}
我们最后需要的是一个上下文容器来实例化并返回正确的对象上下文。同样,我们需要定义一个通用的 interface
,以便我们可以有两个不同的容器类,每个类都有其自身的行为。
上下文容器及其真实的上下文类如下所示
namespace MyApplication
{
public interface IContextContainer
{
IMyEntities Current { get; }
}
public class ContextContainer : IContextContainer
{
private static readonly IMyEntities objectContext = new MyEntities();
public IMyEntities Current
{
get { return objectContext; }
}
}
}
模拟上下文容器实现了相同的 interface
,如下所示
namespace MyApplication
{
public class ContextContainerMock : IContextContainer
{
private static readonly IMyEntities context = new MyEntitiesMock();
public IMyEntities Curren
{
get { return context; }
}
}
}
现在我们已经有了所有基础知识,让我们继续看看如何使用它们。
服务和实体类
在我的设计中,我有一个服务层和数据层。前者包含所有业务逻辑,而后者仅负责数据访问。这种设计使我能够拥有解耦的层,这些层可以轻松地进行扩展和维护。CustomerService
类如下所示
using System.Data.Objects;
namespace MyApplication
{
class CustomerService
{
protected readonly IContextContainer container;
public CustomerService(IContextContainer container)
{
this.container = container;
}
public Customer GetCustomer(int id)
{
// you may have some business logic here
var customer = new Customer(this.container);
return customer.Select(id);
}
}
}
正如您所见,GetCustomer
方法可以执行一些业务逻辑,然后调用实体类中的 Select()
方法,该方法如下所示
public partial class Customer
{
private readonly IContextContainer container;
public Customer()
{
}
public Customer(IContextContainer container)
{
this.container = container;
}
public Customer Select(int id)
{
return this.container.Current.Customers.FirstOrDefault(x =>x.ID == id);
}
}
请注意,由于我们有另一个来自生成代码的 Customer
的 partial
类,所以它被声明为 partial
类。这里的 Select()
方法仅仅是 EF 的魔力;也就是说,它会去数据源,获取数据,然后将其映射到一个 Customer
对象并返回。
现在我们的项目里有了所有东西,让我们看看我们将如何对服务类中的 GetCustomer()
方法进行单元测试。
单元测试
在我们的单元测试代码中,我们所要做的就是使用 ContextContainerMock
来返回一个实现 IMyEntities
的模拟实体类。然后,我们可以根据需要调用/测试这些方法
[TestMethod]
public void GetCustomer()
{
ContextContainerMock container = new ContextContainerMock();
IMyEntities en = container.Current;
Customer c = new Customer { ID = 1, FirstName = "John", LastName = "Doe" };
en.Customers.AddObject(c);
CustomerService service = new CustomerService(container);
var a = service.GetCustomer(1);
Assert.AreEqual(c.FirstName, a.FirstName);
Assert.AreEqual(c.LastName, a.LastName);
}
调用存储过程的方法呢?
如果你的方法使用 EF 模型中的存储过程和函数导入,你仍然可以通过对生成的代码进行另外一项更改来模拟测试它们。
假设你有一个名为 GetCustomers
的存储过程,它只是返回 Customer
表中的所有客户行。一旦将存储过程包含在 EF 模型中,完成函数导入部分并保存模型,您就应该得到类似这样的生成代码
public ObjectResult<Customer> GetCustomers()
{
return base.ExecuteFunction<Customer>("GetCustomers");
}
你需要将返回类型从 ObjectResult<T>
更改为 IEnumerable<T>
,这样你就可以在 IMyEntities interface
中声明这个方法了。
public interface IMyEntities
{
IObjectSet<Customer> Customers { get; }
IObjectSet<Order> Orders { get; }
IEnumerable<Customer> GetCustomers();
int SaveChanges();
}
然后,当你的模拟测试调用这个方法时,你可以在 MyEntitiesMock
中添加代码。基本上,它只是返回一个 MockObjectSet<GetCustomersResult>
的新实例。
public IEnumerable<GetCustomersResult> GetCustomers
{
get { return customers ?? (customers = new MockObjectSet<GetCustomersResult>()); }
}
最后,在你的模拟单元测试中,你会做类似这样的事情
[TestMethod]
public void GetCustomers()
{
ContextContainerMock container = new ContextContainerMock();
IMyEntities en = container.Current;
GetCustomersResult c1 = new GetCustomersResult
{ ID = 1, FirstName = "John", LastName = "Doe" };
GetCustomersResult c2 = new GetCustomersResult
{ ID = 2, FirstName = "Mary", LastName = "Doe" };
en.GetCustomersResult.AddObject(c1);
en.GetCustomersResult.AddObject(c2);
CustomerService service = new CustomerService(container);
var a = service.GetCustomers();
Assert.AreEqual(c1.FirstName, a[0].FirstName);
Assert.AreEqual(c2.FirstName, a[1].FirstName);
}
摘要
在本文中,我们看到如何通过对 ADO.NET Entity Data Model 模板生成的代码进行一些小的更改来模拟测试 EF 模型优先项目。我认为这是能够进行模拟测试的一个小小的代价。更重要的是,它允许您轻松地通过 CI 构建过程自动化您的单元测试,而无需担心拥有和维护仅用于这些测试的数据库实例。毕竟,单元测试的目的应该是验证您的逻辑,而不是数据。