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

测试 Entity Framework 的两种策略 - Effort 和 SQL CE

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (14投票s)

2012年9月17日

CPOL

6分钟阅读

viewsIcon

122709

downloadIcon

2537

单元测试 Entity Framework 的两种方法

引言

单元测试基于 Entity Framework 的业务逻辑是一项艰巨的任务。单元测试的目的是隔离地测试业务逻辑,而不依赖于系统的其他组件。

Entity Framework 的问题在于它依赖于真实数据库的存在才能正确运行。单元测试的常见方法是抽象 Entity Framework 类(例如 DbContext),并针对内存中的模拟对象运行被测业务逻辑。

然而,这种方法存在一些问题:

  1. 使用模拟对象运行 Linq 表达式可能会产生与针对真实数据库运行时不同的结果。某些操作可能会抛出异常,因此通过的单元测试并不一定意味着代码将在生产环境中正常工作。
  2. 模拟测试数据可能需要大量工作。对于实体类型之间存在大量关系的复杂模式,需要通过手动设置适当的导航属性来精心制作这些引用。
  3. 当针对真实数据库运行时,DbSet.Include 可以配置 Entity Framework 自动加载相关实体。如果使用模拟对象,此方法将不起作用。

本文介绍了两种不同的 Entity Framework 测试方法,可以减少这些问题。

1. Effort

http://effort.codeplex.com/

这个项目是一个在内存中运行的 Entity Framework 提供程序。你仍然可以在单元测试中使用你的 DbContextObjectContext 类,而无需实际的数据库。

Entity Framework 的许多功能仍然有效。导航属性将自动加载相关实体,DbSet.Include 方法也像连接到真实数据库一样工作。

这种方法的一个缺点是,我们仍然没有针对真实数据库运行,而是针对不同的 Entity Framework 提供程序。某些操作的行为与针对真实数据库不同。事实上,在撰写本文时,Effort 支持其他数据库提供程序支持的某些操作。(请参阅 CodePlex 上的此问题)。

本质上,这种方法有助于解决上述第 2 和第 3 点,但不能解决第 1 点。

2. SQL CE

这种方法使用真实数据库。本文将向你展示如何即时创建 SQL CE 数据库以运行测试。SQL CE 还支持 Entity Framework Code First,因此数据库架构也会根据你的实体模型自动生成。

值得指出的是,这种方法可能比内存中测试慢,因为 SQL CE 是一个功能齐全的数据库,操作会写入磁盘,而不是仅仅在内存中工作。

一个 Entity Framework 模型示例

在这个例子中,实体模型包含 "Product" 和 "Tag" 实体,它们之间存在多对多关系。这种关系通过 "ProductTag" 实体实现,它将 ProductTag 关联起来。

public partial class Product
{   
    public System.Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public Nullable<decimal> Price { get; set; }
    public System.DateTime CreatedDate { get; set; }
    
    public virtual ICollection<ProductTag> ProductTags { get; set; }
}

public partial class Tag
{
    public System.Guid Id { get; set; }
    public string Name { get; set; }
    public System.DateTime CreatedDate { get; set; }
    
    public virtual ICollection<ProductTag> ProductTags { get; set; }
}

public partial class ProductTag
{
    public System.Guid Id { get; set; }
    public System.Guid ProductId { get; set; }
    public System.Guid TagId { get; set; }
    public System.DateTime CreatedDate { get; set; }
    
    public virtual Product Product { get; set; }
    public virtual Tag Tag { get; set; }
}

我还有一个继承自 DbContextMyAppContext 类。

public partial class MyAppContext : DbContext
{
    public MyAppContext()
        : base("name=MyAppContext")
    {
        this.Configuration.LazyLoadingEnabled = false;
    }
    
    public MyAppContext(string connectionString)
        : base(connectionString)
    {
        this.Configuration.LazyLoadingEnabled = false;
    }
    
    public MyAppContext(DbConnection connection)
        : base(connection, true)
    {
        this.Configuration.LazyLoadingEnabled = false;
    }
    
    public IDbSet<Product> Products { get; set; }
    public IDbSet<ProductTag> ProductTags { get; set; }
    public IDbSet<Tag> Tags { get; set; }   
}

