0

Mocks 不是 Stubs

 3 weeks ago
source link: https://liqiang.io/post/mocks-are-not-stubs-ab86a491
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Mocks 不是 Stubs

@SOLUTION· 2024-04-20 12:28 · 88 min read

在清理收藏夹的时候看到这篇文章(Mocks Aren’t Stubs),所以就准备消化一下,不过总得来说收获不大,我精简成这么一段吧:

  • 不同的测试理念(我觉得没必要区分)
    • 经典主义:不使用 Mock,而是使用真实的外部依赖,例如 DB 操作等都是在真实的 DB 中进行;
    • 模拟主义:不使用真实的操作,而是使用 Mock 进行操作。
  • mock vs stub
    • mock:行为测试,验证代码逻辑是否执行了对应的行为;
    • stub:状态测试,验证模拟对象的状态是否正确,所以这里就需要记录状态
      • 模拟对象会保存测试代码设置的状态,然后测试行为就是验证这个 stub 对象的状态是否符合预期;

下面是我通过 ChatGPT 翻译过来的内容。


“‘Mock 对象’这个术语已经成为描述在测试中模拟真实对象的特例对象的流行用语。现在,大多数编程语言环境都有框架,使得创建 mock 对象变得容易。然而,很多人没有意识到的是,mock 对象只是测试中特例对象的一种形式,它们使不同风格的测试成为可能。在这篇文章中,我将解释 mock 对象是如何工作的,它们如何促进基于行为验证的测试,以及围绕它们的社区如何使用它们来发展不同的测试风格。

我第一次听到 ‘mock 对象’ 这个术语是在几年前的极限编程(XP)社区。自那时起,我越来越多地遇到 mock 对象。部分原因是因为许多 mock 对象的主要开发者在不同时间段曾是 Thoughtworks 的同事。部分原因是因为我在受 XP 影响的测试文献中越来越多地看到它们。

但我经常发现 mock 对象的描述存在问题。尤其是,我经常看到它们与存根相混淆,而存根是测试环境中的常见辅助工具。我理解这种困惑——我自己也曾一度认为它们相似,但与 mock 开发者的对话逐渐让我明白了一点点 mock 的概念。

这种区别实际上包括两个方面的差异。一方面,测试结果的验证方式存在不同:状态验证和行为验证的区别。另一方面,是测试和设计交互方式的完全不同的哲学,我在这里将其称为经典风格和 mockist 风格的测试驱动开发

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

