65.9K
CodeProject 正在变化。 阅读更多。
Home

你在嘲讽我吗?!

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2015年1月17日

CPOL

8分钟阅读

viewsIcon

8879

你在嘲讽我吗?

似乎每个开发者都有自己做事的方式。我知道我有自己的方法论,其中一些可能不是最简单或最好的(据我所知)。在我的职业生涯中,我一直在不断完善我的设计、开发、测试和软件支持技能。

我认识到每个人都有自己的经验,所以我通常不会质疑或试图改变别人的流程。如果我认为会有帮助,我会尝试提出建议。然而,有时我不得不问:“你确定你知道自己在做什么吗?”对于这篇文章,我想重点关注单元测试,特别是使用模拟对象

  

Are you sure you know what you are doing?

“模拟”是什么意思?

我想认真关注一种单元测试技术,它被滥用得如此严重,我甚至会称之为一种反技术。这就是模拟对象的使用。

当单元测试试图消除依赖或避免使用昂贵资源时,模拟对象和函数可以填补一个重要的空白。许多模拟库使模拟代码依赖项的行为变得非常简单。当库集成到您的开发环境中并为您生成大量代码时,情况尤其如此。

还存在其他方法,我认为它们是更好的首选。无论您最终选择哪种方法,都应取决于您的测试目标。我想反思一下我使用模拟对象的一些经验,并提供一些用于剪辑单元测试依赖项的替代可能性。

单元测试

模拟对象只是单元测试这一更大主题的一个小元素。因此,我认为提供一个单元测试的简要概述是明智的,以便为本次讨论设定上下文,并统一我们的理解。单元测试是由代码单元的程序员编写的一个小的、独立的测试,我将普遍称之为系统。

您可以在此文章中找到有关单元测试是什么以及它如何提供价值的详细解释:单元测试的目的[^]。

尝试将您的被测系统 (SUT) 从尽可能多的依赖项中隔离出来非常重要。这将帮助您区分由您的代码引起的问题和由其依赖项引起的问题。在《xUnit Patterns》一书中,Gerrard Meszaros 引入了测试替身的概念,用于替代这些依赖项。我见过许多不同的名称来描述测试替身,例如虚拟对象、伪对象、模拟对象和存根。我认为在我们继续之前,澄清一些词汇很重要。

我发现并沿用至今的最佳定义来自 Martin Fowler 这篇优秀的博客文章《Mocks Aren't Stubs》。Martin 定义了一组术语,我将用它们来区分不同类型的测试替身。

  • 虚拟对象(Dummy objects)被传递,但从未实际使用。通常它们只用于填充参数列表。
  • 伪对象(Fake objects)实际上有可工作的实现,但通常会采取一些捷径,使其不适合生产环境(内存数据库就是一个很好的例子)。
  • 存根(Stubs)为测试期间进行的调用提供预设的答案,通常对测试中编程以外的任何内容根本不响应。存根还可以记录有关调用的信息,例如一个电子邮件网关存根,它会记住它“发送”的消息,或者仅仅是它“发送”了多少条消息。
  • 模拟对象(Mocks)是我们这里讨论的:预先编程了期望的对象,这些期望构成了它们预期收到的调用的规范。

Martin 上述的博客文章也是学习接下来我将讨论的两种通用测试类型之间更深层次理解的绝佳来源。

所以模拟只是另一种测试替身?!

不,并非如此。

除了替换依赖项之外,模拟对象还会在其实现中添加断言。这允许测试报告一个函数是否被调用,一组函数是否按顺序被调用,甚至一个不应该被调用的函数是否被调用。将其与简单的伪对象进行比较,现在伪对象看起来像廉价的仿制品(而不是高端仿制品)。通过添加这些断言,结果变成了一种行为验证形式。

模拟对象是用于软件单元测试的非常有价值的工具。许多单元测试框架现在也包含或支持某种形式的模拟框架。这些框架简化了单元测试中模拟对象的创建。我所知道的一些框架有用于 JAVA 的 easyMock 和 jMock,用于 .NET 的 nMock,以及如果您使用 GoogleTest 验证 C++ 代码的 GoogleMock。

行为验证

模拟对象验证软件的行为。对于特定的测试,您可能期望您的对象被调用两次,并且您可以指定每次调用返回的值。期望可以在您的模拟声明描述中设置,如果这些期望被违反,模拟将触发框架错误。预期行为直接在模拟对象的定义中指定。这反过来很可能会决定实际系统必须如何实现才能通过测试。这是一个简单的例子,其中一个对象的成员函数将该对象注册为一系列回调函数。

