模拟对象不是存根

术语“模拟对象”已成为描述模拟真实对象以进行测试的特殊情况对象的流行术语。大多数语言环境现在都有框架,可以轻松创建模拟对象。然而,人们往往没有意识到,模拟对象只是特殊情况测试对象的一种形式,它能够实现不同的测试风格。在本文中,我将解释模拟对象的工作原理,它们如何鼓励基于行为验证的测试,以及围绕它们的社区如何使用它们来开发不同的测试风格。

2007 年 1 月 2 日



几年前,我在 极限编程 (XP) 社区中第一次遇到了“模拟对象”这个词。从那时起,我遇到的模拟对象越来越多。部分原因是,许多模拟对象的领先开发人员曾在 Thoughtworks 与我共事。部分原因是,我在受 XP 影响的测试文献中越来越多地看到它们。

但通常情况下,我看到对模拟对象的描述很糟糕。特别是,我经常看到它们与存根混淆——测试环境中常见的辅助工具。我理解这种混淆——我曾经也认为它们很相似,但与模拟开发人员的对话逐渐让一些模拟理解渗透到我的龟壳头骨中。

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

常规测试

我将从一个简单的例子开始说明这两种风格。(这个例子是用 Java 编写的,但这些原理对任何面向对象的语言都有意义。)我们想要获取一个订单对象并从一个仓库对象中填充它。订单非常简单,只有一个产品和数量。仓库保存着不同产品的库存。当我们要求订单从仓库中填充自己时,有两种可能的响应。如果仓库中有足够的商品来填充订单,则订单将被填充,仓库中该商品的数量将减少相应的数量。如果仓库中没有足够的商品,则订单不会被填充,仓库中也不会发生任何变化。

这两种行为意味着几个测试,这些测试看起来非常像传统的 JUnit 测试。

public class OrderStateTester extends TestCase {
  private static String TALISKER = "Talisker";
  private static String HIGHLAND_PARK = "Highland Park";
  private Warehouse warehouse = new WarehouseImpl();

  protected void setUp() throws Exception {
    warehouse.add(TALISKER, 50);
    warehouse.add(HIGHLAND_PARK, 25);
  }
  public void testOrderIsFilledIfEnoughInWarehouse() {
    Order order = new Order(TALISKER, 50);
    order.fill(warehouse);
    assertTrue(order.isFilled());
    assertEquals(0, warehouse.getInventory(TALISKER));
  }
  public void testOrderDoesNotRemoveIfNotEnough() {
    Order order = new Order(TALISKER, 51);
    order.fill(warehouse);
    assertFalse(order.isFilled());
    assertEquals(50, warehouse.getInventory(TALISKER));
  }

xUnit 测试遵循典型的四阶段序列:设置、执行、验证、拆卸。在本例中,设置阶段部分在 setUp 方法(设置仓库)中完成,部分在测试方法(设置订单)中完成。对 order.fill 的调用是执行阶段。这是对对象进行操作以执行我们想要测试的操作的地方。然后断言语句是验证阶段,检查执行的方法是否正确执行了其任务。在本例中,没有显式的拆卸阶段,垃圾收集器会为我们隐式地执行此操作。

在设置期间,我们组合了两种类型的对象。Order 是我们正在测试的类,但为了使 Order.fill 工作,我们还需要一个 Warehouse 的实例。在这种情况下,Order 是我们关注测试的对象。面向测试的人喜欢使用诸如被测对象或被测系统之类的术语来命名这样的东西。这两个术语都是难听的,但由于它是一个被广泛接受的术语,我将忍住鼻子使用它。遵循 Meszaros 的说法,我将使用被测系统,或者更确切地说,使用缩写 SUT。

因此,对于此测试,我需要 SUT(Order)和一个协作者(warehouse)。我需要仓库有两个原因:一是让测试的行为正常工作(因为 Order.fill 调用仓库的方法),二是需要它进行验证(因为 Order.fill 的结果之一可能是仓库状态的潜在变化)。当我们进一步探讨这个主题时,您会看到我们会对 SUT 和协作者之间的区别进行很多讨论。(在这篇文章的早期版本中,我将 SUT 称为“主要对象”,将协作者称为“次要对象”)

这种测试风格使用 **状态验证**:这意味着我们通过检查方法执行后 SUT 及其协作者的状态来确定执行的方法是否正常工作。正如我们将看到的,模拟对象能够实现不同的验证方法。

使用模拟对象的测试

现在,我将使用模拟对象来执行相同的行为。对于这段代码,我使用 jMock 库来定义模拟对象。jMock 是一个 Java 模拟对象库。还有其他模拟对象库,但这个库是该技术的创始人编写的最新库,因此它是一个很好的入门库。

public class OrderInteractionTester extends MockObjectTestCase {
  private static String TALISKER = "Talisker";

  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    Mock warehouseMock = new Mock(Warehouse.class);
    
    //setup - expectations
    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");

    //exercise
    order.fill((Warehouse) warehouseMock.proxy());
    
    //verify
    warehouseMock.verify();
    assertTrue(order.isFilled());
  }

  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    
    Mock warehouse = mock(Warehouse.class);
      
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());

    assertFalse(order.isFilled());
  }

首先关注 testFillingRemovesInventoryIfInStock,因为我在后面的测试中做了一些捷径。

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

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

一旦所有期望都到位,我就执行 SUT。在执行之后,我进行验证,验证有两个方面。我对 SUT 运行断言——与之前一样。但是,我还验证模拟对象——检查它们是否按预期被调用。

这里的关键区别在于我们如何验证订单在与仓库的交互中是否做了正确的事情。使用状态验证,我们通过对仓库状态进行断言来实现这一点。模拟对象使用 **行为验证**,我们改为检查订单是否对仓库进行了正确的调用。我们通过在设置期间告诉模拟对象预期什么,并在验证期间要求模拟对象验证自己来进行此检查。只有订单使用断言进行检查,如果方法没有改变订单的状态,则根本没有断言。

在第二个测试中,我做了一些不同的事情。首先,我使用 mock 方法在 MockObjectTestCase 中创建模拟对象,而不是使用构造函数。这是 jMock 库中的一个便利方法,这意味着我不需要在以后显式地调用 verify,使用便利方法创建的任何模拟对象都会在测试结束时自动验证。我也可以在第一个测试中这样做,但我希望更明确地展示验证,以展示使用模拟对象进行测试的工作原理。

第二个测试用例中的第二个不同之处是我使用 withAnyArguments 放宽了对期望的约束。这样做的原因是第一个测试检查了数字是否传递给仓库,因此第二个测试不需要重复测试的这一部分。如果订单的逻辑需要稍后更改,那么只有一个测试会失败,从而减轻了迁移测试的工作量。事实证明,我可以完全省略 withAnyArguments,因为它是默认值。

使用 EasyMock

有很多模拟对象库。我经常遇到一个库是 EasyMock,它有 Java 和 .NET 版本。EasyMock 也支持行为验证,但在风格上与 jMock 有几个区别,值得讨论。以下是熟悉的测试:

public class OrderEasyTester extends TestCase {
  private static String TALISKER = "Talisker";
  
  private MockControl warehouseControl;
  private Warehouse warehouseMock;
  
  public void setUp() {
    warehouseControl = MockControl.createControl(Warehouse.class);
    warehouseMock = (Warehouse) warehouseControl.getMock();    
  }

  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    
    //setup - expectations
    warehouseMock.hasInventory(TALISKER, 50);
    warehouseControl.setReturnValue(true);
    warehouseMock.remove(TALISKER, 50);
    warehouseControl.replay();

    //exercise
    order.fill(warehouseMock);
    
    //verify
    warehouseControl.verify();
    assertTrue(order.isFilled());
  }

  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    

    warehouseMock.hasInventory(TALISKER, 51);
    warehouseControl.setReturnValue(false);
    warehouseControl.replay();

    order.fill((Warehouse) warehouseMock);

    assertFalse(order.isFilled());
    warehouseControl.verify();
  }
}

EasyMock 使用记录/重放隐喻来设置期望。对于您想要模拟的每个对象,您都会创建一个控制对象和模拟对象。模拟对象满足辅助对象的接口,控制对象为您提供额外的功能。要指示期望,您需要调用该方法,并使用您期望的模拟对象上的参数。如果您想要返回值,则可以在此之后调用控制对象。完成设置期望后,您需要在控制对象上调用 replay——此时模拟对象完成记录并准备响应主要对象。完成后,您需要在控制对象上调用 verify。

似乎虽然人们最初看到记录/重放隐喻时往往会感到困惑,但他们很快就习惯了它。它比 jMock 的约束更具优势,因为您正在对模拟对象进行实际的方法调用,而不是在字符串中指定方法名称。这意味着您可以在 IDE 中使用代码完成,并且任何方法名称的重构都会自动更新测试。缺点是您无法拥有更宽松的约束。

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

模拟对象和存根的区别

当模拟对象首次被引入时,许多人很容易将它们与使用存根的常见测试概念混淆。从那时起,人们似乎更好地理解了它们之间的区别(我希望本文的早期版本有所帮助)。但是,要完全理解人们使用模拟对象的方式,重要的是要了解模拟对象和其他类型的测试替身。(“替身”?如果您对这个词不熟悉,请不要担心,再等几段,一切都会变得清晰。)

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

在我上面展示的两种测试风格中,第一种情况使用的是真实的仓库对象,第二种情况使用的是模拟仓库,当然这不是真实的仓库对象。使用模拟对象是在测试中不使用真实仓库的一种方法,但还有其他形式的非真实对象用于这种测试。

讨论此问题的词汇很快就会变得混乱——各种各样的词语都被使用:存根、模拟、伪造、虚拟。对于本文,我将遵循 Gerard Meszaros 的书中的词汇。这不是每个人都使用的词汇,但我认为这是一个很好的词汇,因为这是我的文章,我可以选择使用哪些词语。

Meszaros 使用 **测试替身** 作为通用术语,用于指代任何类型的假对象,用于代替真实对象进行测试。这个名字来源于电影中替身演员的概念。(他的目标之一是避免使用任何已经被广泛使用的名称。)然后 Meszaros 定义了五种特定的替身类型

  • **虚拟** 对象被传递,但实际上从未使用过。通常,它们只是用来填充参数列表。
  • **伪造** 对象实际上有工作实现,但通常会采取一些捷径,使其不适合生产(内存数据库 就是一个很好的例子)。
  • **存根** 为测试期间的调用提供预先录制好的答案,通常对测试中未编程的任何内容都不响应。
  • **间谍** 是记录一些信息的存根,这些信息基于它们被调用的方式。一种形式可能是记录发送了多少条消息的电子邮件服务。
  • 模拟对象 是我们在这里讨论的:预先编程了期望的对象,这些期望构成了对它们预期接收的调用的规范。

在这些类型的替身中,只有模拟对象坚持行为验证。其他替身可以使用,通常也使用状态验证。模拟对象在练习阶段的行为实际上与其他替身类似,因为它们需要让 SUT 相信它正在与真正的协作者进行通信 - 但模拟对象在设置和验证阶段有所不同。

为了更深入地探讨测试替身,我们需要扩展我们的示例。许多人只在真实对象难以使用时才使用测试替身。测试替身更常见的情况是,如果我们说如果我们无法完成订单,我们想发送一封电子邮件。问题是我们不想在测试期间向客户发送实际的电子邮件。因此,我们创建了电子邮件系统的测试替身,我们可以控制和操作它。

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

public interface MailService {
  public void send (Message msg);
}
public class MailServiceStub implements MailService {
  private List<Message> messages = new ArrayList<Message>();
  public void send (Message msg) {
    messages.add(msg);
  }
  public int numberSent() {
    return messages.size();
  }
}                                 

然后,我们可以对存根使用状态验证,如下所示。

class OrderStateTester...

  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    MailServiceStub mailer = new MailServiceStub();
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }

当然,这是一个非常简单的测试 - 只是发送了一条消息。我们没有测试它是否发送给了正确的人,或者内容是否正确,但这足以说明问题。

使用模拟对象,此测试看起来会有很大不同。