业务逻辑和单元测试示例

为了演示不同的测试方法,我需要一些代码进行测试。我有一个 ProductRepository 类,其中有一个 GetProductsByTagName 方法,它获取所有标有指定 tagNameProduct 实体。类构造函数接受一个 MyAppContext 实例,它是 Entity Framework DbContext 类。

public class ProductRepository
{
    private MyAppContext _context;

    public ProductRepository(MyAppContext context)
    {
        _context = context;
    }

    // gets all products that have a matching tag associated with it.
    public IEnumerable<Product> GetProductsByTagName(string tagName)
    {
        // get the products that have a related tag with the specified name
        var products = _context.Products
            .Where(x => x.ProductTags.Any(pt => pt.Tag.Name == tagName))
            .ToList();

        return products;
    }
}

我还有一个简单的单元测试,它调用此方法并测试是否返回了正确的 Product。在下面的测试中,_context 变量是 MyAppContext 的实例。创建此变量以用于两种不同的测试方法将在本文的下一节中解释。

[TestMethod]
public void GetProductsByTagName_Should_ReturnProduct()
{
    var productRepository = new ProductRepository(_context);

    IEnumerable<Product> products = productRepository.GetProductsByTagName("Tag 1");

    // check the correct product is retrieved
    Assert.AreEqual(1, products.Count());
    Assert.AreEqual("Product 1", products.First().Name);
}

使用 Effort 进行测试

使用 Effort 进行测试的关键是,我们使用 Effort 创建一个 DbConnection 对象,然后我们使用它来初始化我们的 MyAppContext 类。

DbConnection connection = Effort.DbConnectionFactory.CreateTransient();

var dbContext = new MyAppContext(connection);

一旦我们有了 DbConnection 实例,我们就可以一次又一次地使用它来创建新的 DbContext 实例,这有效地模拟了对同一数据库的不同连接。这很有用,因为我们可以创建一个连接来用测试数据填充我们的数据库,然后创建一个新的连接来进行测试。

DbConnection 对象实际上是数据库的实例,所以如果你使用 DbConnectionFactory.CreateTransient() 创建一个新的实例,它将包含你添加到不同实例中的任何数据。这意味着你需要在测试期间持有 DbConnection 对象的实例。

以下代码显示了在每个测试运行之前运行的 Test Initialize 方法。它创建一个新的 DbConnection 对象,代表数据库的新实例;使用新的 DbContext 对象向我们的实体模型添加测试数据;然后使用与 DbConnection 相同的实例为我们的测试创建一个新的 DbContext 对象。

private MyAppContext _context;

[TestInitialize]
public void SetupTest()
{
    // create a new DbConnection using Effort
    DbConnection connection = Effort.DbConnectionFactory.CreateTransient();

    // create the DbContext class using the connection
    using (var context = new MyAppContext(connection))
    {
        // Add test data to the database here
        context.Products.Add(new Product() { Id = ... });
        // ... CODE OMMITTED ...
    }

    // use the same DbConnection object to create the context object the test will use.
    _context = new MyAppContext(connection);
}

在设置测试数据时,值得一提的是,无需初始化指向相关实体的属性(例如,Product.ProductTagsTag.ProductTags)。我们所需要做的就是添加单个实体,Effort 将自动使用 ID 值关联这些实体,就像真实数据库一样。

