另一个关于嘲笑的问题

时间:2009-08-31 18:01:45

标签: service mocking encapsulation

首先让我说,尽管TDD是一位相当新的TDD从业者,但我几乎已经将它的好处卖掉了。我觉得自己已经取得了足够的进步,可以考虑使用模拟技术,并且在了解模拟适用于OOP的位置时,已经遇到了真正的砖墙。

我已经阅读了尽可能多的有关该主题的相关帖子/文章(FowlerMiller),我仍然不完全清楚如何或何时嘲笑。

让我举一个具体的例子。我的应用程序有一个服务层类(有些人称之为应用程序层?),其中的方法大致映射到特定的用例。这些类可以与持久层,域层甚至其他服务类协作。我一直是一个很好的小DI男孩,并且已经将我的依赖关系进行了适当的考虑,因此可以将它们用于测试目的等等。

示例服务类可能如下所示:

public class AddDocumentEventService : IAddDocumentEventService
{
    public IDocumentDao DocumentDao
    {
        get { return _documentDao; }
        set { _documentDao = value; }
    }
    public IPatientSnapshotService PatientSnapshotService
    {
        get { return _patientSnapshotService; }
        set { _patientSnapshotService = value; }
    }

    public TransactionResponse AddEvent(EventSection eventSection)
    {
        TransactionResponse response = new TransactionResponse();
        response.Successful = false;

        if (eventSection.IsValid(response.ValidationErrors))
        {

            DocumentDao.SaveNewEvent( eventSection,  docDataID);

            int patientAccountId = DocumentDao.GetPatientAccountIdForDocument(docDataID);
            int patientSnapshotId =PatientSnapshotService.SaveEventSnapshot(patientAccountId, eventSection.EventId);

            if (patientSnapshotId == 0)
            {
                throw new Exception("Unable to save Patient Snapshot!");
            }

            response.Successful = true;
        }
        return response;
    }

}

我通过使用NMock来完成测试此方法的过程,而不依赖于它的依赖关系(DocumentDao,PatientSnapshotService)。这是测试的样子

 [Test]
 public void AddEvent()
    {
        Mockery mocks = new Mockery();
        IAddDocumentEventService service = new AddDocumentEventService();
        IDocumentDao mockDocumentDao = mocks.NewMock<IDocumentDao>();
        IPatientSnapshotService mockPatientSnapshot = mocks.NewMock<IPatientSnapshotService>();

        EventSection eventSection = new EventSection();

        //set up our mock expectations
        Expect.Once.On(mockDocumentDao).Method("GetPatientAccountIdForDocument").WithAnyArguments();
        Expect.Once.On(mockPatientSnapshot).Method("SaveEventSnapshot").WithAnyArguments();
        Expect.Once.On(mockDocumentDao).Method("SaveNewEvent").WithAnyArguments();

        //pass in our mocks as dependencies to the class under test
        ((AddDocumentEventService)service).DocumentDao = mockDocumentDao;
        ((AddDocumentEventService)service).PatientSnapshotService = mockPatientSnapshot;

        //call the method under test
        service.AddEvent(eventSection);

        //verify that all expectations have been met
        mocks.VerifyAllExpectationsHaveBeenMet();
    }

我对这种嘲弄的小想法的想法如下:

  1. 这个测试似乎打破了许多基本的OO规则,其中最重要的是封装:我的测试完全了解被测试类的具体实现细节(即方法调用)。每当班级内部变化时,我都会看到很多非生产性的时间用于更新测试。
  2. 也许是因为我的服务类目前相当简单,但我看不出这些测试增加了什么价值。我是否保证在特定用例指示的情况下调用协作对象?对于如此小的好处,代码重复似乎非常高。
  3. 我错过了什么?

6 个答案:

答案 0 :(得分:2)

你提到马丁福勒关于这个问题的一篇非常好的帖子。他提到的一点是,嘲笑者喜欢测试行为并隔离东西。

经典的TDD风格是尽可能使用真实对象,如果使用真实物品则很难使用双重。所以经典的TDDer会使用真正的仓库和双重的邮件服务。双重并不重要。

然而,模仿者TDD从业者将始终对任何有趣行为的对象使用模拟。在这种情况下,仓库和邮件服务。

如果你不喜欢这种东西,你可能是一个经典的TDDer,只有当它很笨拙时才应该使用模拟(如邮件服务或信用卡收费)。 否则,您创建自己的双打(如创建内存数据库)。

特别是,我是一个模仿者,但是如果调用特定方法(除非它不返回值),我不会验证多少。在任何情况下,我都会测试接口。当函数返回一些东西时,我使用mocking框架来创建存根。

最后,这一切都来自于您想要测试的内容和方式。您认为检查这些方法是否真的被调用(使用模拟)是否很重要?你想只是检查通话前后的状态(使用假货)吗? 选择足以认为它正常工作的内容,然后构建测试以确切地检查它!

