【问题标题】:How are people unit testing with Entity Framework 6, should you bother?人们如何使用 Entity Framework 6 进行单元测试,你应该打扰吗?
【发布时间】:2014-05-06 14:51:21
【问题描述】:

我刚开始使用单元测试和 TDD。我以前涉足过,但现在我决心将其添加到我的工作流程中并编写更好的软件。

我昨天问了一个问题,其中包括这个,但它似乎是一个独立的问题。我已经坐下来开始实现一个服务类,我将使用它从控制器中抽象出业务逻辑,并使用 EF6 映射到特定的模型和数据交互。

问题是我已经给自己设置了障碍,因为我不想将 EF 抽象到存储库中(它仍然可以在服务之外用于特定查询等)并且想测试我的服务(EF 上下文将使用)。

我想这是一个问题,这样做有什么意义吗?如果是这样,鉴于 IQueryable 引起的泄漏抽象以及Ladislav Mrnka 的许多关于单元测试主题的精彩帖子并不简单,因为在使用 in 时 Linq 提供程序的差异,人们如何在野外做这件事与特定数据库相对应的内存实现。

我要测试的代码看起来很简单。 (这只是试图理解我在做什么的虚拟代码,我想使用 TDD 来驱动创建)

上下文

public interface IContext
{
    IDbSet<Product> Products { get; set; }
    IDbSet<Category> Categories { get; set; }
    int SaveChanges();
}

public class DataContext : DbContext, IContext
{
    public IDbSet<Product> Products { get; set; }
    public IDbSet<Category> Categories { get; set; }

    public DataContext(string connectionString)
                : base(connectionString)
    {

    }
}

服务

public class ProductService : IProductService
{
    private IContext _context;

    public ProductService(IContext dbContext)
    {
        _context = dbContext;
    }

    public IEnumerable<Product> GetAll()
    {
        var query = from p in _context.Products
                    select p;

        return query;
    }
}

目前我正在做一些事情的心态:

  1. 使用类似这种方法的方式模拟 EF 上下文 - Mocking EF When Unit Testing 或直接在接口上使用模拟框架(如 moq) - 忍受单元测试可能通过但不一定端到端工作并通过集成测试支持它们的痛苦?
  2. 也许使用 Effort 之类的东西来模拟 EF - 我从未使用过它,不确定是否还有其他人在野外使用它?
  3. 不必费心测试任何简单地回调 EF 的东西 - 所以本质上直接调用 EF 的服务方法(getAll 等)没有经过单元测试,而只是经过集成测试?

有没有人在没有回购的情况下真正做到这一点并取得了成功?

【问题讨论】:

  • 嘿 Modika,我最近在考虑这个问题(因为这个问题:stackoverflow.com/questions/25977388/…)在其中我尝试更正式地描述一下我目前的工作方式,但我很乐意听听你是怎么做的。
  • 嗨@samy,我们决定这样做的方式不是对任何直接触及 EF 的东西进行单元测试。查询经过测试,但作为集成测试,而不是单元测试。模拟 EF 感觉有点脏,但是这个项目很小,所以对数据库进行大量测试的性能影响并不是真正值得关注的问题,所以我们可以更加务实一点。我仍然不能 100% 确定最好的方法是对你完全诚实,在某些时候你会遇到 EF(和你的数据库)并且单元测试对我来说并不合适。

标签: c# entity-framework unit-testing entity-framework-6


【解决方案1】:

这是我非常感兴趣的一个话题。有很多纯粹主义者说你不应该测试 EF 和 NHibernate 等技术。他们是对的,他们已经经过了非常严格的测试,正如之前的回答所说,花费大量时间测试你不拥有的东西通常是没有意义的。

但是,您确实拥有下面的数据库!这是我认为这种方法失败的地方,您不需要测试 EF/NH 是否正确地完成了他们的工作。您需要测试您的映射/实现是否与您的数据库一起使用。在我看来,这是您可以测试的系统中最重要的部分之一。

然而,严格来说,我们正在走出单元测试领域并进入集成测试领域,但原则保持不变。

您需要做的第一件事是能够模拟您的 DAL,以便您的 BLL 可以独立于 EF 和 SQL 进行测试。 这些是你的单元测试。接下来你需要设计你的集成测试来证明你的 DAL,在我看来这些都同样重要。

有几点需要考虑:

  1. 每次测试时,您的数据库都需要处于已知状态。大多数系统为此使用备份或创建脚本。
  2. 每个测试都必须是可重复的
  3. 每个测试都必须是原子的

设置数据库有两种主要方法,第一种是运行 UnitTest 创建数据库脚本。这可确保您的单元测试数据库在每次测试开始时始终处于相同状态(您可以重置它或在事务中运行每个测试以确保这一点)。