using (MyAppContext context = new MyAppContext(connection))
{
    // Add 2 Product entities
    context.Products.Add(new Product() { Id = new Guid("CEA4655C-..."), Name = "Product 1", ...
    context.Products.Add(new Product() { Id = new Guid("A4A989A4-..."), Name = "Product 2", ...

    // Add 3 Tag entities
    context.Tags.Add(new Tag() { Id = new Guid("D7FE98A2-..."), Name = "Tag 1", ...
    context.Tags.Add(new Tag() { Id = new Guid("52FEDB17-..."), Name = "Tag 2", ...
    context.Tags.Add(new Tag() { Id = new Guid("45312740-..."), Name = "Tag 3", ...

    // Associate Product 1 with Tag 1
    context.ProductTags.Add(new ProductTag() 
    { 
        ProductId = new Guid("CEA4655C-..."), 
        TagId = new Guid("D7FE98A2-...")
        ...
    });

    // Associate Product 1 with Tag 3
    context.ProductTags.Add(new ProductTag() 
    { 
         ProductId = new Guid("CEA4655C-..."),
         TagId = new Guid("45312740-...")
         ...
    });

    // Associate Product 2 with Tag 2
    context.ProductTags.Add(new ProductTag() 
    { 
        ProductId = new Guid("A4A989A4-..."),
        TagId = new Guid("52FEDB17-...")
        ...
    });
}

使用 SQL CE 进行测试

使用 SQL CE 进行测试时,我们需要为每个测试创建真实的 SQL CE 数据库,然后使用连接到此数据库的连接初始化我们的 MyAppContext 实例。我设法让它工作的唯一方法是首先设置 System.Data.Entity.Database 类的 static DefaultConnectionFactory 属性,然后调用 CreateDatabase() 方法从实体模型生成数据库。

Test Initialize 方法的以下实现可用于设置我们的 MyAppContext 以用于测试。

[TestInitialize]
public void SetupTest()
{
    // file path of the database to create
    var filePath = @"C:\code\TestingEf\TestTemp\RealMyAppDb.sdf";

    // delete it if it already exists
    if (File.Exists(filePath))
        File.Delete(filePath);

    // create the SQL CE connection string - this just points to the file path
    string connectionString = "Datasource = "+filePath;

    // NEED TO SET THIS TO MAKE DATABASE CREATION WORK WITH SQL CE!!!
    Database.DefaultConnectionFactory = 
        new SqlCeConnectionFactory("System.Data.SqlServerCe.4.0");

    using (var context = new MyAppContext(connectionString))
    {
        // this will create the database with the schema from the Entity Model
        context.Database.Create();
    }

    // initialise our DbContext class with the SQL CE connection string, 
    // ready for our tests to use it.
    _context = new MyAppContext(connectionString);
}

两种方法之间的切换

很有可能,你可能希望对某些测试使用 Effort,而对其他测试使用 SQL CE。起初,在两种实现之间切换会导致问题:一旦使用了 SQL CE 方法,在同一测试运行中后续使用 Effort 方法就不再起作用了。

问题

对静态 DefaultConnectionFactory 属性的调用,将其值设置为 SqlCeConnectionFactory,似乎移除了 Effort 提供程序的注册。

解决方案

将以下内容添加到测试项目的 App.config 中。它添加了 Effort Entity Framework Provider 的注册,此外还有 Effort 库在运行时自身添加的注册。nameinvariant 值与 Effort 在运行时自动注册的值不同。为了避免冲突,必须如此。

<system.data>
<DbProviderFactories>
    <add name="Effort Provider Test" 
        description="Effort Provider for unit testing" 
        invariant="EffortProviderTest" 
        type="Effort.Provider.EffortProviderFactory,Effort"/>
</DbProviderFactories>
</system.data>

可下载的示例项目

查看示例项目以获取工作示例。我将两种测试方法包装到两个不同的类中:

  • TestingEf.Data.Tests.TestDatabaseStrategies.EffortDatabaseStrategy
  • TestingEf.Data.Tests.TestDatabaseStrategies.SqlCeDatabaseStrategy

这些类从 TestingEf.Data.Tests.ProductRepositoryTests 命名空间中的测试中引用,这些测试比本文中介绍的更深入地测试了 ProductRepository。在这些测试中,有一个失败的测试强调了 Effort 如何在 Entity Framework 查询中不支持 String.IsNullOrEmpty,而 SQL CE(和 SQL Server)支持。

示例项目还包含一个 IMyAppContext 接口,在整个代码中引用它,而不是直接使用具体的 MyAppContext 实现。使用接口对于解释本文中的测试方法不是必需的,但对于系统的其他组件使用接口来说是一种好习惯,因为这有助于在测试系统不同组件时进行模拟。

此接口是根据 MyAppContext.tt T4 模板自动生成的。这是 EF 5 DbContext 生成器 T4 的修改版本,它从 EDMX 文件生成实体模型的类作为 DbContext 类和 POCO 对象。有关使用 DbContext 生成器的更多信息,请参阅此链接

历史

  • 2012 年 9 月 17 日:初始版本
© . All rights reserved.