cd ../

Mock并非Stub(翻译)

原文地址:Mocks Aren’t Stubs(2007-01-02)

原文作者:Martin Fowler

译者注:本文中讨论的两个重要概念mock和stub在下文中为了防止引起歧义,都直接使用而不翻译成中文。其中mock的英语中是模拟的意思;而stub是桩的意思。

目录:

mock对象(用于在测试中描述用例的模拟对象)已经变成了一个流行的概念。现在许多语言都有一些用来构造mock对象的框架。然而,我们常常忽视了,mock对象只是许多用于不同形式测试的测试对象中一种。在这篇文章中,我会解释mock对象是如何工作的、如何支持基于行为验证的测试的、以及社区中是如何用它来进行不同形式的测试。

几年前,我在XP(极限编程)社区中第一次听说mock对象这个概念。之后,我就越来越多地接触和使用mock对象,或许是因为许多这方面的顶尖开发者陆续地加入ThoughtWorks成为我的同事,又或许是因为我接触到越来越多的受XP影响的测试。

但是,我却常常看到mock对象并没有被恰当地描述。特别是我发现mock经常会和stub(一个通常被用于测试环境的帮助类)这个概念混淆。我现在才理解了为什么会被混淆,因为我也曾经有一段时间把他们当成类似的东西。在与mock开发者的交谈后,我茅塞顿开。

它们之间有两个不同点:

  • 测试结果的验证方式的不同:一个是状态验证,另一个是行为验证;
  • 测试与设计哲学的完全不同:我将他们分别称之为,一个是传统型的,另一个是mockist(mock风格的)的

(在这篇文章的早期版本中,我把上述的两个区别合并成了一个。在那之后,我改进了我的理解,所以更新了这篇日志。如果你没有读过我之前的版本,那就忽略我的唠叨吧。我在写这篇文章时就当作未曾写过之前的版本。但是如果你熟悉之前的那个版本的话,就请注意一下我把 基于状态的测试基于交互的测试 换成了 状态/行为验证 。同时我也参照了Gerard Meszaros写的xUnit模式中所使用的词汇。)

常规测试

我会通过一个简单的例子来描述这两种风格的测试。(例子是使用Java写的,但是这些原则是适用于任何面向对象编程语言的)在这个例子中,我们有一个仓库(warehouse)和从仓库中得到的一个订单(order)对象。订单很简单,包含产品(product)和数量(quantity)两个属性。仓库中保存了不同产品的存货清单。当我们向仓库获取订单的时候有两种可能的响应:如果仓库中产品量充足,则填写订单;如果不充足,那么订单将不会被填写,仓库也不会发生任何变动)。

上面的那两个行为对应一些测试,看上去是十分传统的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)、执行(exercise)、验证(verify)、关闭(teardown)。在这个实例中,启动的步骤部分是在 setUp 方法中完成的(构造仓库),还有一个部分是在测试方法中完成的(构造订单)。order.fill调用是执行步骤,其中,对象以我们要求的测试方式执行。然后,断言(assert)语句是验证步骤,它检查了执行步骤中的方法是否正确地执行了那个任务。在这个实例中没有显式的关闭阶段,垃圾回收器(garbage collector)帮我们隐式地执行了这个步骤。

在启动阶段,有两种对象被放在一起使用。Order 类是我们测试的类,但是为了让 order.fill 能够工作,我们需要一个 Warehouse 的实例。在这个情况下 Order 是我们测试所关注的。 面向测试的人喜欢在这里使用 测试中的对象 (object-under-test)或者 测试中的系统 (system-under-test)这样的术语来命名它。无论使用其中的哪个术语都说起来十分拗口,但是它们都是被广泛接受的,所以我会勉为其难使用它们。我将参照Meszaros,使用 测试中的系统 这个术语,或者它的缩写SUT。