class OrderInteractionTester...

  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Mock warehouse = mock(Warehouse.class);
    Mock mailer = mock(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,但添加了额外的测试方法。

模拟对象始终使用行为验证,存根可以使用任何一种方式。Meszaros 将使用行为验证的存根称为测试间谍。区别在于替身运行和验证的方式,我会留给你自己去探索。

经典测试和模拟测试

现在我到了可以探索第二个二分法的阶段:经典 TDD 和模拟 TDD 之间的区别。这里最大的问题是何时使用模拟对象(或其他替身)。

经典 TDD 风格是尽可能使用真实对象,如果使用真实对象很麻烦,就使用替身。因此,经典 TDD 会使用真实的仓库和邮件服务的替身。替身的类型并不重要。

然而,模拟 TDD 实践者将始终对任何具有有趣行为的对象使用模拟对象。在这种情况下,仓库和邮件服务都是如此。

尽管各种模拟框架都是针对模拟测试设计的,但许多经典测试者发现它们对于创建替身很有用。

模拟风格的一个重要分支是行为驱动开发 (BDD)。BDD 最初由我的同事 Daniel Terhorst-North 开发,作为一种帮助人们更好地学习测试驱动开发的技术,其重点是 TDD 作为一种设计技术的运作方式。这导致将测试重命名为行为,以更好地探索 TDD 在思考对象需要做什么方面提供的帮助。BDD 采用模拟方法,但它在此基础上进行了扩展,包括其命名风格以及将分析集成到其技术中的愿望。我在这里不再赘述,因为与本文相关的唯一之处在于 BDD 是 TDD 的另一种变体,它倾向于使用模拟测试。我会留给你点击链接获取更多信息。

你有时会看到“底特律”风格用于“经典”,而“伦敦”风格用于“模拟”。这暗指 XP 最初是在底特律的 C3 项目中开发的,而模拟风格是由伦敦的早期 XP 采用者开发的。我还应该提到,许多模拟 TDD 测试者不喜欢这个术语,事实上,他们不喜欢任何暗示经典测试和模拟测试之间存在不同风格的术语。他们认为,经典测试和模拟测试之间没有必要区分。

在差异之间进行选择

在这篇文章中,我解释了两种差异:状态或行为验证 / 经典或模拟 TDD。在做出这些选择时,需要牢记哪些论点?我将从状态与行为验证的选择开始。

首先要考虑的是上下文。我们是在考虑简单的协作,例如订单和仓库,还是复杂的协作,例如订单和邮件服务?

如果这是一个简单的协作,那么选择就很简单。如果我是一个经典 TDD 测试者,我不会使用模拟对象、存根或任何类型的替身。我使用真实对象和状态验证。如果我是一个模拟 TDD 测试者,我使用模拟对象和行为验证。根本不需要做任何决定。

如果这是一个复杂的协作,那么如果我是一个模拟 TDD 测试者,就不需要做任何决定 - 我只使用模拟对象和行为验证。如果我是一个经典 TDD 测试者,那么我确实需要做出选择,但这并不重要。通常,经典 TDD 测试者会根据具体情况做出决定,为每种情况选择最简单的路线。

因此,正如我们所见,状态与行为验证通常不是一个重大的决定。真正的问题在于经典 TDD 和模拟 TDD 之间。事实证明,状态和行为验证的特征确实会影响这种讨论,而这将是我投入大部分精力的地方。

但在这样做之前,让我提一下一个边缘情况。偶尔你会遇到一些很难使用状态验证的东西,即使它们不是复杂的协作。一个很好的例子是缓存。缓存的全部意义在于你无法从其状态判断缓存命中还是未命中 - 这种情况,即使对于最硬核的经典 TDD 测试者来说,行为验证也是明智的选择。我相信在两个方向上都存在其他例外情况。

当我们深入研究经典/模拟选择时,需要考虑很多因素,因此我将它们分成几个粗略的组。

驱动 TDD

模拟对象来自 XP 社区,XP 的主要特点之一是强调测试驱动开发 - 其中系统设计是通过由编写测试驱动的迭代来演化的。

因此,模拟 TDD 测试者特别谈论模拟测试对设计的影响也就不足为奇了。他们特别提倡一种称为需求驱动开发的风格。使用这种风格,你通过编写系统外部的第一个测试来开始开发一个用户故事,将某个接口对象作为你的 SUT。通过思考对协作者的期望,你探索了 SUT 与其邻居之间的交互 - 有效地设计了 SUT 的出站接口。

一旦你的第一个测试运行起来,对模拟对象的期望就为下一步提供了规范,并为测试提供了起点。你将每个期望转化为对协作者的测试,并重复这个过程,一次将一个 SUT 逐步引入系统。这种风格也被称为自外而内,这是一个非常描述性的名称。它适用于分层系统。你首先开始使用模拟层编程 UI,然后编写底层的测试,逐步遍历系统,一次一层。这是一种非常结构化和受控的方法,许多人认为它有助于指导新手学习 OO 和 TDD。

经典 TDD 没有提供完全相同的指导。你可以使用类似的逐步方法,使用存根方法而不是模拟对象。要做到这一点,每当你需要协作者提供的东西时,你只需硬编码测试所需的响应,以使 SUT 正常工作。然后,一旦你用它实现了绿色,你就可以用适当的代码替换硬编码的响应。

但经典 TDD 也可以做其他事情。一种常见的风格是自中而外。在这种风格中,你选择一个功能,并决定为了使该功能正常工作,你需要在域中做什么。你让域对象执行你需要的操作,一旦它们正常工作,你就在上面添加 UI。这样做,你可能永远不需要伪造任何东西。许多人喜欢这样做,因为它首先关注域模型,这有助于防止域逻辑泄漏到 UI 中。

我应该强调,模拟 TDD 测试者和经典 TDD 测试者都是一次完成一个故事。有一种观点认为,应用程序是分层构建的,而不是在另一层完成之前开始构建一层。经典 TDD 测试者和模拟 TDD 测试者都倾向于具有敏捷背景,并更喜欢细粒度的迭代。因此,他们按功能而不是按层工作。

夹具设置

使用经典 TDD,你不仅要创建 SUT,还要创建 SUT 在响应测试时需要的全部协作者。虽然示例中只有几个对象,但实际测试通常涉及大量辅助对象。通常,这些对象是在每次运行测试时创建和销毁的。

然而,模拟测试只需要创建 SUT 及其直接邻居的模拟对象。这可以避免在构建复杂夹具方面的一些繁琐工作(至少在理论上是这样。我遇到过一些关于非常复杂的模拟设置的故事,但这可能是由于没有很好地使用工具造成的)。

在实践中,经典测试者倾向于尽可能地重复使用复杂的夹具。最简单的方法是将夹具设置代码放入 xUnit 的 setup 方法中。更复杂的夹具需要由多个测试类使用,因此在这种情况下,你创建特殊的夹具生成类。我通常称它们为对象母亲,这是基于早期 Thoughtworks XP 项目中使用的命名约定。在大型经典测试中,使用母亲是必不可少的,但母亲是需要维护的额外代码,对母亲的任何更改都可能对测试产生重大连锁反应。设置夹具也可能存在性能成本 - 尽管我没有听说过在正确执行时,这是一个严重的问题。大多数夹具对象创建起来很便宜,那些创建起来不便宜的对象通常会被复制。

因此,我听到过两种风格都指责对方工作量太大。模拟 TDD 测试者说,创建夹具需要花费很多精力,但经典 TDD 测试者说,这是可以重复使用的,但你必须在每次测试中创建模拟对象。

测试隔离

如果你在使用模拟测试的系统中引入了一个错误,它通常只会导致包含该错误的 SUT 的测试失败。然而,使用经典方法,任何客户端对象的测试也可能失败,这会导致在另一个对象的测试中使用有错误的对象作为协作者时出现失败。因此,高度使用对象的错误会导致整个系统出现一系列测试失败。

模拟 TDD 测试者认为这是一个重大问题;它会导致大量调试工作,以找到错误的根源并修复它。然而,经典 TDD 测试者并没有将此视为问题来源。通常,罪魁祸首很容易通过查看哪些测试失败来发现,开发人员可以判断其他失败是由根错误引起的。此外,如果你定期进行测试(正如你应该做的那样),那么你知道故障是由你最后编辑的内容引起的,因此找到故障并不困难。

这里可能一个重要的因素是测试的粒度。由于经典测试会练习多个真实对象,因此你经常会发现单个测试是多个对象的测试,而不是只有一个对象的测试。如果该集群跨越多个对象,那么找到错误的真正来源可能会更加困难。这里发生的事情是测试的粒度太粗了。

模拟测试不太可能遇到这个问题,因为惯例是模拟所有超出主要对象的物体,这明确表明需要对协作者进行更细粒度的测试。也就是说,使用过于粗粒度的测试并不一定意味着经典测试技术失败,而是在于没有正确地进行经典测试。一个好的经验法则是确保为每个类都进行细粒度的测试。虽然集群有时是合理的,但它们应该仅限于非常少的对象——不超过六个。此外,如果您发现由于过于粗粒度的测试而导致调试问题,您应该以测试驱动的方式进行调试,在调试过程中创建更细粒度的测试。

本质上,经典的 xunit 测试不仅仅是单元测试,也是小型集成测试。因此,许多人喜欢客户端测试可以捕获主对象测试可能遗漏的错误,特别是探测类交互的区域。模拟测试失去了这种特性。此外,您还存在对模拟测试的期望不正确的风险,导致单元测试运行正常,但掩盖了潜在的错误。

在这一点上,我应该强调,无论您使用哪种测试风格,都必须将其与跨越整个系统的更粗粒度的验收测试结合起来。我经常遇到一些项目,它们在使用验收测试方面比较晚,并且对此感到后悔。

将测试耦合到实现

当您编写模拟测试时,您正在测试 SUT 对供应商的出站调用,以确保它能够与供应商正确通信。经典测试只关心最终状态,而不是状态是如何得出的。因此,模拟测试与方法的实现更加耦合。更改对协作者的调用性质通常会导致模拟测试失败。

这种耦合导致了一些问题。最重要的是对测试驱动开发的影响。使用模拟测试,编写测试会让您思考行为的实现——事实上,模拟测试人员将此视为优势。然而,经典测试人员认为,重要的是只考虑外部接口发生的事情,并将所有实现考虑留到完成测试编写之后。

与实现的耦合也会干扰重构,因为与经典测试相比,实现更改更有可能导致测试失败。

模拟工具包的性质会加剧这种情况。通常,模拟工具会指定非常具体的调用方法和参数匹配,即使它们与特定测试无关。jMock 工具包的目标之一是在其期望规范方面更加灵活,以便在无关紧要的区域允许期望更加宽松,代价是使用字符串,这可能会使重构更加棘手。

设计风格

对我来说,这些测试风格最迷人的方面之一是它们如何影响设计决策。当我与两种类型的测试人员交谈时,我意识到两种风格鼓励的设计之间存在一些差异,但我相信我只是触及了皮毛。

我已经提到了处理层面的差异。模拟测试支持自外向内的方式,而更喜欢领域模型外风格的开发人员往往更喜欢经典测试。

在更小的层面上,我注意到模拟测试人员倾向于避免返回值的函数,而更喜欢作用于收集对象的函数。以从一组对象中收集信息以创建报告字符串的行为为例。一种常见的方法是让报告函数调用各个对象上的返回字符串的函数,并在临时变量中组装结果字符串。模拟测试人员更有可能将字符串缓冲区传递给各个对象,并让它们将各种字符串添加到缓冲区中——将字符串缓冲区视为收集参数。

模拟测试人员更多地谈论避免“火车事故”——getThis().getThat().getTheOther() 样式的方法链。避免方法链也称为遵循 Demeter 法则。虽然方法链是一种代码异味,但中间人对象充斥着转发方法的相反问题也是一种代码异味。(我一直觉得如果 Demeter 法则被称为 Demeter 建议,我会更舒服。)

面向对象设计中最难让人理解的事情之一是“告诉,不要问”原则,它鼓励您告诉对象做某事,而不是从对象中提取数据并在客户端代码中执行它。模拟测试人员说,使用模拟测试有助于促进这一点,并避免如今充斥着太多代码的 getter 碎片。经典测试人员认为,还有很多其他方法可以做到这一点。

基于状态的验证的一个公认问题是,它会导致创建查询方法只是为了支持验证。为测试而向对象的 API 添加方法从来都不是一件令人愉快的事情,使用行为验证可以避免这个问题。对此的反驳是,在实践中,这种修改通常很小。

模拟测试人员更喜欢角色接口,并断言使用这种测试风格会鼓励更多角色接口,因为每个协作都是单独模拟的,因此更有可能被转换为角色接口。因此,在我上面的示例中,使用字符串缓冲区生成报告,模拟测试人员更有可能发明一个在该领域有意义的特定角色,该角色可能由字符串缓冲区实现。

重要的是要记住,这种设计风格的差异是大多数模拟测试人员的关键动力。TDD 的起源是希望获得强大的自动回归测试,以支持演化设计。在此过程中,其实践者发现,先编写测试对设计过程有重大改进。模拟测试人员对什么是好的设计有一个强烈的想法,并且主要开发了模拟库来帮助人们开发这种设计风格。

那么我应该成为古典主义者还是模拟主义者?

我发现这个问题很难自信地回答。我个人一直都是老式的经典 TDD 支持者,到目前为止,我还没有看到任何改变的理由。我没有看到模拟 TDD 有任何令人信服的优势,并且担心将测试与实现耦合的后果。

当我观察模拟程序员时,这一点尤其令我印象深刻。我真的很喜欢编写测试时,您专注于行为的结果,而不是如何完成它。模拟测试人员一直在思考 SUT 将如何实现,以便编写期望。这对我的感觉真的很不自然。

我也有没有在除玩具以外的任何东西上尝试模拟 TDD 的劣势。正如我从测试驱动开发本身中学到的那样,在没有认真尝试的情况下,很难判断一种技术。我知道许多优秀的开发人员非常高兴并且是坚定的模拟测试支持者。因此,虽然我仍然是坚定的经典测试支持者,但我更愿意尽可能公平地陈述两种观点,以便您可以自己做出决定。

因此,如果模拟测试听起来对您有吸引力,我建议您尝试一下。如果您在模拟 TDD 旨在改进的一些领域遇到问题,那么尤其值得尝试。我认为这里有两个主要领域。一个是,如果您在测试失败时花费大量时间进行调试,因为它们没有干净地中断并告诉您问题出在哪里。(您也可以通过对更细粒度的集群使用经典 TDD 来改进这一点。)第二个领域是,如果您的对象没有包含足够的行为,模拟测试可能会鼓励开发团队创建更多行为丰富的对象。

最后的想法

随着人们对单元测试、xunit 框架和测试驱动开发的兴趣不断增长,越来越多的人开始使用模拟对象。许多时候,人们只是学习了一些关于模拟对象框架的知识,而没有完全理解支撑它们的模拟/经典分界线。无论您倾向于哪一边,我认为了解这种观点差异都是有用的。虽然您不必是模拟测试人员才能发现模拟框架很方便,但了解指导许多软件设计决策的思维方式是有用的。

本文的目的是指出这些差异,并阐述它们之间的权衡。模拟思维比我所能讨论的要多,尤其是它对设计风格的影响。我希望在未来几年,我们能看到更多关于这方面的文章,这将加深我们对在代码之前编写测试的迷人后果的理解。


进一步阅读

要全面了解 xunit 测试实践,请关注 Gerard Meszaros 即将出版的书籍(免责声明:它在我的系列中)。他还维护了一个网站,其中包含书籍中的模式。

要了解更多关于 TDD 的信息,首先要查看Kent 的书

要了解更多关于模拟测试风格的信息,最好的资源是Freeman & Pryce。作者负责管理mockobjects.com。特别是阅读优秀的 OOPSLA 论文。要了解更多关于行为驱动开发的信息,这是一种与 TDD 不同的分支,其风格非常模拟,从 Daniel Terhorst-North 的介绍开始。

您还可以通过查看jMocknMockEasyMock.NET EasyMock 的工具网站来了解更多关于这些技术的信息。(还有其他模拟工具,不要认为这个列表是完整的。)

XP2000 发表了最初的模拟对象论文,但现在已经过时了。

重大修订

2007 年 1 月 2 日:将最初基于状态与基于交互的测试区分拆分为两个:状态与行为验证以及经典与模拟 TDD。我还对各种词汇进行了更改,使其与 Gerard Meszaros 的 xunit 模式书籍保持一致。

2004 年 7 月 8 日:首次发布