这两种行为暗示了几项测试,这些看起来就像是非常传统的 JUnit 测试。”



  1. public class OrderStateTester extends TestCase {
  2. private static String TALISKER = "Talisker";
  3. private static String HIGHLAND_PARK = "Highland Park";
  4. private Warehouse warehouse = new WarehouseImpl();
  5. protected void setUp() throws Exception {
  6. warehouse.add(TALISKER, 50);
  7. warehouse.add(HIGHLAND_PARK, 25);
  8. }
  9. public void testOrderIsFilledIfEnoughInWarehouse() {
  10. Order order = new Order(TALISKER, 50);
  11. order.fill(warehouse);
  12. assertTrue(order.isFilled());
  13. assertEquals(0, warehouse.getInventory(TALISKER));
  14. }
  15. public void testOrderDoesNotRemoveIfNotEnough() {
  16. Order order = new Order(TALISKER, 51);
  17. order.fill(warehouse);
  18. assertFalse(order.isFilled());
  19. assertEquals(50, warehouse.getInventory(TALISKER));
  20. }

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

在设置阶段,我们将两种类型的对象组合在一起。Order 是我们正在测试的类,但为了让 Order.fill 正常工作,我们还需要一个 Warehouse 的实例。在这种情况下,Order 是我们专注于测试的对象。以测试为导向的人喜欢使用诸如 “object-under-test” 或 “system-under-test” 这样的术语来描述这种情况。尽管这些术语有些拗口,但因为它们被广泛接受,我就勉为其难地使用。按照 Meszaros 的说法,我将其称为系统在测试中,或者用缩写 SUT。

因此,对于这次测试,我需要 SUT(Order)和一个协作者(warehouse)。我需要 warehouse 有两个原因:一是为了让测试的行为能够正常工作(因为 Order.fill 调用了 warehouse 的方法),二是为了验证(因为 Order.fill 的结果之一可能是对 warehouse 的状态的潜在改变)。随着我们进一步探讨这个话题,你会看到我们将大量区分 SUT 和协作者。(在这篇文章的早期版本中,我将 SUT 称为“主要对象”,而协作者称为“次要对象”。)

这种测试风格使用 状态验证:这意味着我们通过在执行方法后检查 SUT 和其协作者的状态来确定执行的方法是否正确完成了其任务。正如我们将看到的,mock 对象提供了一种不同的验证方法。

使用 Mock 对象的测试

现在我将采用相同的行为,并使用 mock 对象。在这段代码中,我使用 jMock 库来定义 mock 对象。jMock 是一个 Java mock 对象库。市面上还有其他的 mock 对象库,但这个库是由这种技术的创始人编写的,是一个很好的起点。



  1. public class OrderInteractionTester extends MockObjectTestCase {
  2. private static String TALISKER = "Talisker";
  3. public void testFillingRemovesInventoryIfInStock() {
  4. //setup - data
  5. Order order = new Order(TALISKER, 50);
  6. Mock warehouseMock = new Mock(Warehouse.class);
  7. //setup - expectations
  8. warehouseMock.expects(once()).method("hasInventory")
  9. .with(eq(TALISKER),eq(50))
  10. .will(returnValue(true));
  11. warehouseMock.expects(once()).method("remove")
  12. .with(eq(TALISKER), eq(50))
  13. .after("hasInventory");
  14. //exercise
  15. order.fill((Warehouse) warehouseMock.proxy());
  16. //verify
  17. warehouseMock.verify();
  18. assertTrue(order.isFilled());
  19. }
  20. public void testFillingDoesNotRemoveIfNotEnoughInStock() {
  21. Order order = new Order(TALISKER, 51);
  22. Mock warehouse = mock(Warehouse.class);
  23. warehouse.expects(once()).method("hasInventory")
  24. .withAnyArguments()
  25. .will(returnValue(false));
  26. order.fill((Warehouse) warehouse.proxy());
  27. assertFalse(order.isFilled());
  28. }

首先关注 testFillingRemovesInventoryIfInStock,因为在后面的测试中我采用了一些简化方法。

一开始,设置阶段非常不同。首先,它分为两部分:数据和预期。数据部分设置我们要处理的对象,在这个意义上,它与传统的设置类似。不同之处在于创建的对象。SUT 仍然是订单。然而,协作者不再是仓库对象,而是一个模拟仓库——技术上是 Mock 类的实例。

设置的第二部分是在模拟对象上创建预期。这些预期指示了当 SUT 执行时,哪些方法应该在模拟对象上被调用。

一旦所有预期都到位,我执行 SUT。执行后,我进行验证,这有两个方面。我对 SUT 运行 assert,与之前类似。但我也验证模拟对象,检查它们是否根据预期被调用。

这里的关键区别在于我们如何验证订单在与仓库的交互中是否做了正确的事情。使用状态验证,我们通过对仓库状态进行 assert 来实现这一点。模拟对象使用行为验证,在这里,我们检查订单是否对仓库进行了正确的调用。我们通过在设置阶段告诉模拟对象预期,并在验证阶段要求模拟对象自行验证来实现这一点。只有订单通过 assert 进行检查,如果这个方法不改变订单的状态,那么就不需要任何 assert

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

第二个测试案例中的另一个不同之处是,我通过使用 withAnyArguments 放宽了对预期的限制。这是因为第一个测试检查了号码是否传递给仓库,因此第二个测试不需要重复这个测试元素。如果以后订单的逻辑需要更改,那么只会有一个测试失败,这可以减少测试迁移的工作量。事实证明,我完全可以省略 withAnyArguments,因为这是默认设置。

使用 EasyMock

市面上有许多模拟对象库。其中一个我常用的是 EasyMock,既有 Java 版本,也有 .NET 版本。EasyMock 也支持行为验证,但在风格上与 jMock 有一些差异,值得讨论。以下是我们熟悉的测试:



  1. public class OrderEasyTester extends TestCase {
  2. private static String TALISKER = "Talisker";
  3. private MockControl warehouseControl;
  4. private Warehouse warehouseMock;
  5. public void setUp() {
  6. warehouseControl = MockControl.createControl(Warehouse.class);
  7. warehouseMock = (Warehouse) warehouseControl.getMock();
  8. }
  9. public void testFillingRemovesInventoryIfInStock() {
  10. //setup - data
  11. Order order = new Order(TALISKER, 50);
  12. //setup - expectations
  13. warehouseMock.hasInventory(TALISKER, 50);
  14. warehouseControl.setReturnValue(true);
  15. warehouseMock.remove(TALISKER, 50);
  16. warehouseControl.replay();
  17. //exercise
  18. order.fill(warehouseMock);
  19. //verify
  20. warehouseControl.verify();
  21. assertTrue(order.isFilled());
  22. }
  23. public void testFillingDoesNotRemoveIfNotEnoughInStock() {
  24. Order order = new Order(TALISKER, 51);
  25. warehouseMock.hasInventory(TALISKER, 51);
  26. warehouseControl.setReturnValue(false);
  27. warehouseControl.replay();
  28. order.fill((Warehouse) warehouseMock);
  29. assertFalse(order.isFilled());
  30. warehouseControl.verify();
  31. }
  32. }

EasyMock 使用记录/重放的隐喻来设置预期。对于每个您想要模拟的对象,您创建一个控制和模拟对象。模拟对象满足次要对象的接口,控制提供额外的功能。要表示预期,您调用模拟对象上您期望的方法,带有相应的参数。如果您想要返回值,则接着调用控制对象的方法。一旦完成设置预期,您在控制对象上调用 replay,此时模拟对象完成录制并准备好响应主要对象。测试完成后,您在控制对象上调用 verify

虽然人们在首次接触记录/重放的隐喻时通常会感到困惑,但他们很快就习惯了。与 jMock 的约束相比,它有一个优势,即您是对模拟对象进行实际的方法调用,而不是在字符串中指定方法名称。这意味着您可以在 IDE 中使用代码补全,并且对方法名称进行的任何重构都会自动更新测试。但缺点是您无法拥有较松散的约束。

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

模拟与存根之间的区别

最初引入时,很多人容易将模拟对象与使用存根的常见测试概念混淆。从那时起,人们似乎更好地理解了两者之间的区别(我希望本文的早期版本对此有所帮助)。然而,为了完全理解人们使用模拟对象的方式,了解模拟对象和其他类型的测试替身是很重要的。(“替身”?如果这个术语对您来说是新的,请稍等几段,一切都会清晰。)

当您进行这样的测试时,您一次专注于软件的一个元素——因此通常称为单元测试。问题是,要使一个单元正常工作,您通常需要其他单元——因此在我们的例子中需要某种仓库。

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

谈论这个问题的术语很快变得混乱——使用各种各样的词:存根、模拟、伪造、虚拟。在这篇文章中,我将按照 Gerard Meszaros 的书中的术语。尽管这不是每个人都使用的术语,但我认为这是一个好的术语,因为这是我的文章,我可以选择使用哪个词。

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

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

在这些替身中,只有模拟对象坚持使用行为验证。其他替身可以,通常也确实使用状态验证。在执行阶段,模拟对象确实像其他替身一样,因为它们需要让 SUT 相信它在与真实协作者对话——但模拟对象在设置和验证阶段有所不同。

为了进一步探讨测试替身,我们需要扩展我们的例子。许多人只有在真实对象难以处理时才使用测试替身。更常见的测试替身情况是如果我们说在无法填充订单时想要发送电子邮件。这种情况下的问题是我们不希望在测试期间向客户发送实际的电子邮件。因此,我们创建了我们的电子邮件系统的测试替身,一个我们可以控制和操作的系统。

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



  1. public interface MailService {
  2. public void send (Message msg);
  3. }
  4. public class MailServiceStub implements MailService {
  5. private List<Message> messages = new ArrayList<Message>();
  6. public void send (Message msg) {
  7. messages.add(msg);
  8. }
  9. public int numberSent() {
  10. return messages.size();
  11. }
  12. }
  13. We can then use state verification on the stub like this.
  14. class OrderStateTester...
  15. public void testOrderSendsMailIfUnfilled() {
  16. Order order = new Order(TALISKER, 51);
  17. MailServiceStub mailer = new MailServiceStub();
  18. order.setMailer(mailer);
  19. order.fill(warehouse);
  20. assertEquals(1, mailer.numberSent());
  21. }

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

如果使用模拟对象,这个测试会看起来完全不同。



  1. class OrderInteractionTester...
  2. public void testOrderSendsMailIfUnfilled() {
  3. Order order = new Order(TALISKER, 51);
  4. Mock warehouse = mock(Warehouse.class);
  5. Mock mailer = mock(MailService.class);
  6. order.setMailer((MailService) mailer.proxy());
  7. mailer.expects(once()).method("send");
  8. warehouse.expects(once()).method("hasInventory")
  9. .withAnyArguments()
  10. .will(returnValue(false));
  11. order.fill((Warehouse) warehouse.proxy());
  12. }
  13. }

在这两种情况下,我都使用了测试替身,而不是实际的邮件服务。不同之处在于,存根使用状态验证,而模拟对象使用行为验证。

为了在存根上使用状态验证,我需要在存根上添加一些额外的方法来帮助进行验证。因此,存根实现了 MailService 接口,但增加了额外的测试方法。

模拟对象总是使用行为验证,而存根可以两者兼用。Meszaros 将使用行为验证的存根称为测试间谍。区别在于测试替身的运行和验证方式的不同,我会留给你自行探索。

经典和模拟主义测试

现在,我可以探讨第二个差异:经典 TDD 和模拟主义 TDD 之间的区别。这里的关键问题是 何时 使用模拟对象(或其他测试替身)。

经典 TDD 风格的特点是:如果可能,使用真实对象,只有在使用真实对象不便的情况下才使用测试替身。因此,经典 TDD 的从业者可能会使用真实的仓库,并为邮件服务使用替身。至于用哪种替身,问题并不大。

然而,模拟主义 TDD 从业者总是会使用模拟对象来处理任何具有有趣行为的对象。在这种情况下,仓库和邮件服务都会使用模拟对象。

虽然各种模拟框架是为模拟主义测试设计的,但许多经典 TDD 的从业者发现它们在创建测试替身时也非常有用。

模拟主义风格的一个重要分支是行为驱动开发 (BDD)。BDD 最初由我的同事 Daniel Terhorst-North 开发,旨在通过关注 TDD 如何作为一种设计技术来帮助人们更好地学习 TDD。这导致了将测试重命名为行为,以更好地探索 TDD 在帮助思考对象需要做什么方面的作用。BDD 采用模拟主义的方式,但在命名风格和将分析整合到技术中方面有所扩展。在这里,我不会深入讨论,因为与本文的相关性只是 BDD 是 TDD 的另一种变体,倾向于使用模拟主义测试。如需更多信息,我建议查看相关链接。

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

在不同风格之间的选择

在本文中,我解释了两个差异:状态验证和行为验证/经典或模拟主义 TDD。当在它们之间做出选择时,有哪些需要考虑的论点?让我从状态验证和行为验证的选择开始。

首先要考虑的是上下文。我们是否在考虑一种简单的协作关系,例如订单和仓库,还是一种困难的协作关系,例如订单和邮件服务?

如果这是一个简单的协作关系,那么选择就很简单。如果我是经典 TDD 的从业者,我不会使用模拟、存根或任何形式的测试替身,而是使用真实对象和状态验证。如果我是模拟主义 TDD 的从业者,我会使用模拟对象和行为验证,没有任何选择余地。

如果是困难的协作关系,那么如果我是模拟主义者,则没有选择,我只会使用模拟对象和行为验证。如果我是经典主义者,那么我确实有选择,但无论使用哪种方法都不会是个大问题。经典主义者通常会根据具体情况决定,选择最简单的方法。

因此,如我们所见,状态验证与行为验证之间的选择通常不是一个重要决定。真正的问题是经典和模拟主义 TDD 之间的选择。事实证明,状态和行为验证的特性确实会影响这个讨论,这也是我将集中精力的地方。

但在此之前,我想引入一个极端案例。有时你会遇到即使不是困难协作关系也很难进行状态验证的情况。缓存是一个很好的例子。缓存的全部意义在于你无法从其状态中判断缓存是否命中或未命中——这是一个即使是核心经典 TDD 的从业者也会选择行为验证的情况。我确信还有其他方向的例外。

在深入研究经典/模拟主义的选择时,有许多因素需要考虑,因此我将它们分成几个大致的组。

驱动 TDD

模拟对象源自 XP 社区,而 XP 的一个主要特征是其强调测试驱动开发——通过编写测试来推动系统设计的迭代。

因此,模拟主义者特别谈论模拟主义测试对设计的影响。特别是,他们提倡一种名为需求驱动开发的风格。采用这种风格,你首先通过为系统的外部编写第一个测试来开始开发用户故事,让一些接口对象成为你的 SUT。通过考虑对协作者的期望,你可以探索 SUT 与其邻居之间的交互,从而有效地设计 SUT 的外部接口。

一旦你的第一个测试运行成功,模拟对象上的期望为下一步提供了规范,并成为测试的起点。你将每个期望转化为协作者上的一个测试,然后逐个 SUT 继续这个过程。这种风格也被称为自外而内,这个名字非常具有描述性。它适用于分层系统。你首先从 UI 开始编程,在下面使用模拟层。然后你为较低层编写测试,逐层穿过系统。这是一种非常结构化和受控的方法,许多人认为这对指导面向对象和 TDD 的新手很有帮助。

经典 TDD 没有提供完全相同的指导。你可以采用类似的方法,使用存根方法而不是模拟对象来实现。要做到这一点,每当你需要协作者的帮助时,你只需硬编码测试所需的确切响应以使 SUT 工作。然后,一旦测试通过,你将硬编码的响应替换为合适的代码。

但经典 TDD 也可以做其他事情。一种常见的风格是自中而外。在这种风格中,你选择一个功能,并决定为使这个功能正常运行需要什么。在这个过程中,你可能不需要模拟任何东西。很多人喜欢这种方式,因为它首先将重点放在领域模型上,这有助于防止领域逻辑泄漏到 UI 中。

我应该强调,经典主义者和模拟主义者都是逐个故事进行测试。也有一种观点认为应用程序应该一层一层地构建,在开始一层之前完成另一层。然而,经典主义者和模拟主义者通常都有敏捷背景,喜欢细粒度的迭代。因此,他们是逐个功能而不是逐层进行工作的。

固定装置设置

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

然而,模拟主义测试只需要创建 SUT 和其直接邻居的模拟对象。这可以避免在构建复杂的固定装置方面的一些工作(至少在理论上。我遇到过相当复杂的模拟设置,但这可能是由于没有正确使用工具。)

实际上,经典测试者倾向于尽可能重复使用复杂的固定装置。最简单的方法是将固定装置设置代码放入 xUnit 设置方法中。更复杂的固定装置需要由多个测试类使用,因此在这种情况下,你需要创建专门的固定装置生成类。我通常称这些为对象母亲,这是基于一个早期 ThoughtWorks XP 项目中使用的命名约定。在更大的经典测试中,使用母亲是必不可少的,但这些母亲是需要维护的额外代码,母亲的任何更改都可能对测试产生重大影响。设置固定装置可能还会有性能成本——尽管如果正确执行,我还没有听说过这是一个严重问题。大多数固定装置对象创建起来成本较低,那些不便宜的通常会被模拟。

因此,我听到两种风格的支持者互相指责对方过于繁琐。模拟主义者说创建固定装置需要很多努力,而经典主义者说这部分是可以重复使用的,但你必须在每个测试中创建模拟对象。

当你在使用Mockist测试的系统中引入一个bug时,通常只会导致包含bug的测试用例失败。而在经典测试中,任何客户对象的测试都可能会失败,这可能会导致错误对象在其他对象测试中被用作协作者,从而导致整个系统中的失败。结果,在高度使用的对象出现故障时,会导致整个系统的测试用例失败。

Mockist测试者认为这是一个主要问题;它会导致大量的调试以找到错误的根源并进行修复。然而,经典测试者不认为这是个问题。通常,通过查看哪些测试失败,错误的罪魁祸首很容易找到,而且开发人员可以判断其他失败是由根本错误引起的。此外,如果你经常测试(这是必要的),那么你就会知道损坏是由你最近编辑的内容引起的,因此找到故障并不困难。

这里可能有一个显著的因素是测试的粒度。由于经典测试会测试多个实际对象,你经常会发现单个测试是多个对象的主要测试,而不仅仅是一个。如果这个集群跨越许多对象,那么找到错误的真正来源可能会更加困难。这里的问题是测试的粒度过大。

Mockist测试可能较不容易受到这个问题的影响,因为惯例是将所有主对象之外的对象进行Mock处理,这使得明确需要为协作者创建更细粒度的测试。然而,这种过于粗粒度的测试并不一定是经典测试技巧的失败,而是经典测试没有正确执行的结果。一条良好的经验法则是确保为每个类分离出细粒度的测试。虽然集群有时是合理的,但它们应该仅限于极少数对象——不超过半打。此外,如果由于过于粗粒度的测试导致调试问题,你应该通过在调试过程中创建更细粒度的测试来进行测试驱动。

本质上,经典xunit测试不仅是单元测试,而且还是迷你集成测试。结果,许多人喜欢这样一个事实,即客户测试可能会捕捉到主要测试中遗漏的错误,特别是在探测类之间的交互区域。Mockist测试会失去这一特性。此外,你还可能面临Mockist测试中的期望值可能不正确,从而导致单元测试运行正常但掩盖了固有错误。

在这个阶段,我应该强调,无论使用哪种风格的测试,都必须将其与更粗粒度的验收测试相结合,以便在整个系统上操作。我经常遇到一些项目在使用验收测试时滞后,并对此表示后悔。

测试与实现的耦合

当你编写Mockist测试时,你是在测试SUT的输出调用,以确保它能够正确与其供应商进行通信。经典测试只关心最终状态——而不关心状态是如何得来的。Mockist测试因此更容易与方法的实现耦合。改变与协作者的调用方式通常会导致Mockist测试失败。

这种耦合带来了几个问题。最重要的是对测试驱动开发的影响。在Mockist测试中,编写测试会让你考虑行为的实现——Mockist测试者认为这是一个优势。然而,经典测试者认为重要的是只考虑从外部接口发生了什么,并在编写测试之后才能考虑实现。

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

这可能会因为Mock工具包的性质而恶化。通常,Mock工具会指定非常具体的方法调用和参数匹配,即使它们与这个特定测试无关。jMock工具包的一个目标是允许在不相关的区域放松期望,从而降低了重构的复杂性。

对我来说,这些测试风格最令人着迷的方面之一是它们如何影响设计决策。在与这两类测试者交谈时,我意识到这两种风格在鼓励的设计方面存在一些差异,但我肯定我只是触及了表面。

我已经提到了处理分层方式的差异。Mockist测试支持一种自外而内的方法,而喜欢域模型方式的开发人员则更倾向于经典测试。

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

Mockist测试者确实更喜欢避免“火车残骸”——一种风格的getThis().getThat().getTheOther()。避免方法链也被称为遵循Demeter法则。虽然方法链是一种气味,但充满转发方法的中间人对象是另一个气味。我总是觉得如果Demeter法则被称为Demeter的建议,我会更舒服。

人们在OO设计中最难理解的一件事是 “告诉,不要问” 原则,它鼓励你告诉一个对象做某事,而不是从对象中获取数据以在客户端代码中进行操作。Mockist测试者说,使用Mockist测试有助于促进这一点并避免如今代码中充斥的getter。经典测试者认为有很多其他方法可以做到这一点。

使用基于状态的验证的一个公认问题是,它可能会导致创建查询方法以支持验证。将方法仅添加到对象的API中以进行测试是不舒服的,而使用行为验证可以避免这个问题。对此的反驳是,实际上这样的修改通常是次要的。

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

记住,这种设计风格的差异是大多数Mockist的重要动力。TDD的起源在于一种希望获得强大的自动回归测试以支持进化设计。在这个过程中,实践者们发现首先编写测试对设计过程有显著改善。Mockist们对什么样的设计是好设计有着强烈的想法,并主要开发Mock库来帮助人们开发这种设计风格。

那么我应该是经典主义者还是Mockist?

这是个我难以自信回答的问题。就我个人而言,我一直是一个传统的经典TDDer,到目前为止,我没有看到任何改变的理由。我看不出Mockist TDD的任何显著好处,而且我担心将测试与实现耦合的后果。

这在我观察到Mockist程序员时特别引起了我的注意。我非常喜欢在编写测试时,你专注于行为的结果,而不是它是如何实现的。Mockist不断思考SUT将如何实现以编写期望值。这对我来说感觉非常不自然。

我还缺乏在除玩具以外的东西上尝试Mockist TDD的经验。正如我从测试驱动开发本身学到的,很难在没有认真尝试的情况下判断一种技术。我知道很多好开发者是非常满意并且坚定的Mockists。因此,虽然我是坚定的经典主义者,我还是更愿意公平地呈现这两种观点,以便你自己做出判断。

因此,如果Mockist测试对你来说很有吸引力,我建议你尝试一下。如果你在Mockist TDD试图改进的一些领域遇到问题,特别值得尝试。我认为这里有两个主要领域。第一个是如果你花费大量时间进行调试,因为测试失败并没有清楚地告诉你问题出在哪里。另一个领域是,如果你的对象缺乏足够的行为,Mockist测试可能会鼓励开发团队创建更多富有行为的对象。

随着对单元测试、xunit框架和测试驱动开发的兴趣不断增长,越来越多的人开始使用Mock对象。很多时候,人们了解了一点Mock对象框架,却没有充分理解Mockist/经典主义者的分歧。无论你倾向于那个分歧的哪一边,我认为了解这种不同观点的区别是有用的。虽然你不需要成为Mockist就能找到Mock框架的用处,但了解许多设计决策背后的指导思想是有用的。

这篇文章的目的是指出这些差异,并概述它们之间的权衡。在Mockist思维上还有更多东西我没有时间深入讨论,特别是它对设计风格的影响。我希望在接下来的几年中我们能看到更多相关内容的写作,并加深我们对在代码之前编写测试这一有趣的结果的理解。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK