关于单元测试:假装,嘲弄和存根有什么区别?

What's the difference between faking, mocking, and stubbing?

我知道如何使用这些术语,但我想知道是否有可以接受的关于伪造、模拟和单元测试存根的定义?你如何为你的测试定义这些?描述您可能使用每种方法的情况。

以下是我如何使用它们:

假:实现接口但包含固定数据且没有逻辑的类。只返回"好"或"坏"数据,这取决于实现。

mock:一个实现接口的类,它允许动态地设置从特定方法抛出的返回/异常值,并提供检查是否调用了特定方法的能力。

存根(stub):类似于模拟类,只是它不提供验证方法是否被调用的能力。

模拟和存根可以手工生成或由模拟框架生成。假类是手工生成的。我主要使用模拟来验证我的类和依赖类之间的交互。一旦验证了交互并测试了代码中的备用路径,我就使用存根。我主要使用假类来抽象数据依赖关系,或者当模拟/存根过于繁琐而无法每次设置时。


你可以得到一些信息:

来自马丁·福勒关于模型和存根

假对象实际上有工作实现,但通常采取一些快捷方式,使它们不适合生产。

存根为测试过程中的呼叫提供屏蔽应答,通常对测试程序之外的任何事物都没有任何响应。存根还可以记录有关呼叫的信息,例如电子邮件网关存根,它记住它"发送"的消息,或者可能只记录它"发送"的消息数。

模拟就是我们在这里所说的:预先编程的对象带有期望值,这些期望值构成了期望接收的调用的规范。

从XUnitPattern:

假的:我们获取或构建一个与SUT依赖的组件所提供的功能相同的非常轻量级的实现,并指示SUT使用它而不是真正的。

存根(stub):这个实现被配置为响应来自SUT的调用,其值(或异常)将在SUT中执行未测试的代码(参见第x页的生产错误)。使用测试存根的一个关键指示是由于无法控制SUT的间接输入而导致代码未经测试。

模拟对象,实现与SUT(测试中的系统)所依赖的对象相同的接口。当需要进行行为验证时,我们可以使用模拟对象作为观察点,以避免由于无法观察SUT上调用方法的副作用而导致未测试的需求(参见第x页的生产错误)。

亲自

我试图通过使用:模拟和存根来简化。当对象返回一个设置为测试类的值时,我使用mock。我使用存根来模拟要测试的接口或抽象类。事实上,不管你怎么称呼它,它们都是在生产中不使用的类,并且被用作测试的实用程序类。


存根-一个为方法调用提供预定义答案的对象。

模仿-你设定期望的对象。

假-功能有限的对象(用于测试),例如假Web服务。

双重考试是对存根,嘲弄和伪造的一般术语。但非正式地,你会经常听到人们简单地称他们为嘲弄。


我很惊讶这个问题已经存在这么久了,到目前为止还没有人根据RoyOsherove的"单元测试的艺术"给出答案。

在"3.1引入存根"中,存根定义为:

A stub is a controllable replacement for an existing dependency
(or collaborator) in the system. By using a stub, you can test your code without
dealing with the dependency directly.

并将存根和模拟之间的区别定义为:

The main thing to remember about mocks versus stubs is that mocks are just like stubs, but you assert against the mock object, whereas you do not assert against a stub.

"假"只是用于存根和模拟的名称。例如,当您不关心存根和模拟之间的区别时。

Osherove区分存根和模拟的方式,意味着任何用作测试的伪类都可以是存根或模拟。对于一个特定的测试来说,这完全取决于您如何在测试中编写检查。

  • 当您的测试检查被测类中的值时,或者实际上是除伪项之外的任何地方,伪项被用作存根。它只是为被测试的类提供了要使用的值,要么直接通过对它的调用返回的值,要么间接通过对它的调用导致副作用(在某些状态下)。
  • 当测试检查假值时,它被用作模拟。

将类fakex用作存根的测试示例:

1
2
3
4
5
6
7
const pleaseReturn5 = 5;
var fake = new FakeX(pleaseReturn5);
var cut = new ClassUnderTest(fake);

cut.SquareIt;

Assert.AreEqual(25, cut.SomeProperty);

fake实例用作存根,因为Assert根本不使用fake

将测试类X用作模拟的测试示例:

1
2
3
4
5
6
7
const pleaseReturn5 = 5;
var fake = new FakeX(pleaseReturn5);
var cut = new ClassUnderTest(fake);

cut.SquareIt;

Assert.AreEqual(25, fake.SomeProperty);

在这种情况下,Assert检查fake的值,使其成为一个模拟。

当然,这些例子都是精心设计的,但我从这一区别中看到了巨大的优点。它使您知道如何测试您的东西以及测试的依赖性在哪里。

我同意奥瑟洛夫的观点

from a pure maintainability perspective, in my tests using mocks creates more trouble than not using them. That has been my experience, but I’m always learning something new.

断言伪代码是您真正想要避免的事情,因为它使您的测试高度依赖于一个根本不是正在测试的类的实现。这意味着类ActualClassUnderTest的测试可以开始中断,因为ClassUsedAsMock的实现发生了更改。那会给我带来恶臭。对ActualClassUnderTest的测试最好只在ActualClassUnderTest改变时中断。

我认识到,写作是一种常见的做法,尤其是当你是一个模仿型的TDD用户时。我想我和马丁·福勒在古典主义阵营中关系很好(见马丁·福勒的《模仿不是存根》),并且像奥瑟罗一样,尽量避免互动测试(只能通过对假货的断言来完成)。

为了有趣地阅读为什么你应该避免这里定义的模拟,谷歌为"福勒模拟古典主义者"。你会发现很多意见。


为了说明存根和模拟的用法,我还想包括一个基于RoyOsherove的"单元测试的艺术"的例子。

设想一下,我们有一个日志分析器应用程序,它具有打印日志的唯一功能。它不仅需要与Web服务对话,而且如果Web服务抛出错误,日志分析器必须将错误记录到其他外部依赖项,并通过电子邮件将其发送给Web服务管理员。

下面是我们想在日志分析器内部测试的逻辑:

1
2
3
4
5
6
7
8
9
10
11
if(fileName.Length<8)
{
 try
  {
    service.LogError("Filename too short:" + fileName);
  }
 catch (Exception e)
  {
    email.SendEmail("a","subject",e.Message);
  }
}

当Web服务抛出异常时,如何测试LogAnalyzer是否正确调用电子邮件服务?以下是我们面临的问题:

  • 我们如何替换Web服务?

  • 我们如何从Web服务模拟异常以便测试对电子邮件服务的呼叫?

  • 我们如何知道电子邮件服务是正确调用的或全部?

我们可以使用Web服务的存根来处理前两个问题。为了解决第三个问题,我们可以对电子邮件服务使用模拟对象。

伪造是一个通用术语,可以用来描述存根或模拟。在我们的测试中,我们将有两个伪造。其中一个是电子邮件服务模拟,我们将使用它来验证是否向电子邮件服务发送了正确的参数。另一个是存根,我们将使用它来模拟从Web服务抛出的异常。这是一个存根,因为我们不会使用Web服务伪造来验证测试结果,只是为了确保测试运行正确。电子邮件服务是一个模拟,因为我们会断言它是正确调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[TestFixture]
public class LogAnalyzer2Tests
{
[Test]
 public void Analyze_WebServiceThrows_SendsEmail()
 {
   StubService stubService = new StubService();
   stubService.ToThrow= new Exception("fake exception");
   MockEmailService mockEmail = new MockEmailService();

   LogAnalyzer2 log = new LogAnalyzer2();
   log.Service = stubService
   log.Email=mockEmail;
   string tooShortFileName="abc.ext";
   log.Analyze(tooShortFileName);

   Assert.AreEqual("a",mockEmail.To); //MOCKING USED
   Assert.AreEqual("fake exception",mockEmail.Body); //MOCKING USED
   Assert.AreEqual("subject",mockEmail.Subject);

 }
}

这是使测试具有表现力的问题。如果我想让测试描述两个对象之间的关系,我会在一个模拟上设置期望值。如果我要设置一个支持对象来让我了解测试中有趣的行为,我会截取返回值。


如果您熟悉arrange act assert,那么解释存根和mock之间可能对您有用的区别的一种方法是存根属于arrange部分,就像它们用于安排输入状态一样,mock属于assert部分,就像它们用于断言结果一样。

傻瓜什么都不做。它们只是用来填充参数列表,这样就不会出现未定义或空错误。它们的存在也是为了满足严格类型语言中的类型检查器的要求,以便您可以编译和运行。


正如上面投票的答案所提到的,马丁·福勒讨论了在模拟中的这些区别不是存根,特别是标题"模拟和存根之间的区别",所以一定要阅读这篇文章。

我认为,与其关注这些事物是如何不同的,不如关注为什么它们是不同的概念。每一个都有不同的目的。

假货

伪造是一种行为"自然",但不是"真实"的实现。这些都是模糊的概念,所以不同的人对什么东西是假的有不同的理解。

假数据库的一个例子是内存中的数据库(例如,在:memory:存储中使用sqlite)。您永远不会将其用于生产(因为数据没有持久化),但它完全可以作为数据库在测试环境中使用。它也比"真正的"数据库轻得多。

作为另一个例子,也许您在生产中使用了某种对象存储(例如AmazonS3),但是在测试中,您可以简单地将对象保存到磁盘上的文件中;那么您的"保存到磁盘"实现将是假的。(或者您甚至可以使用内存中的文件系统来伪造"保存到磁盘"操作。)

作为第三个例子,设想一个提供缓存API的对象;一个实现正确接口但只执行完全不缓存但总是返回缓存未命中的对象是一种伪造的。

伪造的目的不是影响被测试系统的行为,而是简化测试的实现(通过删除不必要的或重量级的依赖项)。

短截线

存根是一种行为"不自然"的实现。它被预先配置(通常通过测试设置)以响应具有特定输出的特定输入。

存根的目的是使您的系统处于测试状态。例如,如果您正在为一些与REST API交互的代码编写测试,则可以使用始终返回封闭响应的API,或者使用特定错误响应API请求的API来终止REST API。通过这种方式,您可以编写测试来断言系统对这些状态的反应;例如,如果API返回404错误,测试用户得到的响应。

存根通常被实现为只响应您告诉它要响应的确切交互。但是使某个东西成为存根的关键特性是它的目的:存根就是关于设置测试用例的。

嘲讽

模拟类似于存根,但添加了验证。模拟的目的是断言测试中的系统如何与依赖项交互。

例如,如果您正在为一个将文件上载到网站的系统编写测试,您可以构建一个接受文件的模拟,并使用该模拟来断言上载的文件是正确的。或者,在较小的范围内,通常使用对象的模拟来验证被测试的系统调用被模拟对象的特定方法。

模拟与交互测试紧密相连,交互测试是一种特定的测试方法。喜欢测试系统状态而不是系统交互的人会尽量少用模拟。

双倍测试

伪造、存根和模拟都属于测试加倍的范畴。测试双重对象是您在测试中使用的任何对象或系统,而不是其他对象或系统。大多数自动化软件测试都涉及到使用某种或其他类型的测试双重测试。其他类型的测试加倍包括假值、间谍和I/O黑洞。


您在上面断言的东西称为一个模拟对象,其他所有帮助测试运行的东西都是一个存根。


我从以下资源中学到了很多东西,罗伯特·C·马丁(鲍勃叔叔)给出了一个很好的解释:

清洁代码博客上的小嘲弄者

它解释了

  • 假人
  • 双倍测试
  • 短截线
  • 间谍
  • (真)嘲讽
  • 假货

它还提到了MartinFowler,并解释了一些软件测试历史。

我决不打算用这个链接来回答这个问题,因为这是一个真正的答案。然而,它帮助我更好地理解了嘲笑和间谍的概念,所以我回答这个问题,希望它能帮助更多的人。


stub和fake是对象,因为它们可以根据输入参数改变响应。它们之间的主要区别在于,与存根相比,伪代码更接近于真实的实现。存根基本上包含对预期请求的硬编码响应。让我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MyUnitTest {

 @Test
 public void testConcatenate() {
  StubDependency stubDependency = new StubDependency();
  int result = stubDependency.toNumber("one","two");
  assertEquals("onetwo", result);
 }
}

public class StubDependency() {
 public int toNumber(string param) {
  if (param =="one") {
   return 1;
  }
  if (param =="two") {
   return 2;
  }
 }
}

模仿是从假货和树桩中走出来的。模拟提供与存根相同的功能,但更复杂。它们可以为它们定义规则,这些规则规定必须以何种顺序调用API上的方法。大多数mock可以跟踪调用一个方法的次数,并可以根据这些信息作出反应。模拟通常知道每个调用的上下文,并且在不同的情况下可以做出不同的反应。因此,模仿需要对他们所模仿的课程有一定的了解。存根通常无法跟踪调用方法的次数或方法序列的调用顺序。模拟模型看起来像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class MockADependency {

 private int ShouldCallTwice;
 private boolean ShouldCallAtEnd;
 private boolean ShouldCallFirst;

 public int StringToInteger(String s) {
  if (s =="abc") {
   return 1;
  }
  if (s =="xyz") {
   return 2;
  }
  return 0;
 }

 public void ShouldCallFirst() {
  if ((ShouldCallTwice > 0) || ShouldCallAtEnd)
   throw new AssertionException("ShouldCallFirst not first thod called");
  ShouldCallFirst = true;
 }

 public int ShouldCallTwice(string s) {
  if (!ShouldCallFirst)
   throw new AssertionException("ShouldCallTwice called before ShouldCallFirst");
  if (ShouldCallAtEnd)
   throw new AssertionException("ShouldCallTwice called after ShouldCallAtEnd");
  if (ShouldCallTwice >= 2)
   throw new AssertionException("ShouldCallTwice called more than twice");
  ShouldCallTwice++;
  return StringToInteger(s);
 }

 public void ShouldCallAtEnd() {
  if (!ShouldCallFirst)
   throw new AssertionException("ShouldCallAtEnd called before ShouldCallFirst");
  if (ShouldCallTwice != 2) throw new AssertionException("ShouldCallTwice not called twice");
  ShouldCallAtEnd = true;
 }

}