人们如何使用Entity Framework 6进行单元测试,是否值得尝试?

186
我刚开始接触单元测试和TDD。以前我尝试过,但现在我决定将其添加到我的工作流程中,并编写更好的软件。
昨天我问了一个问题,似乎这是一个独立的问题。我坐下来开始实现服务类,将业务逻辑与控制器分离,并使用EF6映射到特定的模型和数据交互。
问题是我已经遇到了障碍,因为我不想在存储库中抽象EF(它仍然可用于特定查询等服务之外),并且想要测试我的服务(将使用EF Context)。
这里也许是问题所在,这样做有意义吗?如果有,人们在野外如何做到这一点,考虑到IQueryable引起的泄漏抽象和Ladislav Mrnka关于单元测试不直观的差异,因为在内存实现和特定数据库中使用Linq提供程序时存在差异。
The code 我想要测试的看起来相当简单。(这只是虚拟代码,以尝试理解我正在做的事情,我想使用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. 使用类似于Mocking EF When Unit Testing这样的方法模拟EF上下文,或直接在接口上使用模拟框架(如moq)- 尽管单元测试可能会通过,但不一定能够端到端工作,需要用集成测试来支持?
  2. 也许可以使用Effort之类的工具来模拟EF - 我从未使用过它,也不确定其他人是否在实际使用中?
  3. 不必测试任何仅仅回调EF的内容 - 因此直接调用EF的服务方法(例如getAll等)不进行单元测试,而只进行集成测试?

有没有人在没有Repo的情况下成功地实现了这一点呢?


嗨,Modika,最近我在思考这个问题(因为这个问题:https://dev59.com/jIPba4cB1Zd3GeqPup7S#25978157)。在这个问题中,我试图更正式地描述我目前的工作方式,但我很想听听你是如何做到的。 - samy
嗨@samy,我们决定的方法是不对直接涉及EF的任何内容进行单元测试。查询被测试,但作为集成测试,而不是单元测试。模拟EF感觉有点不好,但这个项目比较小,所以担心大量测试影响性能并不是一个问题,因此我们可以更加务实。说实话,我仍然不确定最好的方法是什么,在某些时候你会遇到EF(和你的数据库),在这里进行单元测试感觉不太合适。 - Modika
12个回答

213

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

然而,你拥有数据库底层! 这就是在我看来这种方法存在问题的地方,你不需要测试EF/NH是否正常工作,而是需要测试你的映射/实现是否与你的数据库配合工作。在我看来,这是系统中最重要的部分之一。

但严格来说,我们正在从单元测试领域进入集成测试领域,但原则仍然相同。

您需要做的第一件事是能够模拟您的数据访问层(DAL),以便于EF和SQL独立测试。 这些是您的单元测试。接下来,您需要设计您的集成测试来证明您的数据访问层,我认为这些同样重要。

有几点需要考虑:

  1. 您的数据库需要在每个测试中处于已知状态。大多数系统使用备份或创建脚本进行此操作。
  2. 每个测试必须是可重复的
  3. 每个测试必须是原子性的

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

您的另一个选项是我使用的:针对每个单独的测试运行特定设置。我认为这是最好的方法,原因有两个:

  • 您的数据库更简单,您不需要为每个测试创建整个模式
  • 每个测试更安全,如果您更改创建脚本中的一个值,它不会使数十个其他测试失效。

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

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

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

毫无疑问,我几年后(甚至几个月/天)会回顾这个答案并不同意自己的方法,因为我的方法已经改变了 - 但这是我的当前方法。

尝试总结我上面所说的一切,这是我的典型DB集成测试:

[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年10月13日

我说过我可能会在接下来的几个月里修改这个模型。虽然我大体上支持上述方法,但我稍微更新了我的测试机制。我现在倾向于在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);
}

这种方法有几个原因:

  • 没有额外的数据库调用(一个安装,一个拆卸)
  • 测试更加细粒度,每个测试验证一个属性
  • 设置/清除逻辑从测试方法中移除

我认为这使得测试类更简单,测试更加细粒度(单个断言是好的)

编辑 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());
      });
   }
}

总的来说,这两种方法都有效,具体取决于您要测试什么。


2
这是一种不同的集成测试方法。简而言之,使用应用程序本身来设置测试数据,并在每个测试中回滚事务。这里有详细说明。 - Gert Arnold
3
@Liath,非常好的回答。您已经确认了我对测试EF的怀疑。我的问题是这样的:您的示例是针对一个非常具体的情况,这很好。但是,正如您所指出的,您可能需要测试数百个实体。为了遵循DRY原则(不要重复自己),您如何扩展您的解决方案,而不必每次都重复相同的基本代码模式? - Jeffrey A. Gochin
4
我不同意这个观点,因为它完全绕开了问题。单元测试是关于测试函数的逻辑。在OP的例子中,逻辑依赖于数据存储。当你说不要测试EF时是对的,但这不是问题所在。问题在于从数据存储中隔离测试你的代码。测试映射是我个人认为完全不同的主题。为了测试逻辑与数据正确交互,你需要能够控制存储。 - Sinaesthetic
9
关于是否应该单独对实体框架进行单元测试,没有人会犹豫不决。但是有时需要测试一些方法,这些方法需要执行一些操作,并且还需要对数据库进行实体框架调用。为了在构建服务器上进行测试而无需使用数据库,我们的目标是模拟实体框架。 - The Muffin Man
4
我很喜欢这段旅程。感谢你随着时间的推移对其进行了编辑 - 就像阅读源代码一样,理解了你的思维如何演进。我也非常欣赏功能 (使用 EF) 和单元 (模拟 EF) 的区别。 - Tom Leys
显示剩余4条评论

24

在阅读了很多资料之后,我在我的测试中使用了Effort:在测试期间,上下文是由一个工厂生成的,该工厂返回一个内存版本,这使得我每次都可以针对空白状态进行测试。在测试之外,工厂被解析为返回整个上下文的工厂。

然而,我有一种感觉,即针对完整功能的数据库模拟测试会拖慢测试速度;您意识到必须设置大量依赖项才能测试系统的一个部分。您也倾向于将可能不相关的测试组织在一起,只因为有一个处理所有内容的巨大对象。如果您不注意,可能会发现自己在进行集成测试而不是单元测试。

我希望测试某些抽象性更强的东西而不是一个巨大的DBContext,但我找不到有意义的测试和裸奔测试之间的平衡点。这归咎于我的经验不足。

因此,我认为Effort很有趣;如果您需要快速上手并获得结果,它是一个很好的工具。但我认为下一步应该是使用更加优雅和抽象的东西,并且我将在接下来的时间里进行调查。收藏此帖以查看下一步的情况 :)

编辑以添加:Effort需要一些时间来热身,因此您需要大约5秒钟的测试启动时间。如果您需要测试套件非常高效,则可能会造成问题。


澄清编辑:

我使用Effort测试了一个Web服务应用程序。每个进入系统的消息M都会通过Windsor路由到IHandlerOf<M>。Castle.Windsor解析IHandlerOf<M>,然后再解析组件的依赖项之一是DataContextFactory,该处理程序可请求该工厂。

在我的测试中,我直接实例化了 IHandlerOf 组件,并模拟了 SUT 的所有子组件,并将 Effort-wrapped DataContextFactory 传递给处理程序进行处理。
这意味着我并没有严格意义上的单元测试,因为我的测试会访问数据库。然而,正如我上面所说的,这让我快速入手,可以迅速测试应用程序中的一些要点。

感谢您的输入。由于这是一项真正的有偿工作,我必须让这个项目运行起来,所以我可能会从一些存储库开始,看看我能做些什么,但是Effort非常有趣。 顺便问一下,在您的应用程序的哪个层面上使用了Effort? - Modika
2
只要Effort能够正确地支持事务处理就好了。 - Sedat Kapanoglu
在使用 CSV 加载器时,对于字符串,如果我们使用 '' 而不是 null,可能会出现错误。这需要花费时间和精力来解决。 - Sam

13
如果你想对代码进行单元测试,那么你需要将要测试的代码(在本例中是你的服务)与外部资源(如数据库)隔离开来。你可能可以使用某种内存EF提供程序来完成此操作,但更常见的方法是使用某种仓储模式来抽象化EF实现。如果没有这种隔离,任何你编写的测试都将是集成测试而不是单元测试。
至于测试EF代码-我为我的仓库编写自动化集成测试,在它们初始化期间向数据库写入各种行,然后调用我的仓库实现以确保它们的行为符合预期(例如确保结果被正确过滤或按正确顺序排序)。
这些是集成测试而不是单元测试,因为测试依赖于具有数据库连接,并且目标数据库已安装了最新的架构。