您的另一个选择是我所做的,为每个单独的测试运行特定的设置。我认为这是最好的方法,主要有两个原因:

  • 您的数据库更简单,每个测试都不需要完整的架构
  • 每个测试都更安全,如果您在创建脚本中更改一个值,它不会使其他几十个测试无效。

不幸的是,您在这里的妥协是速度。运行所有这些测试、运行所有这些设置/拆卸脚本都需要时间。

最后一点,编写如此大量的 SQL 来测试您的 ORM 可能非常困难。这是我采取非常讨厌的方法的地方(这里的纯粹主义者会不同意我的观点)。我使用我的 ORM 来创建我的测试!我没有为系统中的每个 DAL 测试使用单独的脚本,而是有一个测试设置阶段,它创建对象、将它们附加到上下文并保存它们。然后我运行我的测试。

这远非理想的解决方案,但在实践中我发现它更容易管理(尤其是当您有数千个测试时),否则您会创建大量脚本。实用胜于纯洁。

毫无疑问,我会在几年(几个月/几天)后回顾这个答案,并且随着我的方法发生变化,我不同意自己的看法 - 但这是我目前的方法。

为了尝试总结我上面所说的一切,这是我的典型数据库集成测试:

[Test]
public void LoadUser()
{
  this.RunTest(session => // the NH/EF session to attach the objects to
  {
    var user = new UserAccount("Mr", "Joe", "Bloggs");
    session.Save(user);
    return user.UserID;
  }, id => // the ID of the entity we need to load
  {
     var user = LoadMyUser(id); // load the entity
     Assert.AreEqual("Mr", user.Title); // test your properties
     Assert.AreEqual("Joe", user.Firstname);
     Assert.AreEqual("Bloggs", user.Lastname);
  }
}

这里要注意的关键是两个循环的会话是完全独立的。在您的 RunTest 实现中,您必须确保上下文已提交和销毁,并且您的数据只能来自您的第二部分的数据库。

2014 年 13 月 10 日编辑

我确实说过我可能会在接下来的几个月中修改这个模型。虽然我在很大程度上支持我上面提倡的方法,但我稍微更新了我的测试机制。我现在倾向于在 TestSetup 和 TestTearDown 中创建实体。

[SetUp]
public void Setup()
{
  this.SetupTest(session => // the NH/EF session to attach the objects to
  {
    var user = new UserAccount("Mr", "Joe", "Bloggs");
    session.Save(user);
    this.UserID =  user.UserID;
  });
}

[TearDown]
public void TearDown()
{
   this.TearDownDatabase();
}

然后分别测试每个属性

[Test]
public void TestTitle()
{
     var user = LoadMyUser(this.UserID); // load the entity
     Assert.AreEqual("Mr", user.Title);
}

[Test]
public void TestFirstname()
{
     var user = LoadMyUser(this.UserID);
     Assert.AreEqual("Joe", user.Firstname);
}

[Test]
public void TestLastname()
{
     var user = LoadMyUser(this.UserID);
     Assert.AreEqual("Bloggs", user.Lastname);
}

这种方法有几个原因:

  • 没有额外的数据库调用(一次设置,一次拆卸)
  • 测试更加精细,每个测试验证一个属性
  • Setup/TearDown 逻辑已从测试方法本身中移除

我觉得这让测试类更简单,测试更细化 (single asserts are good)

2015 年 5 月 3 日编辑

此方法的另一个修订版。虽然类级别设置对于加载属性等测试非常有帮助,但它们在需要不同设置的情况下用处不大。在这种情况下,为每个案例设置一个新类是多余的。

为了解决这个问题,我现在倾向于有两个基类SetupPerTestSingleSetup。这两个类根据需要公开框架。

SingleSetup 中,我们有一个与我第一次编辑中描述的非常相似的机制。一个例子是

public TestProperties : SingleSetup
{
  public int UserID {get;set;}

  public override DoSetup(ISession session)
  {
    var user = new User("Joe", "Bloggs");
    session.Save(user);
    this.UserID = user.UserID;
  }

  [Test]
  public void TestLastname()
  {
     var user = LoadMyUser(this.UserID); // load the entity
     Assert.AreEqual("Bloggs", user.Lastname);
  }

  [Test]
  public void TestFirstname()
  {
       var user = LoadMyUser(this.UserID);
       Assert.AreEqual("Joe", user.Firstname);
  }
}

但是,确保只加载正确实体的引用可以使用 SetupPerTest 方法

public TestProperties : SetupPerTest
{
   [Test]
   public void EnsureCorrectReferenceIsLoaded()
   {
      int friendID = 0;
      this.RunTest(session =>
      {
         var user = CreateUserWithFriend();
         session.Save(user);
         friendID = user.Friends.Single().FriendID;
      } () =>
      {
         var user = GetUser();
         Assert.AreEqual(friendID, user.Friends.Single().FriendID);
      });
   }
   [Test]
   public void EnsureOnlyCorrectFriendsAreLoaded()
   {
      int userID = 0;
      this.RunTest(session =>
      {
         var user = CreateUserWithFriends(2);
         var user2 = CreateUserWithFriends(5);
         session.Save(user);
         session.Save(user2);
         userID = user.UserID;
      } () =>
      {
         var user = GetUser(userID);
         Assert.AreEqual(2, user.Friends.Count());
      });
   }
}

总而言之,这两种方法都取决于您要测试的内容。

【讨论】:

  • Here's 一种不同的集成测试方法。 TL;DR - 使用应用程序本身设置测试数据,每次测试回滚事务。
  • @Liath,很好的回应。你已经证实了我对测试 EF 的怀疑。我的问题是这个;你的例子是一个非常具体的案例,这很好。但是,正如您所指出的,您可能需要测试数百个实体。根据 DRY 原则(不要重复自己),如何扩展您的解决方案,而不是每次都重复相同的基本代码模式?
  • 我不同意这一点,因为它完全回避了这个问题。单元测试是关于测试函数的逻辑。在 OP 示例中,逻辑依赖于数据存储。当您说不测试 EF 时,您是对的,但这不是问题。问题是在与数据存储区隔离的情况下测试您的代码。测试你的映射是一个完全不同的话题。为了测试逻辑是否与数据正确交互,您需要能够控制存储。
  • 对于是否应该单独对实体框架进行单元测试,没有人持谨慎态度。发生的情况是您需要测试一些方法来做一些事情,并且碰巧对数据库进行 EF 调用。目标是模拟 EF,以便您可以在构建服务器上不需要数据库的情况下测试此方法。
  • 我真的很喜欢这段旅程。感谢您随着时间的推移添加编辑 - 这就像阅读源代码管理并了解您的想法是如何演变的。我也非常欣赏功能(使用 EF)和单元(模拟 EF)的区别。
【解决方案2】:

这里的努力体验反馈

经过大量阅读后,我在测试中一直使用Effort:在测试期间,上下文由工厂构建,该工厂返回内存版本,这让我每次都可以针对空白进行测试。在测试之外,工厂被解析为返回整个上下文的工厂。

但是我有一种感觉,针对数据库的全功能模拟进行测试往往会拖累测试;您意识到您必须注意设置一大堆依赖项才能测试系统的一部分。您还倾向于将可能不相关的测试组织在一起,因为只有一个巨大的对象可以处理所有事情。一不留神,你可能会发现自己在做集成测试而不是单元测试

我更喜欢针对更抽象的东西而不是庞大的 DBContext 进行测试,但我找不到有意义的测试和准系统测试之间的最佳平衡点。归咎于我缺乏经验。

所以我觉得努力很有趣;如果您需要开始行动,它是快速入门并获得结果的好工具。但是我认为下一步应该是更优雅和抽象的东西,这就是我接下来要研究的内容。收藏这篇文章,看看接下来会发生什么:)