关于测试的价值,我有一些看法:

  • 短期而言,当您使用TDD时,您通常会获得更好的设计,但您可能需要更长的时间。
  • 从长远来看,你不会太害怕以后更改和维护这段代码(当你不记得细节时),你会立刻得到一个红色, 几乎是即时反馈

顺便说一下,测试代码大小与生产代码大小一样大是正常的。

答案 1 :(得分:1)

破坏封装并因此使您的测试与代码紧密耦合绝对是使用模拟的缺点。您不希望您的测试对重构很脆弱。这是一个你必须走的细线。我个人避免使用模拟,除非它真的很难,笨拙或慢。 看看你的代码,首先,我会使用BDD样式:你的测试方法应该测试方法的特定行为,并且应该这样命名(可能像AddEventShouldSaveASnapshot)。其次,经验法则是仅验证预期的行为是否发生,而不是对应该发生的每个方法调用进行编目。

答案 2 :(得分:1)

使用模拟测试可以帮助您理解协作对象之间的关系 - 描述它们应该如何相互通信的协议。在这种情况下,您希望知道在事件到达时会保留一些内容。如果您描述对象的外部关系,那么它不会破坏封装。 DAO和服务类型描述了那些外部服务,但没有定义它们的实现方式。

这只是一个小例子,但代码感觉是程序性而不是OO。有几种简单值从一个对象中提取并传递给另一个对象。也许某种Patient对象应该直接处理事件。很难说,但也许测试暗示了这些设计问题?

与此同时,如果你不介意自我推销,可以再等一个月, http://www.growing-object-oriented-software.com/

答案 3 :(得分:1)

当我写这样的测试时,我有同样的不安感。当我通过copy'n'pasting函数体中的期望来实现函数时(特别是当我使用LeMock进行模拟时),它特别让我感到震惊。

但是没关系。它发生了。此测试现在记录并验证被测系统如何与其依赖项交互,这是一件好事。这个测试还有其他问题:

  1. 一次测试太多了。此测试验证是否正确调用了三个依赖项。如果这些依赖项中的任何一个发生更改,则此测试必须更改。最好有3个单独的测试,验证每个依赖项是否得到妥善处理。传入一个存根对象,查找未测试的依赖项(而不是模拟,因为它会失败)。

  2. 没有验证传递给依赖项的参数,因此这些测试不完整。

  3. 在此示例中,我将使用Moq作为模拟库。此测试未指定所有依赖项的行为,它仅测试一个调用。它还将检查传入的参数是否是预期的输入,输入的变化将证明单独的测试是合理的。

    public void AddEventSavesSnapshot(object eventSnaphot)
    {
        Mock<IDocumentDao> mockDocumentDao = new Mock<IDocumentDao>();
        Mock<IPatientSnapshotService> mockPatientSnapshot = new Mock<IPatientSnapshotService>();
    
        string eventSample = Some.String();
        EventSection eventSection = new EventSection(eventSample);
    
        mockPatientSnapshot.Setup(r => r.SaveEventSnapshot(eventSample));
    
        AddDocumentEventService sut = new AddDocumentEventService();
        sut.DocumentDao = mockDocumentDao;
        sut.PatientSnapshotService = mockPatientSnapshot;
    
        sut.AddEvent(eventSection);
    
        mockPatientSnapshot.Verify();
    }
    

    请注意,如果AddEvent()可能使用它们,则只在此测试中需要传入的未使用的依赖项。相当合理的是,该类可能具有与未显示的此测试无关的依赖项。

答案 4 :(得分:0)

我发现在这些类型的测试中使用伪(内存)持久层很有用;然后,您可以验证最终结果(项目现在存在于存储库中),而不是验证是否进行了某些调用。我知道你正在使用嘲讽,但我想我说我不认为这是最好的地方。

作为一个例子,我将看到这个测试的伪代码:

Instantiate the fake repositories.
Run your test method.
Check the fake repository to see if the new elements exist in it.

这使您的测试不会意识到实现细节。但是,它确实意味着维护假持久层,所以我认为这是一个你需要考虑的权衡。

答案 5 :(得分:0)

将代码中的利益相关者分开有时是值得的。

封装是关于最小化具有最高变化传播概率的潜在依赖性的数量。这是基于静态源代码。

单元测试是为了确保运行时行为不会无意中发生变化:它不是基于静态源代码。

当单元测试人员不使用原始的封装源代码,而是在源代码的副本上工作时,它有时很有用,源代码的所有私有访问器都自动更改为公共访问器(这只是一个四行的shell脚本)。

这将封装与单元测试完全分开。

然后只剩下你的单元测试有多低:你想测试多少种方法。这是一个品味问题。

有关封装的更多信息(但在单元测试中没有任何内容),请参阅: http://www.edmundkirwan.com/encap/overview/paper7.html