// No, I'm not a Singleton.  
// I'm an Emporium, so quit asking. 
class CallbackEmporium 
{ 
  // Provides functions to register callbacks 
} 
  
TheCallbackEmporium& TheEmporium() 
{ ... } 

// Target SUT 
void Object::Register() 
{ 
  TheEmporium().SetCallbackTypeA( Object::StaticCallA, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallB, this ); 
} 

显然,验证此函数的唯一方法是通过验证其行为。没有返回值,因此无法验证。因此,需要一个模拟对象来验证注册函数。使用模拟框架的语法,这将非常简单,因为我们将断言直接添加到测试的模拟声明中,仅此而已!

数据验证

最终,函数 `object::Register()` 关心的是两个适当的回调函数是否已注册到 `TheEmporium`。所以,如果你在我前一节说“显然唯一的办法是……”时点头同意,我建议你在读到类似那样的句子后停下来,并挑战作者的说法。最肯定的是,还有其他验证方法,这里就是其中之一。

如果你在那句诡计之后停顿了,得1分;如果你保留判断等待证据来支持我的说法,得2分。

如果我们有一个替身对象来替换 TheEmporium,那仍然是最好的。然而,如果在 SUT 调用之后我们能够以某种方式验证预期的回调函数是否已注册到 `TheEmporium` 的正确参数中,那么我们就不需要模拟对象。我们已经验证了系统的最终数据与预期一致,而不是程序按照预设行为执行。

为什么这很重要?

测试与实现之间的紧密耦合。

假设你用这种方式编写你的模拟对象来验证代码

// This is a mocked yet serious syntax  
// for a mock-object statement to verify Register(). 
Mocker.call( SetCallbackTypeA() 
               .with(Object::StaticCallA)).and() 
      .call( SetCallbackTypeB() 
               .with(Object::StaticCallB)); 

这将测试当前实现的功能。然而,如果 `Object::Register` 的实现是以下任何一种,测试可能会报告失败,即使 SUT 已经达到了预期和正确的结果。

void Object::Register() 
{ 
  TheEmporium().SetCallbackTypeB( Object::StaticCallB, this ); 
  TheEmporium().SetCallbackTypeA( Object::StaticCallA, this ); 
} 
// Too many calls to one of the functions 
void Object::Register() 
{ 
  TheEmporium().SetCallbackTypeA( Object::StaticCallA, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallB, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallB, this ); 
} 
// Call each function twice: 
// Assign incorrect values first. 
// Then call a second time with the correct values. 
void Object::Register() 
{ 
  TheEmporium().SetCallbackTypeA( Object::StaticCallB, this ); 
  TheEmporium().SetCallbackTypeA( Object::StaticCallA, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallA, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallB, this ); 
} 
// Assign incorrect values first. 
// Then call a second time with the correct values.void Object::Register() 
{ 
  TheEmporium().SetCallbackTypeA( Object::StaticCallB, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallA, this ); 
  TheEmporium().SetCallbackTypeA( Object::StaticCallA, this ); 
  TheEmporium().SetCallbackTypeB( Object::StaticCallB, this ); 
} 

所有这四种实现对于数据验证形式的测试仍然有效。因为正确的结果在 SUT 返回时被赋给了正确的值。

当模拟对象屈尊俯就你时

讽刺。你不喜欢吗?!

模拟对象可以让你非常成功地走很远。事实上,你可能已经接近开发阶段的尾声,并且你的每个对象和函数都有单元测试。你正在收尾组件集成阶段。事情并没有像预期那样运作。以下是我个人观察到的一些情况:

  • 编译器抱怨缺少定义
  • 链接器(针对 C 和 C++ 开发者)抱怨引用了未定义符号
  • 这是一个网络应用程序,一切都编译并链接,程序加载并且没有崩溃。它也没有做其他任何事情。你连接一个调试器,它没有发送流量。

我看到开发者对模拟对象为他们开发测试的简单性如此热情,以至于他们几乎创建了一个完整的模拟实现。当它编译并执行时,关键核心组件的实现极少或为空。所有关键逻辑都已完成并验证。然而,将应用程序绑定在一起的“胶水”,即实用工具类,尚未实现。它们仍然是存根。

摘要

解决问题的方法有很多种。每种解决方案都有其自身的价值。有些很简单,有些很优雅,而另一些则在一个紧密的循环中空转,消耗额外的处理周期,因为程序在更快的 CPU 上无法正常工作。请注意您从所采用的解决方案方法中获得的价值。如果这是您唯一知道的方法,那是一个很好的第一步。请记住继续寻找,因为可能存在您更看重的更好解决方案。

知而不行,是为未知。

© . All rights reserved.