为什么我们不能在单元测试中模拟领域对象?

5

我给您两个测试; 目的是仅仅确认当service.doSomething被调用时,emailService.sendEmail会以人员的电子邮件作为参数进行调用。

@Mock 
private EmailService emailService;

@InjectMocks
private Service service;

@Captor
private ArgumentCaptor<String> stringCaptor;

@Test
public void test_that_when_doSomething_is_called_sendEmail_is_called_NO_MOCKING() {

    final String email = "billy.tyne@myspace.com";

    // There is only one way of building an Address and it requires all these fields
    final Address crowsNest = new Address("334", "Main Street", "Gloucester", "MA", "01930", "USA");
    // There is only one way of building a Phone and it requires all these fields
    final Phone phone = new Phone("1", "978-281-2965");
    // There is only one way of building a Vessel and it requires all these fields
    final Vessel andreaGail = new Vessel("Andrea Gail", "Fishing", 92000);
    // There is only one way of building a Person and it requires all these fields
    final Person captain = new Person("Billy", "Tyne", email, crowsNest, phone, andreaGail);

    service.doSomething(captain); // <-- This requires only the person's email to be initialised, it doesn't care about anything else

    verify(emailService, times(1)).sendEmail(stringCaptor.capture());

    assertThat(stringCaptor.getValue(), eq(email));   
}

@Test
public void test_that_when_doSomething_is_called_sendEmail_is_called_WITH_MOCKING() {

    final String email = "billy.tyne@myspace.com";

    final Person captain = mock(Person.class);
    when(captain.getEmail()).thenReturn(email);

    service.doSomething(captain); // <-- This requires the person's email to be initialised, it doesn't care about anything else

    verify(emailService, times(1)).sendEmail(stringCaptor.capture());   

    assertThat(stringCaptor.getValue(), eq(email));   
}

为什么我的团队告诉我不要模拟运行测试所需的领域对象,而这些对象并非实际测试的一部分?我被告知mock只用于被测试服务的依赖项。我认为,结果的测试代码更简洁、更清晰、更易于理解。没有任何东西会分散对调用emailService.sendEmail的验证的注意力,这是测试的目的。这是我长期以来听到和接受的教条之一,在许多工作中都有应用。但我仍然不能同意这种说法。
4个回答

4
我认为我理解了你的团队的立场。
他们可能在说,你应该将 mocks 保留给那些难以实例化依赖项的东西。这包括向数据库发出调用的仓库和其他服务,它们可能有自己的复杂依赖关系。但不包括可以实例化的域对象(即使填写所有构造函数参数很麻烦)。
如果你 mock 域对象,那么测试就不能覆盖它们。我知道我更愿意让服务、控制器、仓库等尽可能多地覆盖这些域对象,并最小化编写专门测试 getter 和 setter 的测试。这样可以让域对象的测试专注于任何实际的业务逻辑。
这确实意味着,如果域对象存在错误,则多个组件的测试可能会失败。但我认为这没关系。我仍然会测试域对象(因为在隔离环境中测试它们比确保测试覆盖服务中的所有路径更容易),但我不想完全依赖域对象测试来准确反映这些对象在服务中的使用方式,这似乎要求太高了。
你提到的是,mocks 允许你创建对象而无需填写所有数据(我确信真正的代码比发布的代码要糟糕得多)。这是一个权衡,但对我来说,包含实际域对象和被测试服务在内的代码覆盖似乎是更大的胜利。
对我来说,看起来你的团队选择了务实而非纯粹的路线。如果其他人已经达成了这个共识,那么你需要尊重这一点。有些事情值得引起争议,但这并不是其中之一。

2
这里有两个错误。
首先,测试一个服务方法在被调用时是否委托给另一个方法。这是一个不好的规范。一个服务方法应该根据它返回的值(对于getter)或通过该服务接口随后获取的值(对于mutator)来指定。服务层应该被视为Facade。通常情况下,很少有方法应该被指定为它们委托给哪些方法以及何时委托。委托是实现细节,因此不应该被测试。
不幸的是,流行的mocking框架鼓励了这种错误的方法。过度热衷于行为驱动开发也会导致这种方法的使用。
第二个错误围绕着“单元”测试的概念。我们希望每个单元测试只测试一件事,因此当一件事出现故障时,我们只有一个测试失败,并且定位故障很容易。我们倾向于认为“单元”意味着与“方法”或“类”相同。这导致人们认为单元测试应该仅涉及一个真正的类,而所有其他类都应该被mocked。这对于除最简单的类外几乎是不可能的。几乎所有的Java代码都使用标准库中的类,如String或HashSet。大多数专业的Java代码使用来自各种框架的类,如Spring。没有人认真建议mock这些。我们接受这些类是值得信赖的,因此不需要mock。我们接受不mock单元代码使用的“可信”类是可以的。但是,你说,我们的类是不可信的,所以我们必须mock它们。并非如此。通过为它们编写良好的单元测试,您可以信任那些其他类。但是如何避免交织在一起的相互依赖的类,当只有一个故障时会导致混乱的测试失败?那将是一个噩梦进行调试!使用1970年代编程中的一个概念(称为虚拟机层次结构,现在是一个相当令人困惑的术语,因为虚拟机的附加含义):将软件从低级到高级排列成层次结构,较高的层次使用较低的层次执行操作。每个层提供了描述操作和对象的更具表现力或先进的手段。因此,域对象处于低层,服务层位于较高层。当几个测试失败时,请开始调试最低级别的测试故障:故障可能在该层中,可能(但很可能不是)在更低的层中,而不是在更高的层中。
仅保留用于使测试非常昂贵的输入和输出接口(通常意味着mock存储库层和日志记录接口)。