1
谢谢 @justin,我知道存储库模式,但是阅读像http://ayende.com/blog/4784/architecting-in-the-pit-of-doom-the-evils-of-the-repository-abstraction-layer和http://lostechies.com/jimmybogard/2009/09/11/wither-the-repository/这样的文章让我认为我不想要这个抽象层,但是这些文章更多地涉及查询方法,让人感到非常困惑。 - Modika
8
@Modika Ayende选择了一个较差的仓储模式实现进行批评,因此他是100%正确的 - 它被过度设计并且没有提供任何好处。一个好的实现将您代码中可单元测试的部分与DAL实现隔离开来。直接使用NHibernate和EF使得代码难以(如果不是不可能)进行单元测试,并导致严格的单一代码库。然而,我仍然对仓储模式持有某些怀疑,但我已经100%确信,您需要以某种方式隔离您的DAL实现,而仓储库是我迄今为止发现的最好的解决方案。 - Justin
2
@Modika 请再次阅读第二篇文章。“我不想要这个抽象层”并不是他说的。此外,请阅读Fowler(http://martinfowler.com/eaaCatalog/repository.html)或DDD(http://dddcommunity.org/resources/ddd_terms/)中有关原始存储库模式的内容。不要在完全理解原始概念之前就相信反对者。他们真正批评的是最近滥用该模式,而不是模式本身(尽管他们可能不知道这一点)。 - guillaume31
2
@guillaume31 我并不反对仓储模式(我理解它的作用),我只是试图找出是否需要将已经抽象化的内容再次抽象化,以及如果我可以忽略它,并通过模拟直接使用 EF 进行测试,更上层地在我的应用程序中使用它。此外,如果我不使用仓储库,我可以获得EF扩展功能集的好处,但使用仓储库可能无法获得这些好处。 - Modika
一旦我使用存储库隔离了数据访问层(DAL),我需要一种方法来“模拟”数据库(EF)。到目前为止,对上下文和各种异步扩展(ToListAsync(),FirstOrDefaultAsync()等)进行模拟已经让我感到沮丧。 - Kevin Burton

10

我曾经摸索了一段时间来得出以下考虑:

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();
        }
    }
}

2
实际上,我非常喜欢这个解决方案。非常简单易行,而且测试场景更加真实。谢谢! - slopapa
1
使用EF 6,您会使用DbContext.Database.BeginTransaction,不是吗? - SwissCoder
1
聪明的解决方案 - XRaycat

9
这样说吧,尽管 Entity Framework 抽象了与数据库交互的复杂性,但实际上,直接交互还是紧密耦合的,这就是为什么它很难测试。
单元测试是指独立于任何外部依赖项(在这种情况下是数据存储)测试函数逻辑及其潜在结果的过程。为了做到这一点,你需要能够控制数据存储的行为。例如,如果你想断言你的函数在获取的用户不符合某些条件时返回 false,则你的 [模拟] 数据存储应配置为始终返回一个不符合条件的用户,反之亦然。
有了这个前提,并承认 EF 是一种实现,我可能更倾向于抽象出一个存储库的想法。听起来有点多余?事实并非如此,因为你正在解决一个问题,即将代码与数据实现隔离开来。
在 DDD 中,存储库只返回聚合根,而不是 DAO。这样,存储库的消费者永远不必知道数据实现(正如它不应该知道),我们可以将其用作解决此问题的示例。在这种情况下,由 EF 生成的对象是 DAO,因此应从应用程序中隐藏。这是存储库定义的另一个好处。你可以将业务对象定义为其返回类型,而不是 EF 对象。现在,存储库会隐藏对 EF 的调用,并将 EF 响应映射到存储库签名中定义的业务对象。现在你可以使用该存储库替换你注入到类中的 DbContext 依赖项,因此,你现在可以模拟该接口,以便在隔离环境中测试代码。
这需要花费更多的工作,但它可以解决一个真正的问题。在另一个答案中提到了一个内存提供程序,这可能是一个选择(我没有尝试过),其存在本身就证明了这种实践的必要性。
我完全不同意顶部答案,因为它回避了真正的问题,即隔离代码,然后对测试映射进行了延伸。如果你想测试你的映射,可以,但请解决这里的实际问题并获得一些真正的代码覆盖率。