所以,这个测试我们需要SUT(订单)和一个协作对象(仓库)。我需要仓库有以下两个原因:其一是为了让测试能够运行起来(因为 Order.fill 调用了仓库的方法);其次我需要它来做验证(由于 Order.fill 返回的其中的一个结果可能造成仓库的状态发生变化)。当我们继续深究这个话题时,你会发现我们很大程度上区分了SUT和协作对象这两个概念。(在早期的这篇文章中,我把SUT写作 首要对象 (primary object),把协作对象写作 辅助对象 (secondary objects))

上述的这种测试的风格使用了 状态验证 :我们通过在方法执行后SUT与其协作对象返回的状态来判断被执行的方法是否工作正常。接下来你会看到,mock对象会使用一种不同的方法来做验证。

使用mock对象测试

现在我们使用mock对象来做相同的测试。下面的这段代码使用了jMock类库来定义mock对象。jMock是一个Java的mock对象类库。当然也有其他的一些mock对象类库,但是这个类库比较新颖,并且是这个测试技术的发起人编写的,所以是个不错的选择。

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 这个方法,我之后会多次提到这个方法。

起先,启动阶段是十分不同的。一个启动包含了两个部分:数据与预期(expectation)。数据部分建立了我们关注的需要运行的对象,十分类似于传统的启动。区别在于对象是如何被创建的。SUT(订单)的创建并没有改变,然而协作对象并不是一个仓库对象,而是一个mock过的仓库(从技术上讲,是一个 Mock 类的实例)。

启动的第二部分创建了mock对象的预期。这些预期指出了当执行SUT时,哪些方法应该在mock对象被调用时被执行。

当所有的预期就位后,我执行了SUT。在执行之后,我再做验证,其中包含两个方面:其一几乎和之前的那样,我对SUT做了断言;其二,我通过检查了预期中所描述的调用验证了mock。

这里,关键的不同点在于我们还验证了它与仓库的交互是否满足预期。之前,我们用断言仓库的状态来做状态验证。与之不同的是,mock使用了行为验证来检查订单是否正确地调用了仓库。在启动阶段,我们告诉了mock哪些是预期被执行的,并且要求mock在执行阶段验证自己是否正确。最后,我们只有对订单对象做了断言,如果这个方法并不改变状态,我们这里就根本不需要断言。

在第二个测试中,有许多不同之处。首先,我用不同的方式创建了mock,调用了 MockObjectTestCase 的静态方法 mock,而不是使用 new Mock 这样的构造方法。这在jMock库中是一个十分方便的方法,我可以之后不用显式地调用 verify,任何一个通过这种方法建立的mock都会在测试的最后自动地做一次验证。当然我可以在第一个测试也这样使用,但是我希望更显式地表现出验证过程,从而来表现mock是如何工作的。

第二个不同之处在于我使用了 withAnyArguments 并没有限制预期的参数。这么写的理由是第一个测试中已经检查了传递到仓库的参数数目,因此第二个测试就不需要重复这个测试点了。如果订单的逻辑之后发生了改变,那么只会造成其中的一个测试不通过,这样使得测试的迁移变得较为简单。当然了,我甚至可以不写 withAnyArguments,因为这是默认的。

使用EasyMock

mock对象的类库有很多。其中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使用了 记录(record) / 重播 (replay)的方式来表示建立预期。对于每一个你想要mock的对象,你创建一个控制对象(control)和一个mock对象。mock对象满足了辅助对象(译者注:这里就是协作对象)的接口,控制对象提供了额外的特性。为了指出一个预期,你通过调用这个方法,并且传递你所预期的参数值。如果你想要一个返回值,可以在这之后紧跟一个控制对象的调用。一旦你完成了预期的设置,你调用控制对象的重播方法,此时mock对象就完成了记录,并且准备好返回结果给首要对象(译者注:这里就是上面所说的SUT)了。

貌似,尽管第一次看到这种记录/重播的形式会很折磨,但是却会很快地习惯了。相比jMock的约束,这里的优势在于你可以直接调用实际的方法而不是传递一个方法名称的字符串。这就意味着你可以用你的IDE帮你做代码补全(code-completion),还有你重构方法名的同时也会更新测试。但是反过来,你不能获得像jMock那样较为宽松的约束。

现在,jMock的开发者正在使用另一种技术来使你能够像EasyMock一样直接调用方法。

mock和stub的区别

当我们同时引入这两个概念时,人们很容易把mock对象和普通测试概念中的stub混淆。当然通过上文的描述,貌似能更好地区分两者(我希望之前的版本也能够帮助你们区分)。然而为了完全理解使用mock的方式,理解mock以及其他形式的测试替身(test doubles)是十分重要的。(“替身”?如果对你是一个新概念的话不用担心,在读完之后的几个段落后你就清楚了)

当你像上述的方法那样测试的话,你每次只关注软件中的一个元素(单元测试的基本概念)。问题是,为了让一个单元能够工作,你通常需要其他的单元,因此在我们的例子中需要一种仓库对象。

在上述的两种风格的测试中,第一个使用了真实仓库的对象,而第二个使用了mock过的仓库对象(当然它不是一个真实的仓库)。使用mock是一种在测试中不使用真实仓库的方式,但是还有其他各种形式的测试也同样可以是用非真实的对象。

接下里我们将提及的词汇会变得十分杂乱,什么stub、mock、fake(假对象)、dummy(傀儡对象)都用上了。在这篇文章中,我会是使用Gerard Meszaros书中所使用的词汇。这些词汇并不是所有人都这么使用的,但是我认为这样使用还不错,毕竟我得为我的文章挑选使用的词汇。

Meszaros使用了测试替身来指代那些在测试中用于替代真实对象的伪装对象。这个词取自电影中“特技替身演员”这个概念。(之所以这么取,目的是为了与现有广为使用的概念避免冲突)Meszaros还定义了四种特定的替身类型:

  • dummy对象虽然被传递进方法,但是它没有被真正地使用。通常它们只是被用来填充参数列表。
  • fake对象实际上是有具体实现的,但是实现中做了些捷径,使它们不能应用与生产环境(举个典型的例子:内存数据库)
  • stub提供了测试期间预先设定的返回值,通常在测试中不对外部其他的调用做任何响应。stub同样也可以记录调用,例如一个邮件网关的stub会记录它“寄出去”的信息,或者可能只记录“寄出去”多少条。
  • mock就是我们这边讨论的:对象中预先编写了特定场景下接收调用的预期。

在上述替身中,只有mock是被用来做行为验证的。而其他的都通常被用来做状态验证的。实际上,mock对象在执行阶段和其他替身一样,都让SUT相信它所使用的是一个“真”的协作对象,但是mock对象在启动和验证阶段是不同的。

为了加深对于测试替身的理解,我们需要改造一下我们的例子。许多人只有当真实地对象很难掌控时才会使用一个替身。举个常见的例子,我们想要当填写订单失败后发送一个邮件。问题是,我们在测试时不想真正地给客户发送邮件。所以,我们为我们的邮件系统创建了一个可以控制调整的测试替身。

现在我们可以开始发现mock和stub的不同了。如果我们对于这个邮件行为要编写一个简单stub,那代码将会是这个样子。

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();
  }
}    

接着,我们可以对这个stub做这样的状态验证。

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());
  }

当然,这是一个十分简单的测试,只发送了一条信息。我们根本没有测试信息的收件人、内容是否正确,但这只是是用来呈现观点的。

使用mock来测试会变得有点不同。

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());
  }
}

在这两个例子中,我都用到了测试替身,而不是使用真的邮件服务。区别在于,stub使用了状态验证而mock使用了行为验证。

为了能让stub做状态验证,我需要在stub上添加额外的方法来帮助验证( int numberSent() )。所以,sub实现了MailService接口的同时还添加了额外的测试方法。