2
这是一个权衡,你精心设计了一个处于“边缘”的例子。通常,模拟应该有一个理由。好的理由包括:
  • 您无法轻松地使被依赖组件(DOC)在测试中按预期行事。
  • 调用DOC会导致任何不确定的行为(日期/时间、随机性、网络连接)吗?
  • 测试设置过于复杂和/或维护密集(例如,需要外部文件)(*见下文)
  • 原始DOC为测试代码带来可移植性问题。
  • 使用原始DOC是否导致不可接受的长构建/执行时间?
  • DOC的稳定性(成熟度)是否存在问题,使测试不可靠,甚至更糟的是,DOC尚不可用?

例如,您通常不会模拟标准库数学函数,如sincos,因为它们没有上述任何问题。

为什么推荐避免不必要的模拟?

  • 首先,模拟增加了测试复杂性。
  • 其次,模拟使您的测试依赖于代码的内部工作方式,即代码如何与DOC交互(比如,在您的情况下,船长的名字是使用getFirstName获取的,尽管可能存在另一种获取该信息的方法)。
  • 正如Nathan所提到的,不模拟可以被视为一个优点——因为DOC可以免费测试——虽然我在这里会小心:如果您被诱惑去测试DOCs,那么您的测试可能会失去焦点。DOC应该有自己的测试。

为什么你的情况处于“边缘”?

上述好的模拟理由之一被标记为(*):“测试设置过于复杂……”,而您的示例构建为具有稍微复杂的测试设置。显然,测试设置的复杂性并不是一个硬性标准,开发人员只需做出选择。如果您想以这种方式看待它,您可以说无论哪种方式都存在一些风险,当涉及未来维护场景时。

总之,我认为,既不普遍模拟也不普遍不模拟都是正确的。相反,开发人员应该了解决策标准,然后将其应用于具体情况。当情况处于灰色地带,标准没有明确的决定时,不要为此而争吵。


-1

自动化测试的目的是揭示某个软件单元的预期行为不再按预期执行(也就是揭示错误)。

在给定的测试套件中,测试单元的粒度/大小/范围由您和您的团队决定。

一旦决定了这一点,如果可以模拟范围之外的某些内容而不影响正在测试的行为,则说明它显然与测试无关,应该进行模拟。这将有助于使您的测试更加:

  • 隔离
  • 快速
  • 可读性强(正如您所提到的)

...最重要的是,当测试失败时,它将揭示某个软件单元的预期行为不再按预期执行。对于足够小的测试单元,很明显会出现错误以及原因。

如果您的无模拟测试示例失败,可能表示AddressPhoneVesselPerson存在问题。这将导致浪费时间追踪错误发生的确切位置。

我想提醒的一件事是,你的模拟示例实际上在我看来有点难以阅读,因为你断言一个 String 将具有值 "Billy",但不清楚为什么。


2
如果未模拟的域对象引发错误,测试将失败,并显示堆栈跟踪指示错误发生的位置。这会浪费谁的时间? - Nathan Hughes
1
鉴于这个例子的简单性,我并不完全不同意你的观点。然而,在一个更大的系统中,使用这种测试设计很可能会出现许多故障,而实际上代码中只有一个错误。考虑一下,如果在“Phone”类中引入了一个错误,那么依赖于该类的每个测试(即使它没有直接测试该类)都将失败。这些信息(十个测试失败了)比潜在的更具有针对性的信息要少用:一个测试失败了,它就是测试“Phone”类和仅该类的测试。 - arcadeblast77
3
确实如此,但我不确定冗余故障是否应该是一个很大的问题:确认错误在领域对象中,检查修复并查看CI是否通过。另一种选择可能是根本无法捕捉电话号码错误,如果采用模拟方法可能会发生这种情况。我想我不乐观地认为,单独的领域测试将准确反映它们的使用方式。 - Nathan Hughes

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