莫克斯不是存根(2007)

2021-01-29 13:15:33

术语“模拟对象”已经成为描述特殊情况对象的流行方法,这些特殊情况对象模仿了要测试的真实对象。现在,大多数语言环境都具有易于创建模拟对象的框架。但是,通常没有意识到的是,模拟对象只是一种特殊情况测试对象的形式,一种实现了不同风格的测试。在本文中,我将解释模拟对象如何工作,它们如何基于行为验证鼓励测试以及它们周围的社区如何使用它们来开发不同的测试样式。

我首先遇到了“模拟对象”一词。几年前在极限编程(XP)社区中。从那时起,我就越来越多地遇到模拟对象。部分原因是,许多领先的模拟对象开发人员一直是ThoughtWorks的同事。部分原因是因为我在受XP影响的测试文学中越来越多地看到它们。

但是我经常看到模拟对象的描述很差。我特别看到它们经常与存根混淆-存根是测试环境的常见帮助。我理解这种困惑-我也曾将它们视为相似的论坛,但与模拟开发人员的交谈一直在稳步允许一些模拟理解,以渗透我的to壳颅骨。

这种差异实际上是两个单独的差异。一方面,如何验证测试结果存在差异:状态验证和行为验证之间的区别。另一方面,与测试和设计共同发挥作用的哲学完全不同,我在这里将其称为“测试驱动开发”的经典和模拟风格。

我将通过一个简单的例子来说明这两种样式。 (该示例使用Java,但是这些原则对于任何面向对象的语言都是有意义的。)我们想获取一个订单对象,并从一个仓库对象中填充它。订单非常简单,只有一种产品和数量。仓库中存放着不同产品的库存。当我们要求从仓库填满订单时,有两种可能的响应。如果仓库中有足够的产品来填充订单,则订单将被填充,并且仓库中的产品数量将减少适当的数量。如果仓库中没有足够的产品,则订单无法填写,仓库中什么也没发生。

公共类OrderStateTester扩展了TestCase {private static String TALISKER =" Talisker&#34 ;;私有静态字符串HIGHLAND_PARK =" Highland Park&#34 ;;私人仓库仓库= new WarehouseImpl();受保护的void setUp()引发异常{Warehouse.add(TALISKER,50); Warehouse.add(HIGHLAND_PARK,25); } public void testOrderIsFilledIfEnoughInWarehouse(){订单=新订单(TALISKER,50); order.fill(仓库); assertTrue(order.isFilled()); assertEquals(0,Warehouse.getInventory(TALISKER)); } public void testOrderDoesNotRemoveIfNotEnough(){订单=新订单(TALISKER,51); order.fill(仓库); assertFalse(order.isFilled()); assertEquals(50,Warehouse.getInventory(TALISKER)); }

xUnit测试遵循典型的四个阶段顺序:设置,练习,验证,拆卸。在这种情况下,设置阶段部分通过setUp方法(设置仓库)完成,部分通过测试方法(设置订单)完成。对order.fill的调用是练习阶段。这就是该对象倾向于执行我们要测试的事情的地方。然后,assert语句进入验证阶段,检查执行的方法是否正确执行了其任务。在这种情况下,没有明确的拆卸阶段,垃圾收集器会为我们隐式地完成此任务。

在设置过程中,有两种对象要放在一起。 Order是我们正在测试的类,但是要使Order.fill正常工作,我们还需要一个Warehouse实例。在这种情况下,订单是我们重点测试的对象。面向测试的人喜欢使用被测对象或被测系统之类的术语来命名此类事物。这两个词都很难说,但由于它是一个广为接受的词,所以我会紧握鼻子并使用它。在Meszaros之后,我将使用“被测系统”,或简称SUT。

因此,对于此测试,我需要SUT(订单)和一个合作者(仓库)。我需要仓库有两个原因:一个是使已测试的行为完全起作用(因为Order.fill调用仓库的方法),其次我需要进行验证(因为Order.fill的结果之一是可能的更改)到仓库的状态)。当我们进一步探索该主题时,您将看到在那里我们将在SUT和协作者之间做出很多区分。 (在本文的早期版本中,我将SUT称为"主要对象"并将协作者称为"次要对象")

