繁体   English   中英

单元测试时应该使用模拟对象吗?

[英]Should I be using mock objects when unit testing?

在我的ASP.Net MVC应用程序中,我使用IoC来促进单元测试。 我的应用程序的结构是Controller -> Service Class -> Repository类型的结构。 为了进行单元测试,我有一个InMemoryRepository类,该类继承了IRepository ,该类不使用数据库,而是使用内部List<T>成员。 在构建单元测试时,我只是传递了一个内部存储库的实例,而不是我的EF存储库。

我的服务类通过我的存储库类实现的AsQueryable接口从存储库中检索对象,因此允许我在没有服务类的情况下在服务类中使用Linq,同时仍然抽象出数据访问层。 实际上,这似乎很好。

我看到的问题是,每当我看到单元测试时,它们都在使用模拟对象,而不是我所看到的内部方法。 从表面上看,这是有道理的,因为如果我的InMemoryRepository失败了,那么我的InMemoryRepository单元测试不仅会失败,而且这种失败还将逐步分解为我的服务类和控制器。 实际上,我更担心服务类中的故障会影响控制器单元测试。

我的方法还要求我为每个单元测试做更多的设置,并且随着事情变得越来越复杂(例如,我将授权实现到服务类中),设置也变得更加复杂,因为然后我必须确保每个单元测试都使用服务可以正确分类,因此该单元测试的主要方面不会失败。 我可以清楚地看到模拟对象将在这方面提供帮助。

但是,我看不到如何用模拟完全解决这个问题,仍然有有效的测试。 例如,我的单元测试之一是,如果我调用_service.GetDocumentById(5) ,它将从存储库中获取正确的文档。 根据我的理解,这是有效的单元测试的唯一方法是存储2或3个文档,并且我的GetdocumentById()方法正确检索ID为5的文档。

我将如何使用AsQueryable调用创建一个AsQueryable存储库,以及如何在设置模拟存储库时通过硬编码return语句来确保不掩盖我对Linq语句造成的任何问题? 最好使用InMemoryRepository保留服务类单元测试,而将控制器单元测试更改为使用模拟服务对象,这会更好吗?


编辑:再次遍历我的结构后,我想起了一个阻止控制器单元测试中的模拟的复杂性,因为我忘记了我的结构比我最初说的要复杂一些。

Repository是一种对象的数据存储,因此,如果我的文档服务类需要文档实体,它将创建一个IRepository<Document>

控制器通过IRepositoryFactory传递。 IRepositoryFactory是一个类,可以IRepositoryFactory创建存储库的过程,而不必直接将存储库存储到控制器中,或者让控制器担心哪些服务类需要哪些存储库。 我有一个InMemoryRepositoryFactory ,它提供了服务类InMemoryRepository<Entity>实例化,同样的想法也适用于我的EFRepositoryFactory

在控制器的构造函数中,私有服务类对象通过传入传递到该控制器的IRepositoryFactory对象来实例化。

所以举个例子

 public class DocumentController : Controller { private DocumentService _documentService; public DocumentController(IRepositoryFactory factory) { _documentService = new DocumentService(factory); } ... } 

我看不到如何用这种架构模拟服务层,以便我的控制器经过单元测试而不是集成测试。 我可能对单元测试的体系结构不好,但是我不确定如何更好地解决使我最初想建立存储库工厂的问题。

解决您的问题的一种方法是更改​​控制器以要求IDocumentService实例,而不是自己构造服务:

public class DocumentController : Controller
{
    private IDocumentService _documentService;

    // The controller doesn't construct the service itself
    public DocumentController(IDocumentService documentService)
    {
        _documentService = documentService;
    }
    ...
}

在您的实际应用程序中,让您的IoC容器将IRepositoryFactory实例注入到您的服务中。 在控制器单元测试中,只需根据需要模拟服务。

(请参阅Misko Hevry的有关构造函数进行实际工作的文章 ,以扩展讨论如何重构代码这样的好处。)

我个人将围绕引用存储库的工作单元模式来设计系统。 这可以使事情变得更加简单,并使您可以自动运行更复杂的操作。 通常,您将有一个IUnitOfWorkFactory作为Service类中的依赖项提供。 服务类将创建一个新的工作单元,并且该工作单元引用存储库。 您可以在这里看到一个示例。

如果我理解正确,那么您担心一个(低级)代码中的错误会导致大量测试失败,从而使实际问题更加难以发现。 您将InMemoryRepository作为一个具体示例。

尽管您的顾虑是正确的,但是我个人不会担心InMemoryRepository会失败。 它是一个测试对象,您应该使这些测试对象尽可能简单。 这样可以避免您必须为测试对象编写测试。 大多数时候,我认为它们是正确的(但是,有时我会通过编写Assert语句在此类中使用自我检查)。 如果此类对象行为不当,则测试将失败。 这不是最佳选择,但是根据我的经验,您通常会很快找出问题所在。 为了提高生产力,您将必须在某处画一条线。

