为什么嘲笑类是如此糟糕?

55

我最近和一个同事讨论了关于模拟的话题。他说模拟类非常不好,除非在极少数情况下才应该这样做。

他说只有接口应该被模拟,否则就是架构上的错误。

我想知道为什么这个观点(我完全相信他)是如此正确?我不知道,希望能够被说服。

我是否理解错了模拟的要点(是的,我读过Martin Fowler的文章


2
“Mocking” 可以有几种不同的含义。 - skaffman
6
我认为你的同事有一些不应该使用这些的原因和时间,对吗?他们是什么? - itsmatt
学生们会感到冒犯。 - Galwegian
2
我同意你的同事观点。如果你在测试中发现自己使用了很多mocks,那么你所测试的代码就不是很好写的,例如没有依赖注入、SOLID等。当然,在优秀的代码中,你仍然需要使用mocks/fakes。就像任何事情一样,它从来都不是非此即彼的。 - Josef.B
内部实现的模拟会使测试变得脆弱且难以重构。模拟应仅用于外部服务(数据库、REST、文件、日期)。明确一点 - 当3-5个类相互合作以满足要求时,它们是一个单元(例如模块),您不应使用模拟。阅读有关TDD芝加哥学派的文章。 - andrew.fox
显示剩余2条评论
9个回答

71

Mocking被用于协议测试 - 它测试你如何使用API以及当API相应时如何作出反应。

在理想情况下(至少在许多情况下),API应该被指定为接口而不是类 - 接口定义了一个协议,类定义了至少部分实现。

就实际情况而言,模拟框架在模拟类方面往往存在限制。

根据我的经验,Mocking有些过度使用 - 通常你并不真正关心精确的交互,你真正需要的是一个存根...但是模拟框架可以用于创建存根,并且通过模拟而不是存根来创建脆弱的测试。然而,这是一个难以平衡的问题。


我想知道OP的朋友是否可能混淆了“接口”的几个含义。他可能读到了一些关于模拟类接口的内容,并认为它指的是他选择的语言中“接口”关键字所指的东西。 - Ewan Todd
除了对依赖项进行模拟的需求有逻辑上的合理性外,还有一些技术上的原因不建议对类进行模拟。请查看我的答案。 - Stefan Steinegger
Ewan:我指的是类接口。 - guerda
11
每个类都有一个隐含的接口,它定义了公共方法和构造函数的集合。将其视为类的“协议”并没有什么不妥之处。因此,“协议测试”并不意味着类不应该被模拟。 此外,创建模拟对象并不意味着每个模拟方法都必须在测试中明确指定。期望可以是严格的或非严格的,其中非严格的期望可以自由地出现在被测试的代码中任意次数。换句话说,存根只是一种模拟类型,其中所有期望默认都是非严格的。 - Rogério
3
@Rogerio:我认为通过接口来指定API更简单,因为这样就不会包含“偶然公开”的方法(例如用于实现其他接口的方法)作为依赖混合物的一部分。当您开始仅对某些方法进行模拟,并使用生产代码进行其他方法时,这最终会变成一种不舒适的混合,因为这意味着您假设您正在模拟的部分不受您没有模拟的部分以及反之影响...而且请记住,我们讨论的并不是测试对象本身。 - Jon Skeet
是的,如果你的类实现了多个独立的接口/抽象类,那么需要进行如此处理。不过,大多数类并不需要这样做。对于它们来说,类的公共接口才是最重要的;通过适当的设计,可以避免将太多内容暴露为公共成员。 - Rogério

26

4
“Program to an interface…”只是指你应该使用抽象类型来声明字段、变量、参数和返回类型,而不是具体实现类型。没有更多内容。因此,对于一个不实现单独的抽象类型的类,在需要进行模拟测试时,模拟该类将是完全可以接受的。为这样的类创建一个毫无意义的单独接口只会是浪费精力。 - Rogério
1
@Rogerio:我也理解你在我的回答中所写的评论。我认为你并没有真正理解接口的好处。它们与抽象类没有太多关系。根据我的经验,我使用接口的次数越多,就越能看到它的好处,尽管我需要编写更多的代码,有更多的间接性,并需要依赖注入等等。 - Stefan Steinegger
2
我并没有特别提到抽象类,只是提到了“抽象”和“抽象类型”。Java接口就是一种抽象类型。 我很好地理解了抽象概念。但我怀疑你可能对“耦合”真正的含义(以及“内聚性”)感到困惑。写出没有明确业务或技术理由的额外代码是错误的。我已经看到过足够多这样的代码库,知道在这样的代码库上工作是多么痛苦。 - Rogério

16

对类进行模拟测试(与模拟接口相比)是不好的,因为模拟仍然有一个真实的类在后台,它是继承来的,可能会在测试期间执行真正的实现

当你对接口进行模拟(或存根或其他操作)时,就不存在你实际想要模拟的代码被执行的风险。

对类进行模拟测试还会强制你使可能被模拟的所有内容都变成虚拟的,这非常具有侵入性,可能会导致糟糕的类设计

如果你想解耦类,它们不应该互相知道,这就是为什么对它们中的一个进行模拟(或存根或其他操作)是有意义的原因。因此,实现接口推荐使用,但其他人已经足够提到这一点了。


2
你实际上在谈论某些mocking工具的限制,而不是关于mock引用类型(Java语言中的类、接口、枚举和注释)的固有问题。当mock一个类时,无需创建子类。例如,使用JMockit工具,mock一个类或接口几乎是相同的(即使该类是final/sealed或抽象的)。 - Rogério
@Rogerio:也许这只适用于某些模拟工具。如果没有这些限制,模拟类就“几乎相同”,那么就没有技术上的劣势。最后一段仍然适用,解耦类意味着必须能够在隔离中运行它们,模拟变得自然,接口也是如此。不解耦类意味着糟糕的类设计。 - Stefan Steinegger
5
通过简单引入一个独立的接口来“解耦”类(将某个类与其所依赖的接口分离),当该接口永远不会有第二个实现时,这是真正的糟糕设计。只有当分离抽象和实现的好处清晰明显时,才能证明这样做是有道理的;否则这就是过度工程化,可能(并且确实)会给真实的软件项目带来巨大的伤害。 - Rogério
@Rogerio:我同意为每个依赖项使用接口是过度设计,需要在最终目标上有所收益(对于我们所做的一切)。但“仅有一个实现”根本不是一个论点。重要的不是有多少个实现,而是一个类是否需要知道另一个类。使用接口可以轻松地限制系统的一部分对另一部分的依赖性。这已经足够有益了。如果你不相信我,请看Jon Skeet的答案第二段。 - Stefan Steinegger
@Rogerio:我们很可能在谈论同一件事,但是在不同的环境中。我正在工作的项目中举个例子:有许多服务,框架服务和领域服务。它们仅由接口调用。没有服务直接了解其他服务。即使它们在同一个程序集中,只有一个(真正的)实现。有些服务实现了多个这些接口,因为每个接口都设计用于某种依赖,并且应尽可能小,以避免必要的高耦合。 - Stefan Steinegger
显示剩余2条评论

7
通常您会想要模拟一个接口。
虽然可以模拟常规类,但这往往会对类的设计产生影响,使其更具可测试性。与真正的面向对象(OO)问题不同,访问性、方法是否虚拟等问题都将由于能否模拟类而决定。
有一种名为TypeMock Isolator的伪造库可以让您摆脱这些限制(既要吃蛋糕,又要留下蛋糕),但它的价格相当昂贵。最好为可测试性进行设计。

由于没有指定语言,我将指出Java的等效方法 - EasyMock可以模拟类。但是它存在一些问题。 http://easymock.org/EasyMock2_4_ClassExtension_Documentation.html - Michael Lloyd Lee mlk
对于Java,有免费且开源的JMockit工具包。据我所知,它相当于TypeMock。 (顺便问一句,有人知道TypeMock是否支持按需模拟实现吗?) - Rogério
我完全同意。好答案。 - nightcoder

6
我建议在可能的情况下尽量避免使用模拟框架。同时,我建议尽可能使用模拟/虚假对象进行测试。这里的诀窍在于,你应该与真正的对象一起创建内置的虚假对象。关于此,我在自己的博客文章中有更详细的解释:http://www.yegor256.com/2014/09/23/built-in-fake-objects.html 。请注意保留 HTML 标记,但不要写出它们的解释。

4

在开发生命周期的早期编写测试时,使用模拟类是有意义的。

即使具体实现可用,也有继续使用模拟类的趋势。还有一种趋势是在项目早期开发对模拟类(和存根)进行必要的开发,当系统的某些部分尚未构建时。

一旦系统的一部分已经构建完成,就需要针对它进行测试并继续进行回归测试。在这种情况下,从模拟开始是好的,但应尽快放弃它们,转而使用实现。我见过一些项目因为不同的团队继续根据模拟行为而不是实现(一旦可用)而陷入困境。

通过对模拟进行测试,您假定模拟是系统的特征。通常,这涉及猜测模拟组件将要做什么。如果您有系统的规范,则无需猜测,但由于在构建过程中发现了实际考虑因素,因此“按原样构建”的系统通常与原始规范不匹配。敏捷开发项目假设这种情况总会发生。

然后,您开发可以与模拟一起工作的代码。当发现模拟不真正代表实际构建系统的行为(例如,模拟中未见的延迟问题,资源和效率问题,并发问题,性能问题等)时,您将拥有一堆毫无价值的模拟测试,现在必须维护。

我认为在开发开始时使用模拟是有价值的,但这些模拟不应对项目覆盖范围做出贡献。最好在后期删除模拟,并创建适当的集成测试来替换它们,否则您的系统将无法得到测试以检查模拟未模拟(或相对于实际系统模拟不正确)的各种条件。

因此,问题是是否使用模拟,这取决于何时使用它们以及何时删除它们。


嗯,但是使用模拟测试仍然比集成测试提供更快的反馈循环,对吧?保留这些模拟测试是否有好处,这样我们在开发功能时可以尽可能频繁地运行这些测试呢? - Van Teo Le

3

关于实践的问题,像大多数问题一样,“这取决于情况”。

过度使用模拟对象可能会导致测试无法真正测试任何内容。它也可能导致测试成为代码重构的虚拟重新实现,与特定实现紧密绑定。

另一方面,恰当地使用模拟对象和存根可以导致单元测试被完全隔离,并且只测试一个事物-这是一件好事。

关键在于适度。


你问为什么模拟对象不好。我解释了它们既可以是坏的也可以是好的。如果你期望别人只是确认你同事的偏见,那我很抱歉。否则,我不明白我误解了你的问题在哪里。 - Avdi
啊,现在我明白了,你是在谈论Java(或C#?)中类和接口之间的特定区别。如果你在谈论语言特定的东西,应该用“java”或“C#”标记你的问题。许多(大多数)其他面向对象的语言没有类和接口之间的这种区别。 - Avdi
我曾经是REXX、C、C++、68k ASM、C#、Java、Perl、TCL、Python、Haskell、Lisp和Ruby的开发人员。在这个列表中,只有两种语言有接口的概念。 - Avdi
哦,是的,忘了JavaScript和VB。我记不清VB是否有接口;考虑到它从来没有遇到过不喜欢的语言特性,它可能有。 - Avdi

3
这取决于您使用(或被糟糕的设计所迫)模拟的频率。如果实例化对象变得太难(这种情况经常发生),那么这是代码可能需要进行严重重构或设计更改的信号(建造者?工厂?)。
当您模拟所有内容时,您最终会得到了解有关实现的所有信息的测试(白盒测试)。您的测试不再记录如何使用系统-它们基本上是其实现的镜像。
然后就有可能进行代码重构。从我的经验来看,这是与过度模拟相关的最大问题之一。它变得痛苦并且需要时间,很多时间。一些开发人员因知道需要多长时间而害怕重构他们的代码。还存在一个问题——如果所有内容都被模拟,我们真的在测试生产代码吗?
当然,模拟 tend 倾向于违反 DRY 原则,通过在两个地方(一次在生产代码中,一次在测试中)复制代码。因此,如我之前提到的,对代码的任何更改都必须在两个地方进行(如果测试编写不好,则可能超过两个地方...)。

2

编辑:由于您已经澄清,您的同事是指模拟不好,但是模拟接口不会有问题,所以下面的回答已过时。您应该参考this answer

我所说的模拟和存根是由Martin Fowler定义的(定义请见此处),我假设这也是您的同事所想的。

模拟是不好的,因为它可能导致测试过度规范化。尽量使用存根并避免使用模拟。

以下是模拟和存根之间的区别(来自上述文章):

We can then use state verification on the stub like this.

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

Of course this is a very simple test - only that a message has been sent. We've not tested it was send to the right person, or with the right contents, but it will do to illustrate the point.

Using mocks this test would look quite different.

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

In order to use state verification on the stub, I need to make some extra methods on the >stub to help with verification. As a result the stub implements MailService but adds extra >test methods.


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接