这种测试方式使用状态验证:这意味着我们可以通过在执行该方法后检查SUT及其合作者的状态来确定所执行的方法是否正确工作。正如我们将看到的,模拟对象启用了不同的验证方法。

现在,我将采取相同的行为并使用模拟对象。对于此代码,我使用jMock库定义模拟。 jMock是一个Java模拟对象库。还有其他的模拟对象库,但这是该技术的创建者编写的最新库,因此它是一个很好的起点。

公共类OrderInteractionTester扩展了MockObjectTestCase {private static String TALISKER =" Talisker&#34 ;; public void testFillingRemovesInventoryIfInStock(){//设置-数据订单=新订单(TALISKER,50); Mock WarehouseMock =新的Mock(Warehouse.class); //设置-期望WarehouseMock.expects(once())。method(" hasInventory").with(eq(TALISKER),eq(50)).will(returnValue(true)); WarehouseMock.expects(once())。method(" remove").with(eq(TALISKER),eq(50)).after(" hasInventory"); //执行order.fill((Warehouse)WarehouseMock.proxy()); //验证WarehouseMock.verify(); assertTrue(order.isFilled()); } public void testFillingDoesNotRemoveIfNotEnoughInStock(){订单=新订单(TALISKER,51);模拟仓库=模拟(Warehouse.class); Warehouse.expects(once())。method(" hasInventory").withAnyArguments().will(returnValue(false)); order.fill((Warehouse)Warehouse.proxy()); assertFalse(order.isFilled()); }

首先,设置阶段非常不同。首先,它分为两个部分:数据和期望。数据部分设置了我们感兴趣的对象,从这个意义上讲,它类似于传统设置。区别在于创建的对象。 SUT是相同的-订单。但是,协作者不是仓库对象,而是一个模拟仓库-从技术上讲是Mock类的实例。

设置的第二部分在模拟对象上创建期望,期望表明在执行SUT时应在它们上调用哪些方法。

一旦所有期望都实现了,我就开始练习。练习后,我将进行验证,这有两个方面。我对SUT断言-和以前一样。但是,我也验证了模拟-检查它们是否根据期望被调用。

此处的主要区别在于我们如何验证订单在与仓库的交互中所做的正确操作。通过状态验证,我们通过针对仓库状态进行断言来做到这一点。假人使用行为验证,而我们在其中检查订单是否在仓库中进行了正确的调用。我们通过告诉模拟程序在安装过程中期望什么并要求模拟程序在验证期间进行自我验证来进行此检查。仅使用断言检查订单,如果该方法不更改订单状态,则根本不会断言。

在第二项测试中,我做了几件不同的事情。首先,我使用MockObjectTestCase中的模拟方法而不是构造函数来不同地创建模拟。这是jMock库中的一种便捷方法,这意味着我以后无需明确调用verify,在测试结束时会自动验证任何使用便捷方法创建的模拟。我也可以在第一个测试中做到这一点,但是我想更明确地显示验证,以显示使用模拟进行测试的工作方式。

第二个测试案例中的第二个不同之处是,我通过使用withAnyArguments明确了对期望的约束。这样做的原因是,第一个测试检查该编号是否已传递到仓库,因此,第二个测试无需重复测试的该元素。如果以后需要更改订单的逻辑,则只有一个测试将失败,从而简化了迁移测试的工作。事实证明,我本可以完全不使用AnyArguments,因为这是默认设置。

有许多模拟对象库。我碰到的一点是EasyMock,无论是在Java还是.NET版本中。 EasyMock还支持行为验证,但是jMock在样式上有几个值得讨论的地方。这又是熟悉的测试:

公共类OrderEasyTester扩展了TestCase {private static String TALISKER =" Talisker&#34 ;;私人的MockControl WarehouseControl;私人仓库WarehouseMock;公共无效setUp(){WarehouseControl = MockControl.createControl(Warehouse.class); WarehouseMock =(仓库)WarehouseControl.getMock(); } public void testFillingRemovesInventoryIfInStock(){//设置-数据Order order = new Order(TALISKER,50); //设置-期望WarehouseMock.hasInventory(TALISKER,50); WarehouseControl.setReturnValue(true); WarehouseMock.remove(TALISKER,50); WarehouseControl.replay(); //执行order.fill(warehouseMock); // verify WarehouseControl.verify(); assertTrue(order.isFilled()); } public void testFillingDoesNotRemoveIfNotEnoughInStock(){订单=新订单(TALISKER,51); WarehouseMock.hasInventory(TALISKER,51); WarehouseControl.setReturnValue(false); WarehouseControl.replay(); order.fill((仓库)WarehouseMock); assertFalse(order.isFilled()); WarehouseControl.verify(); }}

EasyMock使用记录/重放隐喻来设置期望。为每个您希望模拟的对象创建一个controland模拟对象。该模拟满足次要对象的界面,该控件为您提供了其他功能。为了表示期望,您可以调用方法,并在它们上添加所需的参数。如果需要返回值,可以在此之后调用控件。完成期望的设置后,您可以在控件上调用重播-此时,模拟将完成录制并准备响应主对象。完成后,请在控件上调用验证。

似乎人们常常对记录/重放隐喻一见钟情,但他们很快就习惯了。它比jMock的约束具有优势,因为您可以对模拟进行实际的方法调用,而不是在字符串中指定方法名称。这意味着您可以在IDE中使用代码完成功能,并且任何方法名称的重构都将自动更新测试。缺点是您不能拥有较宽松的约束。

jMock的开发人员正在开发一个新版本,它将使用其他技术来允许您使用实际的方法调用。

首次引入模拟对象时,许多人很容易将模拟对象与使用存根的通用测试概念混淆。从那时起,人们似乎已经更好地理解了这些差异(我希望本文的早期版本有所帮助)。但是,要完全了解人们使用模拟的方式,重要的是要了解模拟和其他类型的测试双打。 (  doubles&#34 ;?不用担心这对您来说是个新名词,请等几段,然后便会清楚。)

当您进行这样的测试时,您一次只关注软件的一个元素,因此使用了通用术语“单元测试”。问题在于,要使一个单元正常工作,您通常需要其他单元-因此在我们的示例中需要某种类型的仓库。

在上面显示的两种测试样式中,第一种情况使用真实的仓库对象,第二种情况使用模拟仓库,这当然不是真正的仓库对象。使用模拟是在测试中不使用真实仓库的一种方法,但是像这样在测试中还有其他形式的虚幻对象。

谈论这个的词汇很快就会变得混乱-使用了各种各样的单词:存根,模拟,伪造,伪造。对于本文,我将遵循Gerard Meszaros的书的词汇。它不是每个人都使用的,但是我认为它是一个很好的词汇,并且因为这是我的论文,所以我可以选择要使用的单词。

Meszaros使用术语Test Double作为测试对象代替真实对象使用的任何假装对象的通用术语。该名称来自电影中的特技替身。 (他的目标之一是避免使用已经被广泛使用的任何名称。)然后,Meszaros定义了五种特殊的double类型:

虚拟对象可以传递,但从未实际使用过。通常它们仅用于填充参数列表。

伪对象实际上具有有效的实现,但是通常采取一些捷径,这使它们不适合生产(内存数据库就是一个很好的例子)。

存根提供对测试期间进行的呼叫的固定答复,通常通常根本不响应为测试编程的内容以外的任何内容。

间谍是存根,它们还根据调用方式记录一些信息。其中一种形式可能是电子邮件服务,它记录发送了多少消息。

嘲笑是我们在这里谈论的:带有期望的预编程对象,这些对象构成了期望接收的调用的规范。

在这类双打中,只有模拟者坚持进行行为验证。其他双打通常可以使用状态验证。在练习阶段,模拟程序实际上的行为确实像其他双打游戏一样,因为他们需要让SUT相信与真正的合作者交谈-但是模拟在设置和验证阶段有所不同。

要进一步探索测试的两倍,我们需要扩展示例。如果实际对象难以使用,许多人只会使用测试倍数。如果我们说如果我们未能履行订购订单,我们想发送电子邮件,则更常见的是双重测试。问题是我们不想在测试过程中向客户发送实际的电子邮件消息。因此,我们改为创建电子邮件系统的testdouble,我们可以对其进行控制和操作。

在这里,我们可以开始看到模拟和存根之间的区别。如果我们正在为此邮件行为编写测试,则可能会编写一个像这样的简单存根。

公共类MailServiceStub实现MailService {private List< Message>消息=新ArrayList< Message>();公共无效发送(消息味精){messages.add(msg); } public int numberSent(){return messages.size(); }}

公共无效testOrderSendsMailIfUnfilled(){订单=新订单(TALISKER,51); MailServiceStub mailer = new MailServiceStub(); order.setMailer(mailer); order.fill(仓库); assertEquals(1,mailer.numberSent()); }

当然,这是一个非常简单的测试-仅发送了一条消息。我们尚未测试它是否已发送给正确的人或正确的内容,但可以说明这一点。

公共无效testOrderSendsMailIfUnfilled(){订单=新订单(TALISKER,51);模拟仓库=模拟(Warehouse.class);模拟邮件=模拟(MailService.class); order.setMailer((MailService)mailer.proxy()); mailer.expects(once())。method(" send"); Warehouse.expects(once())。method(" hasInventory").withAnyArguments().will(returnValue(false)); order.fill((Warehouse)Warehouse.proxy()); }}

在这两种情况下,我都使用双重测试代替真实邮件服务。区别在于存根使用状态验证,而模拟使用行为验证。

为了在存根上使用状态验证,我需要在存根上做一些额外的方法来帮助进行验证。结果,存根实现了MailService,但添加了Extratest方法。

模拟对象始终使用行为验证,存根可以任意选择。 Meszaros指的是将行为验证用作测试间谍的存根。区别在于两次运行和验证的精确程度不同,我将留给您自己进行探索。

现在,我可以探讨第二个二分法:古典与模拟主义者TDD之间的二分法。这里最大的问题是何时使用模拟(或其他双精度)。

经典的TDD风格是在可能的情况下使用真实的对象,在不方便使用真实的对象的情况下使用两倍。因此,传统的TDDer将使用一个真实的仓库,并使用一个双重的邮件服务。双重类型并没有那么重要。

但是,模拟派TDD练习者将始终对任何具有有趣行为的对象使用模拟。在这种情况下,仓库和邮件服务都没有。

尽管在设计各种模拟框架时都考虑了模拟测试,但许多古典主义者发现它们对于创建双精度模型很有用。

模仿者风格的一个重要分支是行为驱动开发(BDD)。 BDD最初是由我的同事Daniel Terhorst-North开发的,旨在通过专注于TDD作为一种设计技术来更好地帮助人们学习测试驱动开发。这导致重命名测试行为,以更好地探索TDD在哪里帮助思考对象需要做什么。 BDD采取了一种模拟方法,但是在命名方式和将分析整合到其技术中的愿望上都对此进行了扩展。我在这里不做更多介绍,因为与本文唯一相关的是BDD是TDD的另一种变体,倾向于使用模拟测试。我将其留给您以点击链接以获取更多信息。

有时您会看到" Detroit"用于"古典"的样式和"伦敦"为“模拟主义者”。这暗示了XP最初是由底特律的C3项目开发的,而模拟主义者的风格是由XP的早期采用者在伦敦开发的。我还应该提到,许多嘲笑TDD的人不喜欢该术语,甚至不喜欢暗示经典测试和嘲笑测试之间有不同风格的任何术语。他们认为这两种样式之间没有有用的区别。

在本文中,我解释了一对差异:状态或行为验证/经典或模拟主义者TDD。在它们之间进行选择时要牢记哪些参数?我首先介绍状态验证与行为验证的选择。

首先要考虑的是上下文。我们是在考虑轻松的协作(例如订单和仓库)还是尴尬的协作(例如订单和邮件服务)?

如果协作容易,那么选择就很简单。如果我是经典的TDDer,则不要使用模拟,存根或任何双精度类型。我使用一个真实的对象和状态验证。如果我是模拟者TDDer,则可以使用模拟和行为验证。完全没有决定。

如果协作很尴尬,那么我是否要当一个模拟主义者并没有决定-我只是使用模拟和行为验证。如果我是古典主义者,那么我确实可以选择,但是使用哪一个并不重要。通常,古典主义者会根据具体情况决定具体情况,使用每种情况的最简单方法。

因此,正如我们所看到的,状态验证与行为验证在很大程度上并不是一个重大决定。真正的问题在经典与嘲讽TDD之间。事实证明,国家的特征和

......