由服务引起的控制器错误是另一杯茶IMO。 尽管您可以嘲笑该服务,但这会使测试变得更加困难且可信度降低。 最好根本不测试服务。 只测试控制器! 控制器将调用该服务,如果您的服务运行不正常,则会发现您的控制器测试。 这样,您仅测试应用程序中的顶级对象。 代码覆盖率将帮助您发现未测试的代码部分。 当然,并非在所有情况下都可行,但这通常效果很好。 当服务与模拟的存储库(或工作单元)一起使用时,这将很好地工作。


您的第二个担心是,这些缺陷使您有许多测试设置 关于这一点,我有两件事要说。

首先,我尝试将依赖倒置最小化,仅使我需要能够运行单元测试。 应该伪造对系统时钟,数据库,Smtp服务器和文件系统的调用,以使单元测试快速可靠。 我尝试不做其他事情,因为嘲笑的次数越多,测试变得越不可靠。 您正在减少测试。 最小化依赖关系倒置(达到良好的RTM单元测试所需的条件)有助于简化测试设置。

但是(第二点),您还需要以一种易于读取和维护的方式编写单元测试(关于单元测试的难点,或者实际上是在制作软件)。 大型测试设置使它们难以理解,并且当类获得新的依赖关系时很难更改测试代码。 我发现使测试更具可读性和可维护性的最佳方法之一是在测试类中使用简单的工厂方法来集中创建测试中所需的类型(我从不使用模拟框架)。 我使用两种模式。 一种是简单的工厂方法,例如一种创建有效类型的方法:

FakeDocumentService CreateValidService()
{
    return CreateValidService(CreateInitializedContext());
}

FakeDocumentService CreateValidService(InMemoryUnitOfWork context)
{
    return new FakeDocumentSerice(context);
}

这样,测试可以简单地调用这些方法,并且当它们需要有效的对象时,它们可以简单地调用工厂方法之一。 当然,当这些方法之一意外创建无效对象时,许多测试将失败。 很难防止这种情况,但是很容易解决。 易于修复意味着测试是可维护的。

我使用的另一种模式是使用包含您要创建的实际对象的参数/属性的容器类型。 当对象具有许多不同的属性和/或构造函数参数时,此功能特别有用。 将此与容器的工厂和要创建的对象的构建器方法混合,您将获得非常易读的测试代码:

[TestMethod]
public void Operation_WithValidArguments_Succeeds()
{
    // Arrange
    var validArgs = CreateValidArgs();

    var service = BuildNewService(validArgs);

    // Act
    service.Operation();
}

[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void Operation_NegativeAge_ThrowsException()
{
    // Arrange
    var invalidArgs = CreateValidArgs();

    invalidArgs.Age = -1;

    var service = BuildNewService(invalidArgs);

    // Act
    service.Operation();
}

这使您可以让测试仅指定重要事项! 这对于使测试具有可读性非常重要! CreateValidArgs()方法可以创建一个包含100个以上参数的容器,这些容器将构成一个有效的SUT(被测系统)。 现在,您将默认有效配置集中在一处。 我希望这是有道理的。


您的第三个问题是无法测试LINQ查询在给定的LINQ提供程序中是否表现出预期的行为。 这是一个有效的问题,因为将LINQ(向表达式树)查询编写起来很容易,当它们在内存对象上使用时可以完美地运行,但是在查询数据库时失败。 有时,不可能翻译查询(因为您调用的数据库中没有对应的.NET方法),或者LINQ提供程序有局限性(或错误)。 尤其是Entity Framework 3.5的LINQ提供程序很难。

但是,这是无法使用每个定义的单元测试解决的问题。 因为当您在测试中调用数据库时,它不再是单元测试。 但是,单元测试永远不会完全取代手动测试:-)

尽管如此,这仍然是一个令人担忧的问题。 除了单元测试之外,您还可以进行集成测试。 在这种情况下,您将使用真正的提供程序和(专用)测试数据库运行代码。 在数据库事务中运行每个测试,并在测试结束时回滚该事务( TransactionScope可以很好地配合!)。 但是请注意,编写可维护的集成测试比编写可维护的单元测试还要难。 您必须确保测试数据库的模型是同步的。 每个集成测试应在数据库中插入该测试所需的数据,这通常需要编写和维护大量工作。 最好是将集成测试的数量保持在最低水平。 有足够的集成测试,使您对更改系统充满信心。 例如,在单个测试中必须使用复杂的LINQ语句调用服务方法通常足以测试LINQ提供程序是否能够从中构建有效的SQL。 大多数时候,我只是假设LINQ提供程序的行为与LINQ to Objects( .AsQueryable() )提供程序的行为相同。 同样,您将不得不在某处画线。

我希望这有帮助。

我认为您的方法适合测试服务层本身,但是,正如您建议的那样,最好将服务层完全模拟用于业务逻辑和其他高级测试。 这使您的更高级别的测试更易于实现/维护,因为如果已经对服务层进行了测试,则无需再次使用该服务层。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM