代码之家  ›  专栏  ›  技术社区  ›  KallDrexx

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

  •  2
  • KallDrexx  · 技术社区  · 15 年前

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

    我的服务类通过 AsQueryable 我的存储库类实现的接口,从而允许我在没有服务类的服务类中使用LINQ,同时仍将数据访问层抽象出来。实际上,这似乎很管用。

    我看到的问题是,每当我看到所讨论的单元测试时,他们都在使用模拟对象,而不是我看到的内部方法。从表面上看,这是有意义的,因为如果我 内存报告 失败,不仅是我的 内存报告 单元测试失败,但这个失败也将级联到我的服务类和控制器中。更现实地说,我更关心我的服务类中影响控制器单元测试的故障。

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

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

    我如何拥有一个模拟的存储库 可修的 调用,我如何确保在设置模拟存储库时,不会通过硬编码返回语句来掩盖我对LINQ语句所做的任何问题?使用 内存报告 但是,是否将控制器单元测试更改为使用模拟服务对象?


    编辑: 在重新检查了我的结构之后,我想起了一个复杂的问题,那就是在控制器单元测试中防止模拟,因为我忘记了我的结构比我最初说的要复杂一些。

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

    控制器通过 IRepositoryFactory . 这个 IRepositoryFactory公司 是一个类,它应该使创建存储库变得容易,而不必直接将存储库导入控制器,或者让控制器担心哪些服务类需要哪些存储库。我有一个 InMemoryRepositoryFactory ,它提供服务类 InMemoryRepository<Entity> 实例化,我也有同样的想法 EFRepositoryFactory .

    在控制器的构造函数中,通过传入 IRepositoryFactory公司 传递到该控制器的对象。

    例如

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

    我看不到如何用这个体系结构模拟我的服务层,这样我的控制器就可以进行单元测试,而不是集成测试。对于单元测试,我可能有一个不好的体系结构,但是我不知道如何更好地解决使我首先想要建立一个存储库工厂的问题。

    3 回复  |  直到 15 年前
        1
  •  3
  •   Jeff Sternal    15 年前

    解决问题的一个方法是将控制器更改为按需 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's article about constructors doing real work 有关像这样重新构造代码的好处的扩展讨论。)

        2
  •  2
  •   Community Mohan Dere    9 年前

    就个人而言,我将围绕引用存储库的工作模式单元设计系统。这可以使事情简单得多,并允许原子地运行更复杂的操作。你通常会有一个 IUnitOfWorkFactory 在服务类中作为依赖项提供的。服务类将创建一个新的工作单元和该工作单元引用存储库。你可以看到一个例子 here .

    如果我理解正确的话,您会担心一段(低级别)代码中的错误会导致大量测试失败,从而使您很难看到实际的问题。你采取 InMemoryRepository 作为一个具体的例子。

    虽然你的担心是正确的,但我个人不会担心失败 内存报告 . 它是一个测试对象,您应该尽可能地保持这些测试对象的简单性。这可以防止您必须为测试对象编写测试。大多数情况下,我认为它们是正确的(不过,有时我会通过编写断言语句在此类类中使用自检查)。当这样的对象行为不当时,测试将失败。这不是最佳的,但你通常会很快发现我的经验中的问题所在。为了提高效率,你必须在某个地方划一条线。

    服务导致的控制器错误是另一杯茶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(to Expression Tree)查询非常容易,当在内存中对象上使用时,这些查询运行得很好,但在查询数据库时失败。有时无法转换查询(因为您调用的.NET方法在数据库中没有对应项),或者LINQ提供程序有限制(或错误)。尤其是实体框架3.5的LINQ提供者很差劲。

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

    不过,这是一个值得关注的问题。除了单元测试之外,您还可以进行集成测试。在这种情况下,您可以使用真正的提供者和(专用的)测试数据库来运行代码。在数据库事务中运行每个测试,并在测试结束时回滚该事务。( TransactionScope 用这个很好!)但是请注意,编写可维护的集成测试比编写可维护的单元测试更困难。您必须确保测试数据库的模型是同步的。每个集成测试都应该将测试所需的数据插入到数据库中,这通常是需要编写和维护的大量工作。最好是将集成测试的数量保持在最低限度。有足够的集成测试,使您对系统的更改充满信心。例如,在单个测试中调用带有复杂LINQ语句的服务方法通常足以测试您的LINQ提供程序是否能够从中构建有效的SQL。大多数情况下,我只是假设Linq提供程序将具有与LinqTo对象相同的行为。( .AsQueryable() 提供程序。再说一次,你得在某个地方划一条线。

    我希望这有帮助。

        3
  •  1
  •   Dan Bryant    15 年前

    我认为您的方法对于测试服务层本身是合理的,但是,正如您所建议的那样,如果服务层完全模拟为您的业务逻辑和其他高级测试,那就更好了。这使得更高级别的测试更容易实现/维护,因为如果已经测试了服务层,就不需要再次运行它了。