编辑添加:努力确实需要一些时间来热身,所以你正在看大约。测试启动 5 秒。如果您需要非常高效的测试套件,这对您来说可能是个问题。


为澄清而编辑:

我使用 Effort 测试了一个 Web 服务应用程序。进入的每条消息 M 通过 Windsor 路由到 IHandlerOf&lt;M&gt;。 Castle.Windsor 解析了 IHandlerOf&lt;M&gt;,它解析了组件的依赖关系。其中一个依赖项是DataContextFactory,它让处理程序请求工厂

在我的测试中,我直接实例化 IHandlerOf 组件,模拟 SUT 的所有子组件并将 Effort-wrapped DataContextFactory 处理给处理程序。

这意味着我没有严格意义上的单元测试,因为我的测试会命中数据库。然而,正如我上面所说,它让我开始运行,我可以快速测试应用程序中的一些点

【讨论】:

  • 感谢您的意见,我可以做的,因为我必须让这个项目运行,因为它是一个真正的有偿工作,从一些 repos 开始,看看我是怎么做的,但是 Effort 非常有趣。出于对您在应用程序中的哪一层投入的兴趣?
  • 仅当 Effort 正确支持事务时
  • 当我们在字符串中使用 '' 而不是 null 时,努力使用 csv 加载器对字符串有一个错误。
【解决方案3】:

如果您想要单元测试代码,那么您需要将您想要测试的代码(在这种情况下是您的服务)与外部资源(例如数据库)隔离开来。您可能可以使用某种in-memory EF provider 来执行此操作,但是更常见的方法是抽象出您的 EF 实现,例如具有某种存储库模式。如果没有这种隔离,您编写的任何测试都将是集成测试,而不是单元测试。

至于测试 EF 代码 - 我为我的存储库编写自动化集成测试,在初始化期间将各种行写入数据库,然后调用我的存储库实现以确保它们按预期运行(例如,确保过滤结果正确,或者它们按正确的顺序排序)。