9
我不会对我不拥有的代码进行单元测试。你在测试什么,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执行了多少次数据库操作,但他们的数据存储库涉及数十亿条记录,这些额外的操作确实很重要。


1
谢谢你的回答,但是这种方法与使用 Repository 的区别在于,你是否将 EF 的内部隐藏在其后面?我并不想抽象化 EF,虽然我可能仍然会通过 IContext 接口来实现这一点?我对此还很陌生,请温柔点 :) - Modika
3
“@Modika 一个仓库也可以。任何你想要的模式。‘我不想抽象EF’你是想要可测试的代码还是不想要?” - Jonathan Henson
1
@Modika 我的观点是,如果你不分离关注点,你将没有任何可测试的代码。数据访问和业务逻辑必须在不同的层中分开,以实现良好的可维护性测试。 - Jonathan Henson
3
我觉得没有必要使用一个仓储抽象来包装EF,因为IDbSets本身就是仓储,而上下文则是UOW。我会稍微修改一下我的问题,因为那可能会让人误解。任何抽象都存在问题,关键在于我到底在测试什么,因为我的查询不会在相同的边界内运行(linq-to-entities vs linq-to-objects)。所以如果我只是测试我的服务是否进行了调用,那似乎有点浪费,我这么做对吗? - Modika
2
虽然我同意你的一般观点,DbContext是一个工作单元,IDbSets绝对是某种存储库实现,而且我不是唯一一个这样认为的人。我可以模拟EF,在某些层面上我需要运行集成测试,如果我在存储库或更高级别的服务中进行测试,那真的很重要吗?紧密耦合到DB并不是真正的问题,我相信它会发生,但我不会为可能不会发生的事情做计划。 - Modika
显示剩余5条评论

6
简而言之,我会说不需要测一条检索模型数据的服务方法,因为这并不值得花费精力去测试。根据我的经验,刚开始接触 TDD 的人想要测试所有东西。我认为,为了创建一个框架 API 的模拟程序来注入虚假数据,以此对其进行更改/扩展,从而抽象出一个 3rd party 框架的外观是没有多大价值的老生常谈。每个人都有自己关于最佳单元测试数量的不同看法。现在我更加务实,并且会问自己测试是否真正增加了最终产品的价值,以及代价是什么。

1
是的,务实主义。我仍然认为你的单元测试质量不如你的原始代码质量。当然,使用TDD来改善编码实践并提高可维护性是有价值的,但是TDD可能会产生递减的价值。我们针对数据库运行所有测试,因为这可以让我们确信我们对EF和表本身的使用是正确的。测试确实需要更长时间才能运行,但它们更加可靠。 - Savage

5
我想分享一种方法,它被评论和简要讨论过,但展示了一个我目前正在使用的实际例子来帮助单元测试基于EF的服务。
首先,我很想使用EF Core的内存提供程序,但这是关于EF 6的。此外,对于其他存储系统(如RavenDB),我也支持通过内存数据库提供程序进行测试。再次强调 - 这是专门为了帮助测试基于EF的代码而不需要太多的仪式感
以下是我制定模式时的目标:
  • 它必须简单易懂,方便团队中的其他开发人员理解
  • 它必须将EF代码隔离到最基本的级别
  • 它不得涉及创建奇怪的多责任接口(例如“通用”或“典型”存储库模式)
  • 它必须易于在单元测试中进行配置和设置
我同意之前的观点,即EF仍然是实现细节,如果感觉需要将其抽象化以进行“纯”单元测试,那么这是可以接受的。 我也同意理想情况下,我希望确保EF代码本身可以正常工作-但这涉及沙箱数据库、内存提供程序等。 我的方法解决了两个问题-您可以安全地对依赖于EF的代码进行单元测试,并创建集成测试来专门测试您的EF代码。
我通过将EF代码简单地封装到专用的查询和命令类中来实现这一点。 这个想法很简单:只需在类中包装任何EF代码,并在最初使用它的类中依赖于一个接口。 我需要解决的主要问题是避免向类添加大量依赖项并在我的测试中设置大量代码。
这就是有用的、简单的库Mediatr发挥作用的地方。它允许简单的进程内消息传递,并通过将"请求"与实现代码的处理程序解耦来实现。这样做的另一个好处是将"what"与"how"解耦。例如,通过将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)

按特性分组不是重点,但这样可以将所有相关/依赖的代码放在一起并易于发现。最重要的是,我遵循命令/查询分离原则,将查询与命令分开。

这符合我所有的标准:它很简单,易于理解,并且有额外的隐藏好处。例如,如何处理保存更改?现在,您可以通过使用角色接口 (IUnitOfWork.SaveChangesAsync()) 简化您的 Db Context,并模拟对单个角色接口的调用,或者您可以将提交/回滚封装在 RequestHandlers 中--无论您喜欢哪种方式,只要它可维护即可。例如,我曾经想过创建一个通用的请求/处理程序,在其中只需传递一个 EF 对象,它就会保存/更新/删除它--但是您必须问自己的意图,并记住如果您想将处理程序与另一个存储提供程序/实现交换,您可能应该创建明确表示您打算执行的命令/查询。往往,一个服务或功能需要特定的东西--在需要之前不要创建通用的东西。

当然,这种模式也有一些注意事项——简单的发布/订阅机制可能会走得太远。我将我的实现限制在仅抽象EF相关代码方面,但是冒险的开发人员可以开始使用MediatR来过度使用消息化——良好的代码审查实践和同行审查应该能够发现这一点。这是一个流程问题,而不是MediatR的问题,所以要注意如何使用此模式。

你想要一个具体的例子,说明人们如何对EF进行单元测试/模拟,这是我们项目上成功运作的方法——团队非常满意采用这种方法的便利性。希望这可以帮到你!像编程中的所有事情一样,有多种方法,这完全取决于您想要实现什么。我重视简单性、易用性、可维护性和可发现性——而这个解决方案满足了所有这些需求。


感谢您的回答,这是使用中介者描述QueryObject模式的绝佳说明,我也开始在我的项目中推广它。我可能需要更新问题,但我不再对EF进行单元测试,因为抽象层泄漏太多了(SqlLite可能还可以),所以我只对查询数据库的内容进行集成测试,并对业务规则和其他逻辑进行单元测试。 - Modika

4
为了对依赖于数据库的代码进行单元测试,你需要为每个测试设置一个数据库或模拟数据库。
  1. 使用单一状态的数据库(真实或模拟)将很快让你遇到问题;你无法测试所有记录是否有效,因为有些数据是相同的。
  2. 在OneTimeSetup中设置一个内存数据库会出现问题,在下一个测试开始之前旧数据库没有被清除。这会导致测试单独运行时正常工作,但全部运行时失败。
  3. 理想情况下,单元测试只应该设置影响测试的内容。

我正在处理一个包含大量表格连接和庞大 Linq 代码块的应用程序,这些需要经过测试。遗漏简单的分组或者结果超过1个的连接都会影响结果。

为了解决这个问题,我建立了一个重型单元测试助手,需要大量设置工作,但能够可靠地模拟处于任何状态的数据库,并在55个互联表之间运行48个测试,整个数据库设置48次仅需4.7秒。

以下是具体操作方法:
  1. In the Db context class ensure each table class is set to virtual

    public virtual DbSet<Branch> Branches { get; set; }
    public virtual DbSet<Warehouse> Warehouses { get; set; }
    
  2. In a UnitTestHelper class create a method to setup your database. Each table class is an optional parameter. If not supplied, it will be created through a Make method

    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. For each table class, each object in it is mapped to the other lists

        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. And add it to the 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. Define a list of IDs to make is easier to reuse them and make sure joins are valid

     internal const int BranchID = 1;
     internal const int WarehouseID = 2;
    
  6. Create a Make for each table to setup the most basic, but connected version it can be

     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();
}

3
有一个名为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;");

嗨Andrew,问题从来不是获取上下文,你可以将上下文工厂化,这就是我们正在做的事情,抽象上下文并通过工厂构建它。最大的问题是内存中的内容与Linq4Entities的内容一致性不同,这可能会导致测试结果误导。目前,我们只是集成测试数据库内容,但这可能不适合每个人的流程。 - Modika
如果您有一个要模拟的上下文,那么这个Moq助手可以工作(https://www.codeproject.com/Tips/1045590/Testing-with-mock-on-Entity-Framework)。如果您使用列表来支持模拟的上下文,它将不会像由SQL数据库支持的上下文一样运行。 - andrew pate

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接