mock对象总是使用行为验证,stub可以用其他方式来实现。Meszaros把这种使用行为测试的stub称之为测试间谍(Test Spy)。区别在于实际运行时这个测试替身是如何运作与验证的,读者们可以自行探究,我就不在这里展开了。

经典测试与mockist测试

现在我们可以开始讨论第二个对于测试的分类了:经典测试驱动开发与mockist测试驱动开发。这里,最大的问题是什么时候应该用mock(或者其他测试替身)。

当使用经典的测试驱动开发时,我们会尽可能地使用真实的对象,除非真实的对象太难被直接使用。所以,一个经典测试驱动的开发者会使用一个真实的仓库对象,以及一个邮件服务的替身。至于使用哪一种替身并不重要。

然而,一个mockist的实践者对于那些具有“有趣”的行为的对象,总是使用mock。在相同的情况下,他会同时mock仓库以及邮件服务。

虽然各式的mock框架都是为mockist测试设计的,但是许多经典测试驱动开发者会发现,使用这些框架可以帮助他们构造测试替身。

行为驱动开发(BDD)是mockist风格的一个重要衍生。BDD最初是由我的同事Dan North开发的,用来更好地帮助人们学习测试驱动开发,并关注于把TDD作为一种设计技术。之所以把它重命名为“行为”,是为了更好地体现出TDD是如何帮助我们考虑一个对象所应该做的工作。BDD遵循mockist风格,但同时扩展了这种风格,扩展了命名的风格,以及要求在技术中集成分析。我不会再次继续深究这个概念,可以点击那个链接了解更多相关信息。

如何选择

在这篇文章中,我已经解释了一对区别:状态或是行为的验证 / 经典或是mockist的测试驱动开发。那我应该在它们中做出选择时注意什么呢?首先,我先来说说选择状态或是行为的验证。

第一个要考虑的是上下文。我们的对象间是不是只是一个简单协作?例如订单和仓库,或者是比较麻烦的,例如订单与邮件服务。

如果是一个简单的协作,那么选择也很简单。如果我是一个经典测试驱动开发者,那么我不会使用mock、stub或者其他形式的替身。我会使用真实的对象以及基于状态的验证。如果我是一个mockist的测试驱动开发者,我会使用mock以及基于行为的验证。根本就不需要做决定。

如果是一个麻烦的协作,那么如果我是个mockist测试驱动开发者,那么我就没有选择,我就使用mock对象以及基于行为的验证。如果我是经典测试驱动开发者,那么我需要做出选择,但是选择使用哪一个都不重要。通常,经典测试驱动开发者会根据具体的实例来决定使用在这个情况下最简单的方式。

所以就如我们所看到的,选择状态或是行为验证并不是一个重要的决定。真正重要的是到底使用经典的方式还是使用mock的方式。就如看到的那样,基于状态或是行为验证的特性影响了上述的选择,所以我接下来会重点讨论这个点。

但在我们开始之前,先让我抛出一个极端的情况。有时候有些东西是很难使用状态验证的,甚至它们就不是一个麻烦的协作。最好的例子就是缓存。你根本不能通过其状态来判断缓存是否命中。就算是经典测试驱动开发者也不得不承认,在这个情况下使用行为验证是一个明智的决定。相反,也存在行为测试无法胜任的情况。

当我们开始研究如何选择经典/mockist时,我们需要考虑许多因素,所以我把它们划分开来逐一讨论。

使用测试驱动开发

mock对象来自于XP社区。XP的主要特征之一就是重视测试驱动的开发。其中,系统的设计是通过编写测试随迭代演进的。

所以,这并不会令你惊讶,mockist特别喜欢讨论一个mockist风格测试在系统设计中的重要性。特别是,他们拥护一种叫做需求驱动(need-driven)开发的风格。在这个风格中,你通过在外部系统中编写测试、为SUT创建接口对象来开始开发一个用户故事(user story)。通过思考协作对象的预期调用,你发现了SUT与其相邻对象之间的交互——有效地设计SUT的外部接口。

一旦你运行了第一个测试,mock中的预期不仅为下一步开发提供了参考,还为下一步的测试提供了起点。你每次逐步测试上一个测试中预期的协作对象,不断重复这个过程。这个风格有一个很具描述性的名字,叫由外入内(outside-in)。在分层系统的测试中十分好用。你一开始使用下层的mock来编写UI层的代码。然后你逐步向下,为更下层编写测试。这是一个结构良好、便于控制的方法,并且许多人相信这个能够对于指导面向对象和测试驱动开发的初学者有所帮助。

然而经典测试驱动开发并不具有相同的指导。你同样可以使用逐步测试的方法,只是你使用了stub来替换mock。为了做到这一点,每当你需要一个协作对象时,你都需要把测试所需返回的结果写死在代码中。然后当测试通过时,再把它替换成合适的代码。

但是不仅如此,经典测试驱动开发还做了其他一些mockist风格中没有的事情。其中有一个常用的风格叫做从中间向外(middle-out)。在这个风格中,你从业务中挑出一个特征用例,然后来决定这个用例下所需的领域对象。你使用领域对象来实现你的用例,然后一旦它们没有问题,你再在上层编写UI层。为了做到这一点,你根本不需要制造任何的假对象。许多人喜欢这个风格,因为它最先关注的时领域模型,这样就能防止领域逻辑渗入UI层中。

我这里需要强调的是,不管mockist风格还是经典风格,都有同样一个观点:每次都测试一个用户故事。在这个观点下,应用的开发是跨层的,而不是直到一层开发完成才开始另一层。经典风格的和mockist风格的开发者往往都具备敏捷开发的背景,更喜欢细粒度的迭代。所以,都期望一个特性一个特性地开发,而不是一层一层地开发。

建立测试设施

对于经典的测试驱动开发,你除了需要创建SUT,还需要同时把这个SUT所需的协作对象都创建出来。虽然我们的例子中仅仅创建了一点对象,但是在真实开发中,需要编写大量的协作对象。通常,这些对象在每次测试的运行时都需要创建然后销毁。

然而,mockist测试仅仅需要创建SUT以及与之直接相关对象的mock。这个就避免了需要建立复杂的测试设施。(至少从理论上来说是这样。当然我也碰到过十分复杂的mock对象构建过程,但是这可能是由于没有利用好工具的关系。)

在实践中,经典的测试者更希望尽可能地重用这些复杂的测试设施。一种简单的方式就是把这些测试设施的构建过程放在xUnit的启动方法中。更复杂的方法就需要结合使用多个测试类,所以在这个情况下,你需要构建用于创建这些测试对象的类。我把它们称之为Object Mother,这个名字来源于ThoughtWorks早期的一个极限开发项目。在较为大型的经典测试中,Object Mother是十分必要的,但是Object Mother作为测试的附加代码需要维护,任何对于Object Mother的修改都会在测试中造成很大规模的级联变动。而且还会在性能方面影响测试对象的启动,虽然我并没有听说在处理恰当的情况下会发生严重的问题。大多数测试设施对象创建所需的代价很低,而那些代价大的通常都是前者的两倍。

所以双方都互相指责对方有太多需要做。mockist说创建设施需要花费大量精力,而经典测试者说这些设施是可以被重用的,而你们需要每次测试都建立相应的mock。

测试隔离

在使用mockist测试的情况下,如果在系统中一个模块出现了bug,通常情况下,这只会造成包含这个bug模块的测试失败。然而当使用经典测试时,任何使用了这个模块的测试都会失败,并且还会使得其他间接依赖的协作对象也失败。因此当这个模块被系统中大量其他模块依赖时,就会造成整个系统连锁失败。

mockist测试者把这个视为使用经典测试的一个主要问题,因为这个需要调试很多地方来最终定位问题。然而经典测试者却不不这么认为。因为通常这个测试的根源很容易被发现,开发者知道其他的测试失败是源自哪里。更进一步地说,如果你一直跑测试(你应该如此),那么你应该知道这个问题是由上一次修改造成的,因此找到这个错误并不会很难。

这里有一个十分重要的因素需要考虑,那就是测试的粒度。由于经典测试使用真实的对象进行测试,你会发现通常会有一个主要测试用例,它不仅仅测试自己本身,同时还测试了一串级联的对象。如果这一串对象跨度很大,包含了许多,那么这会使得很难找出bug的源头。这就是我上面所说的问题,测试的粒度太大了。

mockist测试就不大会出现这种问题,因为通常是把主要对象外的其他依赖mock掉,这就使得协作对象能获得更好的粒度。即使如此,过大粒度的测试并不是造成经典测试问题的充分原因,因为你可以控制测试的粒度不要过大。一个好的做法是,你把每一个类都划分到一个合适的粒度上进行测试。尽管同时测试一串级联对象这种情况还会存在,但是这一串对象中不会包含过多对象,例如不超过6个。另外,如果你因为大粒度的测试而难以调试一个问题时,你应该使用测试驱动的方式去调试,添加一些粒度合适的测试用例。

从本质上来说,典型测试不仅仅是单元测试,同样也是一个小型化的集成测试。许多人都喜欢上层测试所遗漏的错误会被其下层的测试捕获到,特别是那种类与类交互的地方。而mockist测试就没有办法做到。另外,你同样可能由于mockist测试上错误的预期造成隐藏了其内在的错误。

在这点上,我应该强调一下,无论你使用哪种风格的测试,你最终还是得在验收测试中进行粗粒度的组合,把整个系统组合在一起来测试。我经常会发现一些项目由于过晚地进行验收测试而后悔。

测试与实现耦合

当你是mockist测试时,你测试SUT的外部调用,验证它们是否正确地调用了外部接口。而当你是经典测试时,你仅关注最终的状态,而不是这个状态是怎么来的。因此,mockist测试更与方法的实现耦合。如果修改了协作器的调用方式,就很容易造成mockist测试失败。

而这个耦合就导致了有些问题需要关注。其中最重要的一点是测试驱动所带来的问题。当使用mockist测试时,编写测试帮助你去思考实现的行为,事实上mockist测试者们把这个视为优点。而经典测试者则认为,应该仅仅需要关注外部接口发生了什么,具体的实现应该留到你编写测试之后再去关注。

与实现耦合同样还造成了对于重构的干扰。因为相对经典测试而言,修改实现更容易造成mockist的测试失败。

而mock的工具使得这个情况变得更加糟糕。mock工具指定十分具体的方法调用以及匹配参数,甚至和特定测试没有直接关系。jMock工具的目标之一就是为了解决上述的过度耦合,但是由于使用了字符串的形式,作为代价,使得重构变得更加的棘手。

设计风格

对我来说,这两个测试风格最吸引我的一点就是它们在设计决策的影响。在我分别与使用这两种类型测试的开发者们交谈之后,我了解到它们鼓励两种不同的设计风格。当然,我只能泛泛而谈。

我已经在上文中讲解了它们在处理分层问题上的一个区别。mockist风格的测试支持自外向内的,而对于那些喜欢从领域模型开始的开发者来说,他们更倾向于经典的测试。

在一处细节上,我发现mockist测试者相比使用一个返回对象的方法,更喜欢使用参数去收集对象的返回。例如,实现一个收集各个对象的信息并产生报表字符串的功能。一个比较常见的做法是调用各个对象返回字符串的方法,然后再把它们组装在一起。而一个mockist测试者很有可能把一个string buffer当做一个收集参数,传递到各个方法中,让那些方法把字符串追加上来。

mockist测试者经常谈论起要避免像getThis().getThat().getTheOther()这样链式方法调用的“火车事故”。避免链式调用就遵守了得墨忒耳定律(Law of Demeter)。虽然链式调用很糟糕,但相对地,为了解决链式调用而使用大量臃肿的转发方法也很糟糕。(我一直觉得,把得墨忒耳定律称之为得墨忒耳建议(Suggestion of Demeter)更合适。)

在面向对象设计原则中,有一个让人最难理解的是“命令而不要去询问”(Tell Don’t Ask)原则。这个原则鼓励去命令一个对象做一个事情,而不是把这个对象的数据都取出来自己做。mockist测试者认为使用mock风格的测试能够促进遵守这个原则并且避免现在常常被滥用的getter方法。而经典测试风格的人则认为可以用其他的方式来做到。

在使用基于状态测试时,一个被公认存在的问题是,会添加仅仅用于测试校验的查询方法。这个看起来肯定很不舒服,而使用基于行为的校验就能避免这个问题。但反对者认为,这些变动在实际中很少存在。

mockist测试者喜欢使用角色接口,并声称使用mockist测试风格可以鼓励使用更多的角色接口,因为每一个协作对象都被分别mock,所以每个都可能变为一个角色接口。因此,在上述使用string buffer生成报表的例子中,他们很有可能创造一个符合那个场景的角色,并在string buffer中实现。

重要的是,这个设计上的不同,是mockist测试者使用这种风格的最关键的促进因素。测试驱动开发早期的目的是为了构建起一个强有力的回归测试,用来支撑逐步演化的设计。在这个过程中,实践者们发现测试先行能极大地帮助到设计。mockist测试者清楚,什么样的设计才是好的设计,并开发了mock类库来帮助人们使用这种设计风格。

那我应该使用经典测试还是mockist测试呢?

我觉得这是一个很难确切回答的问题。个人来讲,我一直是一个过时的、基于经典测试的测试驱动的开发者,所以我至今也没有任何理由尝试改变。我并没有发现基于mockist的测试驱动有令人不可抗拒的优势,并且我担心测试代码与实现耦合而带来的问题。

mockist开发者的代码给我带来十分大地冲击。在编写测试时,我更倾向于关注它行为的结果,而不是这个结果是如何产生的。mockist测试者常常为了编写测试预期而思考SUT是如何实现的。这个让我感到十分不自然。

由于我仅仅把mockist风格应用在一些简单的代码中,这给我带来了十分片面的理解。就如我从测试驱动开发中学到的,我们很难判断一个技术的好坏,除非我们十分完整地、彻底地使用了它。其实,许多我认识的开发者也是mockist坚定的支持者。所以,虽然我是一个经典测试的坚定支持者,我还是希望能不失公允地表达出两者的概念与思想。至于使用哪一种风格,还是需要你自己来决定。

所以,如果你对mockist风格的测试很有兴趣,我建议你尝试一下。特别是当你面对那些mockist测试驱动特别擅长的场景。就例如,由于测试没有被拆分得很清晰,你找不到问题在哪里,而花费大量时间调错。(这种情况,你也可以通过在经典测试中使用细粒度的级联测试来解决。)又例如,你的对象没有包含足够多的行为逻辑,而使用mockist风格的测试能鼓励你的开发团队编写更多具有丰富行为的对象。

最后的一点思考

如今,xunit框架以及测试驱动的开发正在飞速发展,越来越多的人开始对于单元测试感兴趣,并逐步接触到mock对象。人们总是对于mock对象框架略知一二,而并不能完全区分支撑它们的mockist以及经典风格的测试。无论你更倾向于哪一种,我认为理解他们之间的区别是很有帮助的。虽然你没有必要为了使用mock框架而成为一个mockist测试者,但我认为理解这个指导软件设计决策的思想是很有用的。