这些是集成测试而不是单元测试,因为测试依赖于存在数据库连接,并且目标数据库已经安装了最新的最新架构。

【讨论】:

  • 谢谢@justin 我知道 Repository 模式,但是阅读诸如 ayende.com/blog/4784/…lostechies.com/jimmybogard/2009/09/11/wither-the-repository 之类的东西让我觉得我不想要这个抽象层,但又是这些话题更多关于 Query 方法的信息也会变得非常混乱。
  • @Modika Ayende 选择了一个糟糕的存储库模式实现来进行批评,因此 100% 正确 - 它过度设计并且没有提供任何好处。一个好的实现将代码的可单元测试部分与 DAL 实现隔离开来。直接使用 NHibernate 和 EF 会使代码难以(如果不是不可能的话)进行单元测试,并导致僵化的单体代码库。我仍然对存储库模式持怀疑态度,但是我 100% 确信您需要以某种方式隔离 DAL 实现,并且存储库是我迄今为止发现的最好的东西。
  • @Modika 再次阅读第二篇文章。 “我不想要这个抽象层”不是他所说的。另外,阅读 Fowler (martinfowler.com/eaaCatalog/repository.html) 或 DDD (dddcommunity.org/resources/ddd_terms) 的原始存储库模式。在没有完全理解原始概念的情况下,不要相信反对者。他们真正批评的是最近对模式的滥用,而不是模式本身(尽管他们可能不知道这一点)。
  • @guillaume31 我不反对存储库模式(我确实理解)我只是想弄清楚是否需要它来抽象该级别已经是抽象的内容,以及是否可以省略它并通过模拟直接针对 EF 进行测试,并在我的应用程序更高层的测试中使用它。此外,如果我不使用 repo,我将获得 EF 扩展功能集的好处,使用 repo 我可能无法获得。
  • 一旦我将 DAL 与存储库隔离开来,我需要以某种方式“模拟”数据库 (EF)。到目前为止,模拟上下文和各种异步扩展(ToListAsync()、FirstOrDefaultAsync() 等)让我感到沮丧。
【解决方案4】:

我曾经摸索过一段时间以达到这些考虑:

1- 如果我的应用程序访问数据库,为什么测试不应该?如果数据访问出现问题怎么办?测试必须事先知道它并提醒自己注意问题。

2- 存储库模式有些困难且耗时。

所以我想出了这个方法,我认为它不是最好的,但满足了我的期望:

Use TransactionScope in the tests methods to avoid changes in the database.

这样做是必要的:

1- 将 EntityFramework 安装到测试项目中。 2-将连接字符串放入测试项目的app.config文件中。 3- 在测试项目中引用 dll System.Transactions。

独特的副作用是身份种子在尝试插入时会增加,即使在事务中止时也是如此。但是由于测试是针对开发数据库进行的,所以这应该没有问题。

示例代码:

[TestClass]
public class NameValueTest
{
    [TestMethod]
    public void Edit()
    {
        NameValueController controller = new NameValueController();

        using(var ts = new TransactionScope()) {
            Assert.IsNotNull(controller.Edit(new Models.NameValue()
            {
                NameValueId = 1,
                name1 = "1",
                name2 = "2",
                name3 = "3",
                name4 = "4"
            }));

            //no complete, automatically abort
            //ts.Complete();
        }
    }

    [TestMethod]
    public void Create()
    {
        NameValueController controller = new NameValueController();

        using (var ts = new TransactionScope())
        {
            Assert.IsNotNull(controller.Create(new Models.NameValue()
            {
                name1 = "1",
                name2 = "2",
                name3 = "3",
                name4 = "4"
            }));

            //no complete, automatically abort
            //ts.Complete();
        }
    }
}

【讨论】:

  • 其实我很喜欢这个解决方案。超级简单的实现和更真实的测试场景。谢谢!
  • 对于 EF 6,您会使用 DbContext.Database.BeginTransaction,不是吗?
  • 聪明的解决方案
【解决方案5】:

我不会对我不拥有的代码进行单元测试。你在这里测试什么,MSFT 编译器可以工作?

也就是说,要使此代码可测试,您几乎必须将数据访问层与业务逻辑代码分开。我所做的是将我所有的 EF 东西放入一个(或多个)DAO 或 DAL 类中,该类也具有相应的接口。然后我编写我的服务,该服务将注入 DAO 或 DAL 对象作为作为接口引用的依赖项(最好是构造函数注入)。现在,需要测试的部分(您的代码)可以通过模拟 DAO 接口并将其注入到您的单元测试中的服务实例中轻松地进行测试。

//this is testable just inject a mock of IProductDAO during unit testing
public class ProductService : IProductService
{
    private IProductDAO _productDAO;

    public ProductService(IProductDAO productDAO)
    {
        _productDAO = productDAO;
    }

    public List<Product> GetAllProducts()
    {
        return _productDAO.GetAll();
    }

