使用模拟对象时,如何检测单元测试的依赖性问题?


98

您有一个X类,并且编写了一些验证行为X1的单元测试。还有一个类A,它把X作为依赖项。

为A编写单元测试时,您将模拟X。换句话说,在对A进行单元测试时,您将(假设)X的模拟行为设置为X1。时间的流逝,人们确实在使用您的系统,需要改变,X演变:您修改X以显示行为X2。显然,针对X的单元测试将失败,您需要对其进行调整。

但是A呢?修改X的行为后(由于X的模拟),针对A的单元测试不会失败。当使用“真实”(修改的)X运行时,如何检测A的结果会有所不同?

我期望得到以下答案:“这不是单元测试的目的”,但是单元测试有什么价值呢?它真的只是告诉您,当所有测试通过时,您还没有进行重大更改吗?当某个班级的行为发生变化时(有意或无意),您如何发现(最好以自动化方式)所有后果?我们不应该更多地关注集成测试吗?



36
除了建议的所有答案外,我还必须对以下陈述表示怀疑:“它真的告诉您,当所有测试通过时,您还没有进行重大改变吗?” 如果您真的认为消除对重构的恐惧没有什么价值,那么您就在编写
无法

5
单元测试告诉您代码单元是否按预期运行。没有更多或更少。模拟和测试双打为您提供了一个人为的,受控的环境,可让您(独立地)练习代码单元以查看其是否符合您的期望。没有更多或更少。
罗伯特·哈维

2
我相信您的前提不正确。当您提到时,X1您是说X实现接口X1。如果你改变了接口X1,以X2您在其他测试中使用的模拟不应再编译,因此你不得不解决这些测试过。班级行为的变化不重要。实际上,您的类A不应依赖于实现细节(在这种情况下,这将是您要更改的)。因此,对于的单元测试A仍然是正确的,并且它们告诉您A在接口的理想实现下可以进行的工作。
巴库里

5
我不了解您,但是当我不得不在没有测试的代码库中工作时,我会被吓死了,我会破坏一些东西。又为什么呢 因为它发生的频率很高,以至于某些原本不想要的东西会破裂。祝福我们测试人员的内心,他们无法测试所有内容。甚至关闭。但是,在无聊的例程之后,单元测试会很高兴地从无聊的例程中消失。
corsiKa

Answers:


125

为A编写单元测试时,您将模拟X

你呢?除非绝对必要,否则我不会。我必须:

  1. X 慢,或
  2. X 有副作用

如果这些都不适用,那么我的单元测试也A将进行测试X。要做其他任何事情都会将测试隔离到一个不合逻辑的极端。

如果您的部分代码使用其他代码的模拟,那么我会同意:这种单元测试的意义何在?所以不要这样做。让那些测试使用真实的依赖项,因为它们以这种方式形成了更有价值的测试。

如果有些人不满意您将这些测试称为“单元测试”,那么就称它们为“自动化测试”,然后继续编写良好的自动化测试。


94
@Laiv,不,单元测试应该作为一个单元,即与其他测试隔离运行。节点和图可以加息。如果我可以在短时间内运行隔离的,无副作用的免费端到端测试,那就是单元测试。如果您不喜欢该定义,请将其称为自动测试,并停止编写废话测试以适应愚蠢的语义。
大卫·阿诺

9
@DavidArno遗憾的是,隔离的定义非常广泛。有些人希望“单元”包括中间层和数据库。他们可以相信自己喜欢的任何东西,但是有可能在任何规模的开发中,轮子都会在相当短的时间内脱落,因为构建经理会将其扔掉。通常,如果将它们隔离到程序集(或等效程序)中,那很好。注意,如果您对接口进行编码,那么以后添加模拟和DI会容易得多。
罗比·迪

13
您主张的是另一种测试,而不是回答问题。这是正确的一点,但这是一种相当不为人知的方法。
Phil Frost

17
引用自己的@PhilFrost的话:“ 如果某些人不满意您将这些测试称为“单元测试”,那么就称它们为“自动化测试”,然后继续编写良好的自动化测试。 ”编写有用的测试,不要愚蠢的测试只是满足一个单词的一些随机定义。或者,也可以接受,也许您对“单元测试”的定义是错误的,并且因为使用了错误的模型而过度使用了模拟。无论哪种方式,您都将获得更好的测试。
David Arno

30
我和@DavidArno在一起。在观看了Ian Cooper的演讲后,我的测试策略发生了变化:vimeo.com/68375232。简而言之:不要测试class。测试行为。您的测试不应了解用于实现所需行为的内部类/方法;他们应该只知道您的API /库的公开外观,并且应该对其进行测试。如果测试知识太多,那么您正在测试实现细节,并且测试变得脆弱,与实现耦合,实际上只是您的瓶颈。
Richiban

79

你们两个都需要。单元测试可以验证每个单元的行为,还可以进行一些集成测试以确保它们正确连接。仅依靠集成测试的问题是由您所有单元之间的交互导致的组合爆炸。

假设您有A类,需要10个单元测试才能完全覆盖所有路径。然后,您有了另一个B类,它也需要10个单元测试来覆盖代码可以通过的所有路径。现在,在您的应用程序中,您需要将A的输出馈送到B中。现在您的代码可以采用100条不同的路径,从A的输入到B的输出。

使用单元测试,您只需要20个单元测试+ 1个集成测试即可完全涵盖所有情况。

使用集成测试,您将需要100个测试来涵盖所有代码路径。

这是一个很好的视频,介绍了仅依靠集成测试的弊端JB Rainsberger集成测试是骗局HD


1
我敢肯定,关于集成测试功效的问号与覆盖所有其他层的单元测试并驾齐驱并非偶然。
罗比迪

16
是的,但是您的20个单元测试的任何地方都不需要模拟。如果您有10个涵盖所有A的A测试和10个涵盖所有B的测试,并且还重新测试了25%的A作为奖励,那么这似乎在“好”和好事之间。在Bs测试中嘲笑A似乎很愚蠢(除非确实有A成为问题的原因,例如它是数据库还是带来了很多其他问题)
理查德·廷格

9
我不同意这样一个想法,即如果您想要全面覆盖,则单个集成测试就足够了。B对A输出的反应将根据输出而变化;如果在A中更改参数会更改其输出,则B可能无法正确处理它。
Matthieu M.

3
@ Eternal21:我的意思是有时候问题不在于个人行为,而在于意想不到的互动。即,在某些情况下,当A和B之间的粘连行为异常时。因此,A和B都根据规范进行操作,并且情况很令人满意,但是在某些输入上,粘合代码中存在错误……
Matthieu

1
@MatthieuM。我认为这超出了单元测试的范围。胶水代码本身可以进行单元测试,而通过胶水代码进行的A和B之间的交互是集成测试。当发现特定的边缘情况或错误时,可以将其添加到粘合代码单元测试中,并最终在集成测试中进行验证。
Andrew T Finnell '18

72

为A编写单元测试时,您将模拟X。换句话说,在对A进行单元测试时,您将(假设)X的模拟行为设置为X1。时间的流逝,人们确实在使用您的系统,需要改变,X演变:您修改X以显示行为X2。显然,针对X的单元测试将失败,您需要对其进行调整。

哇,等等。测试对于X失败的含义太重要了,以至于无法掩盖。

如果将X的实现从X1更改为X2破坏了X的单元测试,则表明您对合同X进行了向后不兼容的更改。

Liskov的角度来看,X2不是X,因此您应该考虑满足利益相关者需求的其他方式(例如引入由X2实现的新规范Y)。

有关更深入的见解,请参见Pieter Hinjens:软件版本的结尾或Rich Hickey 轻松实现

从A的角度来看,存在一个前提条件,那就是合作者尊重合同X。并且您的观察有效地是,对A的隔离测试不能完全保证A会识别违反X合同的合作者。

审查综合测试是一个骗局 ; 在较高的层次上,期望您有尽可能多的隔离测试来确保X2正确地实现合同X,并且有尽可能多的隔离测试来确保A给出正确的响应(从X发出有趣的响应),以及少量的集成测试,以确保X2和A同意X的含义。

有时您会看到这种区别表示为孤立测试与sociable测试。请参阅Jay Fields 有效地进行单元测试

我们不应该更多地关注集成测试吗?

再次,看到集成测试是一个骗局-Rainsberger详细描述了一个积极的反馈循环(根据他的经验)对于依赖于集成(注释拼写)测试的项目很常见。总而言之,如果没有孤立/单独的测试对设计施加压力,质量就会下降,从而导致更多的错误和更多的集成测试。...

您还将需要(一些)集成测试。除了由多个模块引入的复杂性之外,执行这些测试还比隔离测试带来更多的拖累。进行工作时快速进行非常快速的检查会更有效率,而在您认为自己“完成”时可以保存其他检查。


8
这应该是公认的答案。该问题概述了一种情况,其中类的行为已以不兼容的方式进行了修改,但从外观上看仍然相同。这里的问题在于应用程序的设计,而不是单元测试。在测试中遇到这种情况的方法是在这两个类之间使用集成测试。
尼克·科德

1
“如果没有孤立/孤立的测试对设计施加压力,质量就会下降”。我认为这是重要的一点。除行为检查外,单元测试还会产生副作用,迫使您进行更具模块化的设计。
MickaëlG

我想这都是真的,但是如果外部依赖关系对合同X引入了向后不兼容的更改,这对我有什么帮助?也许库中具有I / O性能的类破坏了兼容性,我们之所以嘲笑X,是因为我们不希望CI中的单元测试依赖于繁重的I / O。我认为OP正在要求对此进行测试,但我不知道这如何回答问题。如何对此进行测试?
Gerrit

15

首先,我要说这个问题的核心前提是有缺陷的。

您从不测试(或模拟)实现,而是在测试(和模拟)接口

如果我有一个实实的类X实现了接口X1,则可以编写一个也符合X1的模拟XM。然后,我的A类必须使用实现X1的东西,它可以是X类或模拟XM。

现在,假设我们更改X以实现新的接口X2。好吧,显然我的代码不再编译。A需要实现X1的东西,并且不再存在。该问题已确定,可以解决。

假设我们没有修改X1,而是对其进行了修改。现在,A类已全部设置好。但是,模拟XM不再实现接口X1。该问题已确定,可以解决。


单元测试和模拟的整个基础是编写使用接口的代码。的界面的消费者不关心如何实现该代码的,则只有同一合同被粘附到(输入/输出)。

当您的方法有副作用时,这种方法会失效,但是我认为可以安全地将其排除在外,因为“无法进行单元测试或模拟”。


11
这个答案提出了许多不需要成立的假设。首先,它粗略地假设我们使用C#或Java(或更准确地说,我们使用的是编译语言,该语言具有接口,并且X实现了一个接口;这些都不是必须的)。其次,它假定对X的行为或“合同”进行任何更改都需要对X实现的接口(如编译器所理解的)进行更改。这显然是正确,即使我们在Java或C#; 您可以更改方法实现而无需更改其签名。
Mark Amery

6
@MarkAmery的确,“接口”术语更特定于C#或Java,但我认为要指出的是假设行为已定义为“合同”(如果未进行编码,则无法自动检测到)。您也完全正确,可以在不更改合同的情况下更改实现。但是,在不更改接口(或合同)的情况下更改实现不应影响任何消费者。如果A的行为取决于接口(或协定)的实现方式,则不可能(有意义地)进行单元测试。
Vlad274 '18

1
“您也完全正确,可以在不更改合同的情况下更改实现” –同样,这不是我要提出的重点。相反,我要在合同(程序员对对象应该做的理解,可能在文档中指定)和接口(方法签名列表,编译器可以理解)之间进行区分,并说合同可以无需更改界面即可进行更改。从类型系统的角度来看,具有相同签名的所有功能都是可以互换的,但实际上不能互换!
Mark Amery

4
@MarkAmery:我不认为Vlad在使用“接口”一词时的含义与您在使用它时的含义相同;就我阅读答案的方式而言,它不是在狭义的C#/ Java含义(即一组方法签名)中谈论接口,而是在该词的一般意义上,例如在术语“应用程序接口”或什至“用户界面”。[...]
Ilmari Karonen

6
@IlmariKaronen如果Vlad使用“接口”来表示“合同”,而不是狭义的C#/ Java,则声明“现在,假设我们更改X以实现新接口X2。那么,显然我的代码不再编译了。 ” 完全是错误的,因为您可以更改合同而无需更改任何方法签名。但老实说,我认为这里的问题在于Vlad并没有始终使用这两种含义,而是它们混为一谈,这就是导致声称X1合同的任何变更必然会导致编译错误而没有注意到这是错误的的原因。 。
Mark Amery

9

依次提出您的问题:

那么单元测试有什么价值

它们的编写和运行很便宜,您会得到早期的反馈。如果您破坏X,只要测试良好,就会立即发现更多或更少的内容。除非您已经对所有层进行了单元测试(甚至是在数据库上),否则甚至不要考虑编写集成测试。

它真的只是告诉您,当所有测试通过时,您还没有进行重大更改吗?

通过测试实际上可以告诉你很少。您可能没有编写足够的测试。您可能没有测试足够的方案。代码覆盖率可以在这里有所帮助,但这不是灵丹妙药。您可能有始终通过的测试。因此,红色是红色,绿色重构的经常被忽略的第一步。

当某个班级的行为发生变化时(有意或无意),您如何检测(最好以自动化方式)所有后果

更多测试-尽管工具越来越好。但是您应该在接口中定义类行为(请参见下文)。注意:在测试金字塔的顶部始终会有一个手动测试的地方。

我们不应该更多地关注集成测试吗?

越来越多的集成测试也不能解决问题,它们的编写,运行和维护成本很高。根据您的构建设置,您的构建管理器可能会始终排除它们,从而使它们依赖于开发人员的记忆(从来都不是一件好事!)。

我已经看到开发人员花了数小时试图修复如果他们有良好的单元测试,他们会在五分钟之内找到损坏的集成测试。如果失败,请尝试仅运行软件-最终用户将关心的所有软件。当用户运行整个套件时,如果整个纸牌屋掉下来,没有百万个单元测试可以通过。

如果要确保A类以相同的方式使用X类,则应使用接口而不是取缔。这样一来,在编译时就更有可能发生重大变化。


9

没错

单元测试可用来测试单元的隔离功能,即一目了然的检查单元是否按预期工作并且不包含愚蠢的错误。

没有单元测试可以测试整个应用程序是否正常运行。

很多人忘记了,单元测试只是验证代码的最快,最肮脏的方法。一旦知道您的小例程正常工作,那么您还必须运行集成测试。单元测试本身仅比没有测试好。

我们之所以拥有单元测试,是因为它们应该很便宜。快速创建,运行和维护。一旦开始将它们转换为最小集成测试,您将陷入痛苦的世界。如果您要进行完整的集成测试,则可以完全忽略单元测试。

现在,有人认为一个单元不仅仅是一个类中的函数,而是整个类本身(包括我自己)。但是,所有这一切都是增加单元的大小,因此您可能需要较少的集成测试,但仍然需要它。如果没有完整的集成测试套件,仍然无法验证您的程序是否应该执行预期的工作。

然后,您仍然需要在实时(或半实时)系统上运行全面集成测试,以检查其是否符合客户使用的条件。


2

单元测试不能证明任何东西的正确性。对于所有测试都是如此。如果需要定期验证正确性,通常将单元测试与基于合同的设计(按合同设计是另一种说法)结合使用,并且可能会自动进行正确性证明。

如果您具有包含类不变式,前提条件和后置条件的真实合同,则可以通过将较高级别的组件的正确性基于较低级别的组件的契约来分层证明正确性。这是合同设计背后的基本概念。


单元测试不能证明任何东西的正确性。不确定我是否理解这一点,单元测试肯定会检查自己的结果吗?还是您是说某行为无法证明是正确的,因为它可能包含多个层次?
罗比迪

7
@RobbieDee我猜,他的意思是说,当您测试时fac(5) == 120,您还没有证明fac()确实返回了其参数的阶乘。您只证明fac()在传递时返回5的阶乘5。甚至还不能确定,fac()可以想象的42是,在廷巴克图(Tombbtutu)发生日全食后的第一个星期一返回。这里的问题是,您无法通过检查各个测试输入来证明是否合规,您需要检查所有可能的输入,并证明您没有忘记任何内容(例如读取系统时钟)。
cmaster

1
对于真正的目标,@ RobbieDee测试(包括单元测试)是一个较差的替代品(通常是最佳的替代品),它是机器检查的证明。考虑被测单元的整个状态空间,包括其中任何组件或模型的状态空间。除非您的状态空间非常有限,否则测试无法覆盖该状态空间。完全覆盖将是一个证明,但这仅适用于微小的状态空间,例如,测试包含单个可变字节或16位整数的单个对象。自动证明更有价值。
Frank Hileman '18

1
@cmaster您很好地总结了测试和证明之间的区别。谢谢!
Frank Hileman '18

2

我发现进行大量模拟的测试很少有用。大多数时候,我最终会重新实现原始类已经具有的行为,这完全违背了模拟的目的。

对于我来说,更好的策略是将关注点很好地分离(例如,您可以测试应用程序的A部分,而无需引入B部分到Z部分)。这样好的架构确实有助于编写好的测试。

而且,只要我可以将副作用回滚,我就更愿意接受副作用,例如,如果我的方法修改了db中的数据,那就顺其自然吧!只要我可以将数据库回滚到以前的状态,会有什么害处?此外,我的测试还可以检查数据是否符合预期,这是有好处的。内存中的数据库或特定的数据库测试版本在这里确实有帮助(例如,RavenDB的内存中测试版本)。

最后,我喜欢在服务边界上进行模拟,例如不要对服务b进行http调用,但是让我们截取它并引入一个适当的方法。


1

我希望两个阵营的人都能理解类测试和行为测试不是正交的。

类测试和单元测试可以互换使用,也许不应该这样。一些单元测试恰好在类中实现。就这些。数十年来,单元测试已经使用无类语言进行了。

至于测试行为,完全可以在使用GWT构造的类测试中完成。

此外,您的自动化测试是按照类还是行为来进行还是取决于您的优先级。有些将需要快速原型制作并拿出一些东西,而另一些则将由于房屋风格而受到覆盖范围的限制。很多原因。它们都是完全有效的方法。您付钱,您可以选择。

因此,代码中断时该怎么办。如果已将其编码为接口,则只需更改具体内容(以及任何测试)。

但是,引入新的行为根本不会损害系统。Linux等具有不推荐使用的功能。诸如构造函数(和方法)之类的东西可以很高兴地被重载,而不必强制所有调用代码进行更改。

赢得类测试的地方是您需要更改尚未加入的类(由于时间限制,复杂性或其他原因)。如果具有全面的测试,那么上课就容易得多。


如果您有具体的看法,我将书面执行。这是从另一种语言(我猜是法语)算起的,还是固结实现之间的重要区别?
彼得·泰勒

0

除非X的接口发生更改,否则您无需更改A的单元测试,因为与A相关的任何内容都没有更改。听起来您真的是一起写了X和A的单元测试,但是称它为A的单元测试:

为A编写单元测试时,您将模拟X。换句话说,在对A进行单元测试时,您将(假设)X的模拟行为设置为X1。

理想情况下,X的模拟应该模拟X的所有可能的行为,而不仅仅是您期望 X 的行为。因此,无论您实际上在X中实现什么,A都应该已经能够处理它。因此,除了更改接口本身之外,对X所做的任何更改都不会对A的单元测试产生任何影响。

例如:假设A是一种排序算法,而A提供了要排序的数据。X的模拟应提供一个空返回值,一个空数据列表,一个元素,多个元素已排序,多个元素尚未排序,多个元素向后排序,具有相同元素的列表重复,空值混杂在一起,可笑大量元素,并且还应该引发异常。

因此,也许X最初在星期一返回排序后的数据,而在星期二返回空列表。但是现在,当X在星期一返回未排序的数据并在星期二抛出异常时,A不在乎-A的单元测试中已经涵盖了这些情况。


如果X刚将返回数组中的索引从foobar重命名为foobarRandomNumber怎么办,该如何计算呢?如果您明白我的意思,那基本上是我的问题,我将返回的列从secondName重命名为姓,这是一个经典的任务,但是我的测试永远不会知道,因为它被嘲笑了。我有一种奇怪的感觉,好像这个问题中的许多人在发表评论之前从未真正尝试过类似的事情
FantomX1

编译器应该已经检测到此更改,并给了您一个编译器错误。如果您使用Javascript之类的东西,那么我建议您切换到Typescript或使用Babel这样的编译器来检测这些东西。
Moby Disk

如果我在PHP或Java或Javascript中使用数组,如果更改数组索引或将其删除,这些语言中的任何一种都不会由编译器告诉您,该索引可能嵌套在第36位-想出的数字数组的嵌套级别,因此我认为编译器不是解决方案。
FantomX1

-2

您必须查看不同的测试。

单元测试本身只会测试X。它们在那里是为了防止您更改X的行为,但不能保护整个系统。他们确保您可以在不改变行为的情况下重构您的班级。如果打破X,就打破它...

实际上,A应该为其单元测试模拟X,并且即使更改了Mock,其测试也应保持通过。

但是测试不止一个级别!也有集成测试。这些测试可以验证类之间的交互。这些测试通常价格较高,因为它们并未对所有内容都使用模拟。例如,集成测试实际上可能会将记录写入数据库,而单元测试应该没有外部依赖关系。

同样,如果X需要具有新的行为,最好提供一种能够提供所需结果的新方法

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.