    ...
}

我认为实时数据访问层是集成测试的一部分,而不是单元测试。我曾经见过有人验证过 hibernate 数据库访问了多少次,但他们的项目涉及数据存储中的数十亿条记录,而这些额外的访问确实很重要。

【讨论】:

  • 感谢您的回答,但是说一个存储库,您在这个级别隐藏了 EF 的内部结构,这有什么区别?我真的不想抽象 EF,尽管我可能仍在使用 IContext 接口来做这件事?我是新手,要温柔:)
  • @Modika A Repo 也很好。你想要什么图案。 “我真的不想抽象 EF”你想要可测试的代码吗?
  • @Modika 我的观点是,如果您不分离您的关注点,您将没有任何可测试的代码。数据访问和业务逻辑必须位于单独的层中,以实现良好的可维护测试。
  • 我只是觉得没有必要将 EF 包装在存储库抽象中,因为本质上 IDbSet 是 repo,而上下文是 UOW,我会稍微更新一下我的问题,因为这可能会产生误导。问题来自任何抽象,重点是我到底在测试什么,因为我的查询不会在相同的边界(linq-to-entities 与 linq-to-objects)中运行,所以如果我只是测试我的服务是否会产生打电话这似乎有点浪费,或者我在这里过得好吗?
  • ,虽然我同意您的一般观点,但 DbContext 是一个工作单元,而 IDbSet 绝对是存储库实现的一部分,而且我不是唯一一个这样认为的人。我可以模拟 EF,并且在某些层我需要运行集成测试,如果我在存储库中执行它,或者在服务中更进一步,这真的很重要吗?与数据库紧密耦合并不是一个真正的问题,我确信它会发生,但我不会为可能不会发生的事情做计划。
【解决方案6】:

所以事情是这样的,Entity Framework 是一种实现,所以尽管它抽象了数据库交互的复杂性,但直接交互仍然是紧密耦合的,这就是为什么它难以测试的原因。

单元测试是关于独立于任何外部依赖项(在本例中为数据存储)来测试函数的逻辑及其每个潜在结果。为此,您需要能够控制数据存储的行为。例如,如果您想断言您的函数在获取的用户不满足某些条件时返回 false,那么您的 [mocked] 数据存储应配置为始终返回不满足条件的用户,反之亦然反之亦然。

话虽如此,并接受 EF 是一种实现这一事实,我可能会赞成抽象存储库的想法。似乎有点多余?不是,因为您正在解决一个将代码与数据实现隔离的问题。

在 DDD 中,存储库只返回聚合根,而不是 DAO。这样,存储库的使用者永远不必知道数据实现(因为它不应该),我们可以将其用作如何解决此问题的示例。在这种情况下,EF 生成的对象是一个 DAO,因此应该对您的应用程序隐藏。这是您定义的存储库的另一个好处。您可以将业务对象定义为其返回类型,而不是 EF 对象。现在 repo 所做的是隐藏对 EF 的调用并将 EF 响应映射到 repos 签名中定义的业务对象。现在,您可以使用该 repo 代替注入到类中的 DbContext 依赖项,因此,现在您可以模拟该接口,为您提供所需的控制,以便单独测试您的代码。

这需要更多的工作,许多人对此嗤之以鼻,但它解决了一个真正的问题。在另一个答案中提到了一个内存提供程序,这可能是一种选择(我没有尝试过),它的存在就是实践需要的证据。

我完全不同意最佳答案,因为它回避了隔离代码的真正问题,然后与测试映射相切。如果您愿意,请务必测试您的映射,但请在此处解决实际问题并获得一些真正的代码覆盖率。

【讨论】:

    【解决方案7】:

    简而言之,我会说不,榨汁不值得用单行检索模型数据来测试服务方法。以我的经验,刚接触 TDD 的人绝对希望测试所有内容。在我看来,将外观抽象为 3rd 方框架只是为了创建该框架 API 的模拟,然后您可以使用该 API 进行混蛋/扩展以便注入虚拟数据的旧栗子在我看来没有什么价值。每个人对单元测试的最佳程度都有不同的看法。这些天,我倾向于更加务实,并问自己我的测试是否真的为最终产品增加了价值,以及付出了多少代价。

    【讨论】:

    • 赞成实用主义。我仍然认为你的单元测试的质量不如你的原始代码的质量。当然,使用 TDD 来改进您的编码实践以及增强可维护性是有价值的,但 TDD 的价值可能会降低。我们对数据库运行所有测试,因为它让我们确信我们对 EF 和表本身的使用是正确的。测试确实需要更长的时间才能运行,但它们更可靠。
    【解决方案8】:

    我想分享一个经过评论和简要讨论的方法,但展示一个我目前用来帮助单元测试基于 EF 的服务的实际示例。

    首先,我很想使用 EF Core 的内存提供程序,但这是关于 EF 6。此外,对于 RavenDB 等其他存储系统,我也支持通过内存数据库进行测试提供者。再说一遍 - 这是专门用于帮助测试基于 EF 的代码无需太多仪式

    以下是我提出模式时的目标:

    • 团队中的其他开发人员必须易于理解
    • 它必须尽可能地隔离 EF 代码
    • 不得涉及创建奇怪的多职责接口(例如“通用”或“典型”存储库模式)
    • 在单元测试中必须易于配置和设置

    我同意之前的说法,即 EF 仍然是一个实现细节,并且感觉你需要抽象它以进行“纯”单元测试是可以的。我也同意,理想情况下,我希望确保 EF 代码本身可以工作——但这​​涉及到沙箱数据库、内存提供程序等。我的方法解决了这两个问题——您可以安全地对 EF 相关代码进行单元测试 创建集成测试来专门测试您的 EF 代码。

    我实现这一点的方法是通过简单地将 EF 代码封装到专用的查询和命令类中。这个想法很简单:只需将任何 EF 代码包装在一个类中,并依赖于最初会使用它的类中的接口。我需要解决的主要问题是避免向类添加大量依赖项并在测试中设置大量代码。

    这是一个有用、简单的库的用武之地:Mediatr。它允许简单的进程内消息传递,它通过将“请求”与实现代码的处理程序分离来实现。这具有将“什么”与“如何”分离的额外好处。例如,通过将 EF 代码封装成小块,它允许您将实现替换为另一个提供程序或完全不同的机制,因为您所做的只是发送执行操作的请求。

    利用依赖注入(有或没有框架——您的偏好),我们可以轻松地模拟中介并控制请求/响应机制以启用单元测试 EF 代码。

    首先,假设我们有一个需要测试业务逻辑的服务:

    public class FeatureService {
    
      private readonly IMediator _mediator;
    
      public FeatureService(IMediator mediator) {
        _mediator = mediator;
      }
    
      public async Task ComplexBusinessLogic() {
        // retrieve relevant objects
    
        var results = await _mediator.Send(new GetRelevantDbObjectsQuery());
        // normally, this would have looked like...
        // var results = _myDbContext.DbObjects.Where(x => foo).ToList();
    
        // perform business logic
        // ...    
      }
    }
    

    您开始看到这种方法的好处了吗?您不仅明确将所有与 EF 相关的代码封装到描述性类中,还通过消除“如何”处理此请求的实现问题来允许可扩展性——此类不关心是否相关对象来自 EF、MongoDB 或文本文件。

    现在通过 MediatR 处理请求和处理程序:

    public class GetRelevantDbObjectsQuery : IRequest<DbObject[]> {
      // no input needed for this particular request,
      // but you would simply add plain properties here if needed
    }
    
    public class GetRelevantDbObjectsEFQueryHandler : IRequestHandler<GetRelevantDbObjectsQuery, DbObject[]> {
      private readonly IDbContext _db;
    
      public GetRelevantDbObjectsEFQueryHandler(IDbContext db) {
        _db = db;
      }
    
      public DbObject[] Handle(GetRelevantDbObjectsQuery message) {
        return _db.DbObjects.Where(foo => bar).ToList();
      }
    }
    

    如您所见,抽象很简单且被封装。它也是绝对可测试的,因为在集成测试中,您可以单独测试这个类——这里没有业务问题。

    那么我们的功能服务的单元测试是什么样的呢?这很简单。在这种情况下,我使用Moq 进行模拟(使用任何让你开心的东西):

    [TestClass]
    public class FeatureServiceTests {
    
      // mock of Mediator to handle request/responses
      private Mock<IMediator> _mediator;
    
      // subject under test
      private FeatureService _sut;
    
      [TestInitialize]
      public void Setup() {
    
        // set up Mediator mock
        _mediator = new Mock<IMediator>(MockBehavior.Strict);
    
        // inject mock as dependency
        _sut = new FeatureService(_mediator.Object);
      }
    
      [TestCleanup]
      public void Teardown() {
    
        // ensure we have called or expected all calls to Mediator
        _mediator.VerifyAll();
      }
    
      [TestMethod]
      public void ComplexBusinessLogic_Does_What_I_Expect() {
        var dbObjects = new List<DbObject>() {
          // set up any test objects
          new DbObject() { }
        };
    
        // arrange
    
        // setup Mediator to return our fake objects when it receives a message to perform our query
        // in practice, I find it better to create an extension method that encapsulates this setup here
        _mediator.Setup(x => x.Send(It.IsAny<GetRelevantDbObjectsQuery>(), default(CancellationToken)).ReturnsAsync(dbObjects.ToArray()).Callback(
        (GetRelevantDbObjectsQuery message, CancellationToken token) => {
           // using Moq Callback functionality, you can make assertions
           // on expected request being passed in
           Assert.IsNotNull(message);
        });
    
        // act
        _sut.ComplexBusinessLogic();
    
        // assertions
      }
    
    }
    

    您可以看到,我们只需要一个设置,甚至不需要配置任何额外的东西——这是一个非常简单的单元测试。 让我们明确一点:没有类似 Mediatr 的东西是完全可以做到的(您只需实现一个接口并模拟它以进行测试,例如 IGetRelevantDbObjectsQuery),但实际上对于具有许多功能和查询/命令的大型代码库,我喜欢 Mediatr 提供的封装和固有的 DI 支持。

    如果您想知道我是如何组织这些课程的,这很简单:

    - MyProject
      - Features
        - MyFeature
          - Queries
          - Commands
          - Services
          - DependencyConfig.cs (Ninject feature modules)
    

    Organizing by feature slices 是题外话,但这将所有相关/依赖的代码放在一起并且很容易被发现。最重要的是,我将查询与命令分开——遵循Command/Query Separation 原则。

    这符合我的所有标准:仪式性低,易于理解,而且还有额外的隐藏好处。例如,您如何处理保存更改?现在,您可以通过使用角色接口 (IUnitOfWork.SaveChangesAsync()) 并模拟对单个角色接口的调用来简化您的 Db 上下文,或者您可以将提交/回滚封装在您的 RequestHandlers 中——无论您喜欢做什么,都取决于您,因为只要它是可维护的。例如,我很想创建一个通用请求/处理程序,您只需在其中传递一个 EF 对象,它会保存/更新/删除它——但您必须询问您的意图是什么,并记住,如果您想要将处理程序换成另一个存储提供程序/实现,您可能应该创建表示您打算做什么的显式命令/查询。通常,单个服务或功能需要特定的东西——在你需要它之​​前不要创建通用的东西。

    当然对这种模式有一些警告——你可以用一个简单的发布/订阅机制走得太远。我已将我的实现限制为仅抽象与 EF 相关的代码,但有冒险精神的开发人员可以开始使用 MediatR 来过度使用并将所有内容消息化——良好的代码审查实践和同行审查应该抓住这一点。这是一个流程问题,而不是 MediatR 的问题,因此请注意您是如何使用此模式的。

    您想要一个具体的例子来说明人们如何对 EF 进行单元测试/模拟,这是一种在我们的项目中成功地为我们工作的方法 - 并且团队对它的易于采用感到非常满意。我希望这有帮助!与编程中的所有事情一样,有多种方法,这完全取决于您想要实现的目标。我看重简单性、易用性、可维护性和可发现性 - 此解决方案满足所有这些要求。

    【讨论】:

    • 感谢您的回答,它很好地描述了使用调解器的 QueryObject 模式,并且我也开始在我的项目中推出一些东西。我可能需要更新问题,但我不再对 EF 进行单元测试,抽象太泄漏(虽然 SqlLite 可能没问题)所以我只是集成测试查询数据库和单元测试业务规则和其他逻辑的东西。跨度>
    【解决方案9】:

    Effort 是一个内存实体框架数据库提供程序。我实际上并没有尝试过......哈只是发现问题中提到了这一点!

    或者,您可以切换到内置内存数据库提供程序的 EntityFrameworkCore。

    https://blog.goyello.com/2016/07/14/save-time-mocking-use-your-real-entity-framework-dbcontext-in-unit-tests/

    https://github.com/tamasflamich/effort

    我使用工厂来获取上下文,因此我可以创建接近其用途的上下文。这似乎可以在 Visual Studio 本地工作,但不能在我的 TeamCity 构建服务器上工作,尚不知道为什么。

    return new MyContext(@"Server=(localdb)\mssqllocaldb;Database=EFProviders.InMemory;Trusted_Connection=True;");
    

    【讨论】:

    • 嗨,安德鲁,问题从来没有得到上下文,你可以工厂化我们正在做的上下文,抽象上下文并由工厂构建。最大的问题是内存中的内容与 Linq4Entities 所做的内容的一致性,它们并不相同,这可能导致误导性测试。目前,我们只是集成测试数据库的东西,可能不是每个人都介意的最佳过程。
    • 如果您有要模拟的上下文,这个 Moq 助手可以工作 (codeproject.com/Tips/1045590/…)。如果您使用列表支持模拟上下文,它的行为可能不会像由 sql 数据库支持的上下文。
    【解决方案10】:

    我喜欢将我的过滤器与代码的其他部分分开,并按照我在博客 http://coding.grax.com/2013/08/testing-custom-linq-filter-operators.html987654321@ 中的概述进行测试

    话虽如此,由于 LINQ 表达式和底层查询语言(例如 T-SQL)之间的转换,正在测试的过滤器逻辑与程序运行时执行的过滤器逻辑不同。尽管如此,这仍然允许我验证过滤器的逻辑。在我测试层之间的集成之前,我不会太担心发生的翻译以及诸如区分大小写和空值处理之类的事情。

    【讨论】:

      【解决方案11】:

      为了单元测试依赖于您的数据库的代码,您需要为每个测试设置一个数据库或模拟。

      1. 拥有一个用于所有测试的单一状态的数据库(真实的或模拟的)会让你很快陷入困境;您无法测试所有记录是否有效,并且有些记录不是来自相同的数据。
      2. 在 OneTimeSetup 中设置内存数据库会出现问题,即在下一次测试启动之前未清除旧数据库。当您单独运行它们时,这将显示为测试有效,但当您全部运行它们时会失败。
      3. 理想情况下,单元测试应该只设置影响测试的内容

      我在一个应用程序中工作,该应用程序有很多表,有很多连接和一些大量的 Linq 块。这些需要测试。一个简单的分组遗漏,或导致多于 1 行的连接都会影响结果。

      为了解决这个问题,我设置了一个繁重的单元测试助手,它需要大量的设置工作,但使我们能够可靠地模拟任何状态下的数据库,并针对 55 个相互连接的表运行 48 个测试,以及整个数据库设置48 次需要 4.7 秒。

      方法如下:

      1. 在 Db 上下文类中确保每个表类都设置为虚拟

        public virtual DbSet<Branch> Branches { get; set; }
        public virtual DbSet<Warehouse> Warehouses { get; set; }
        
      2. 在 UnitTestHelper 类中创建一个方法来设置您的数据库。每个表类都是一个可选参数。如果未提供,它将通过 Make 方法创建

        internal static Db Bootstrap(bool onlyMockPassedTables = false, List<Branch> branches = null, List<Products> products = null, List<Warehouses> warehouses = null)
        {
            if (onlyMockPassedTables == false) {
                branches ??= new List<Branch> { MakeBranch() };
                warehouses ??= new List<Warehouse>{ MakeWarehouse() };
            }
        
      3. 对于每个表类,其中的每个对象都映射到其他列表

            branches?.ForEach(b => {
                b.Warehouse = warehouses.FirstOrDefault(w => w.ID == b.WarehouseID);
            });
        
            warehouses?.ForEach(w => {
                w.Branches = branches.Where(b => b.WarehouseID == w.ID);
            });
        
      4. 并将其添加到 DbContext

             var context = new Db(new DbContextOptionsBuilder<Db>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
             context.Branches.AddRange(branches);
             context.Warehouses.AddRange(warehouses);
             context.SaveChanges();
             return context;
         }
        
      5. 定义一个 ID 列表,以便更容易重复使用它们并确保连接有效

         internal const int BranchID = 1;
         internal const int WarehouseID = 2;
        
      6. 为每个表创建一个 Make 以设置最基本但可以连接的版本

         internal static Branch MakeBranch(int id = BranchID, string code = "The branch", int warehouseId = WarehouseID) => new Branch { ID = id, Code = code, WarehouseID = warehouseId };
         internal static Warehouse MakeWarehouse(int id = WarehouseID, string code = "B", string name = "My Big Warehouse") => new Warehouse { ID = id, Code = code, Name = name };
        

      这是很多工作,但只需要做一次,然后您的测试就可以非常集中,因为数据库的其余部分将为它设置。

      [Test]
      [TestCase(new string [] {"ABC", "DEF"}, "ABC", ExpectedResult = 1)]
      [TestCase(new string [] {"ABC", "BCD"}, "BC", ExpectedResult = 2)]
      [TestCase(new string [] {"ABC"}, "EF", ExpectedResult = 0)]
      [TestCase(new string[] { "ABC", "DEF" }, "abc", ExpectedResult = 1)]
      public int Given_SearchingForBranchByName_Then_ReturnCount(string[] codesInDatabase, string searchString)
      {
          // Arrange
          var branches = codesInDatabase.Select(x => UnitTestHelpers.MakeBranch(code: $"qqqq{x}qqq")).ToList();
          var db = UnitTestHelpers.Bootstrap(branches: branches);
          var service = new BranchService(db);
      
          // Act
          var result = service.SearchByName(searchString);
      
          // Assert
          return result.Count();
      }
      

      【讨论】:

        【解决方案12】:

        测试您期望实体框架做什么(即验证您的期望)非常重要。我已成功使用的一种方法是使用 moq,如本例所示(复制到此答案中需要很长时间):

        https://docs.microsoft.com/en-us/ef/ef6/fundamentals/testing/mocking

        但是要小心...除非您在 linq 查询中有适当的“OrderBy”,否则不能保证 SQL 上下文以特定顺序返回内容,因此当您使用 in-内存列表 (linq-to-entities) 但在使用 (linq-to-sql) 时在您的 uat / live 环境中失败。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2018-01-15
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2020-05-30
          • 1970-01-01
          • 2021-09-22
          相关资源
          最近更新 更多