在不进行广泛模拟的情况下,应该如何编写单元测试?


80

据我了解,单元测试的重点是隔离测试代码单元。这意味着:

  1. 它们不应被代码库中其他地方不相关的代码更改破坏。
  2. 与集成测试相反(集成测试可能会中断),只有一个单元测试应该通过被测试单元中的错误来破坏。

所有这些暗示着,应该模拟掉测试单元的所有外部依赖关系。我的意思是所有外部依赖关系,而不仅仅是网络,文件系统,数据库等“外部层”。

这得出一个合理的结论,几乎每个单元测试都需要模拟。另一方面,谷歌对嘲笑的快速搜索显示了成千上万的文章声称“嘲笑是一种代码味道”,应该(尽管不完全)避免。

现在,到问题。

  1. 单元测试应如何正确编写?
  2. 它们和集成测试之间的界线到底在哪里?

更新1

请考虑以下伪代码:

class Person {
    constructor(calculator) {}

    calculate(a, b) {
        const sum = this.calculator.add(a, b);

        // do some other stuff with the `sum`
    }
}

可以在Person.calculate不模拟Calculator依赖关系的情况下测试该方法的测试(假定,它Calculator是不访问“外部世界”的轻量级类)可以视为单元测试吗?


10
其中一部分只是随着时间的流逝而产生的设计经验。您将学习如何构造组件,以使它们不存在很多难以模拟的依赖关系。这意味着可测试性必须是任何软件的次要设计目标。如果在代码之前或与代码一起编写测试(例如使用TDD和/或BDD),则可以轻松实现该目标。
阿蒙

33
将快速可靠地运行的测试放在一个文件夹中。将缓慢且可能易碎的测试放入另一个测试中。尽可能频繁地在第一个文件夹中运行测试(从字面上看,每次您键入内容时暂停并且代码编译都是理想的选择,但并非所有开发环境都支持此功能)。较少运行较慢的测试(例如,在喝咖啡休息时间时)。不必担心单元和集成名称。如果需要,可以快速和缓慢地打电话给他们。没关系
David Arno

6
“几乎每个单元测试都需要模拟” ,是吗?“关于嘲讽快速谷歌搜索发现吨声称该篇‘嘲讽是一个代码味道’,” 他们错了
迈克尔

13
@Michael简单地说“是的,所以”并宣布相反的观点是错误的,并不是解决此类有争议主题的好方法。也许写一个答案并详细说明为什么您认为模拟应该无处不在,以及为什么您认为“大量文章”本质上是错误的?
AJFaraday

6
由于您没有引用“模拟是一种代码味道”,因此我只能猜测您在误读所读内容。模拟不是代码的味道。需要使用反射或其他恶作剧方法来注入您的模拟内容是一种代码味道。模拟的难度与API设计的质量成反比。如果您可以编写简单的简单单元测试,将模拟传递给构造函数,那么您做对了。
TKK

Answers:


59

单元测试的重点是隔离测试代码单元。

Martin Fowler进行单元测试

单元测试经常在软件开发中谈论,这是我在编写程序的整个过程中所熟悉的一个术语。但是,就像大多数软件开发术语一样,它的定义也很不明确,我看到人们常常认为定义的定义比实际定义更严格,常常会产生混淆。

举例来说, Kent Beck在“ 测试驱动开发”中写的内容

我称它们为“单元测试”,但它们与公认的单元测试定义不太匹配

任何给定的“单元测试点是”的主张将在很大程度上取决于所考虑的“单元测试”的定义。

如果您的观点是您的程序是由许多相互依赖的小单元组成的,并且如果您将自己约束为一种单独测试每个单元的样式,那么很多测试倍数都是不可避免的结论。

您看到的相互矛盾的建议来自在不同假设条件下工作的人员。

例如,如果您在重构过程中编写测试以支持开发人员,并且将一个单元拆分为两个是应该支持的重构,则需要提供一些支持。也许这种测试需要一个不同的名称?也许我们需要对“单位”有所不同。

您可能要比较:

可以在不模拟Calculator依赖项的情况下测试Person.calculate方法的测试(假定Calculator是不访问“外界”的轻量级类)是否可以视为单元测试?

我认为这是一个错误的问题。当我相信我们真正关心的是属性时,这又是关于标签的争论。

当我在代码中引入更改时,我并不关心测试的隔离性-我已经知道“错误”在我当前未验证的编辑中。如果我经常运行测试,那么我会限制该堆栈的深度,并发现错误是微不足道的(在极端情况下,每次编辑后都要运行测试-堆栈的最大深度为1)。但是运行测试不是目标,而是中断,因此减少中断的影响是有价值的。减少中断的一种方法是确保测试速​​度快(加里·伯恩哈特建议300ms,但我还没有弄清楚在我的情况下该怎么做)。

如果调用Calculator::add不会显着增加运行测试所需的时间(或此用例的任何其他重要属性),那么我就不会打扰使用双重测试-它不会提供超过成本的收益。

请注意此处的两个假设:作为成本评估一部分的人员,以及利益评估中未经验证的变更的简短堆栈。在那些条件不成立的情况下,“隔离”的值会发生很大变化。

另请参见Harry Percival撰写的Hot Lava


5
模拟添加所做的一件事是证明计算器可以被模拟,即设计不会将人和计算器耦合在一起(尽管这也可以通过其他方式进行检查)
jk。

40

在不进行广泛模拟的情况下,应该如何编写单元测试?

通过最大程度地减少代码中的副作用。

以您的示例代码为例,例如,如果calculator与Web API对话,那么您要么创建依赖于能够与该Web API交互的脆弱测试,要么创建它的模拟。但是,如果它是确定性的,无状态的计算函数集,那么您就不要(也不应该)模拟它。如果这样做,则冒着模拟行为与真实代码不同的风险,从而导致测试中的错误。

仅应使用读/写到文件系统,数据库,URL端点等的代码来伪装;取决于您所运行的环境;或者本质上是高度有状态且不确定的。因此,如果将代码的这些部分减至最少并将它们隐藏在抽象之后,那么它们很容易被模拟,而其余的代码则避免了对模拟的需求。

对于确实具有副作用的代码点,值得编写模拟的测试和没有模拟的测试。尽管后者需要小心,因为它们本来就很脆弱并且可能很慢。因此,您可能只想在CI服务器上过夜运行它们,而不是每次保存和构建代码时都运行它们。尽管以前的测试应该在可行的情况下经常运行。至于每个测试是单元测试还是集成测试,都将成为学术性测试,避免在单元测试和非单元测试之间发生“激烈的争论”。


8
无论是在实践中还是在避免毫无意义的语义辩论方面,这都是正确的答案。
贾里德·史密斯

您是否有使用这种样式并且仍然获得良好测试覆盖率的非平凡开源代码库的示例?
Joeri Sebrechts

4
@JoeriSebrechts每个FP都一个?示例
Jared Smith,

并不是我要找的东西,因为那只是彼此独立的功能的集合,而不是相互调用的功能的系统。如果该函数是顶级函数之一,那么为了测试它,该如何构造复杂的参数呢?例如游戏的核心循环。
Joeri Sebrechts

1
@JoeriSebrechts嗯,或者我误会了你想要的东西,或者您对我所举的例子没有足够深入的了解。ramda函数在其源(例如R.equals)中的各处使用内部调用。因为这些大多数都是纯函数,所以通常不会在测试中将它们模拟掉。
贾里德·史密斯

31

这些问题的难度截然不同。让我们首先考虑问题2。

单元测试和集成测试明确分开。单元测试将测试一个单元(方法或类),并仅使用达到该目标所需的其他单元。可能需要模拟,但这不是测试的重点。集成测试测试不同实际单元之间的交互。这种差异是我们同时需要单元测试和集成测试的全部原因-如果一个测试者能够很好地完成另一个测试者的工作,我们就不会这样做,但是事实证明,使用两个专用工具比使用一个通用工具通常更有效。

现在针对重要问题:您应该如何进行单元测试?如上所述,单元测试应仅在必要时构造辅助结构。通常,使用模拟数据库比使用真实数据库甚至任何真实数据库都容易。但是,嘲笑本身没有任何价值。如果经常发生,实际上使用另一层的实际组件作为中级单元测试的输入会更容易。如果是这样,请毫不犹豫地使用它们。

许多从业人员担心,如果单元测试B重用已经由单元测试A测试过的类,那么单元A中的缺陷将在多个地方导致测试失败。我认为这不是问题:测试套件必须成功100%才能给您所需的保证,因此出现太多失败并不是一个大问题-毕竟,您确实有缺陷。唯一关键的问题是,如果一个缺陷引发过一些故障。

因此,不要嘲笑宗教。这是一种手段,而不是目的,因此,如果您可以避免不必要的努力,那么您应该这样做。


4
The only critical problem would be if a defect triggered too few failures.这是嘲弄的弱点之一。我们必须对预期的行为进行“编程”,因此,这样做可能会失败,导致测试以“误报”结束。但是,为了达到确定性(测试的最重要条件),模拟是一种非常有用的技术。我尽可能在所有项目中使用它们。他们还向我展示了集成过于复杂或依赖性过紧的情况。
Laiv

1
如果要测试的单元使用其他单元,那真的不是集成测试吗?因为从本质上讲,这个单元将像集成测试一样测试这些单元之间的交互。
亚历山大·洛米亚

11
@AlexanderLomia:您将如何称呼一个单位?您是否也将“字符串”称为单位?我会的,但是我不会梦想嘲笑它。
Bart van Ingen Schenau,

5
单元测试和集成测试明确分开。一个单元测试测试一个单元(方法或类),并仅使用达到该目标所需的其他单元 ”。这是擦。那就是对单元测试的定义。我的完全不同。因此,对于任何给定的定义,它们之间的区别只是“清楚地分开”,但是不同定义之间的区别是不同的。
David Arno

4
@Voo使用了这样的代码库后,找到原始问题可能会很麻烦(特别是如果该体系结构覆盖了您用于调试它的内容),但由于模拟导致了用于测试的代码损坏后才能继续工作的测试。
James_pic

6

好的,因此直接回答您的问题:

单元测试应如何正确编写?

如您所说,您应该模拟依赖关系并仅测试所讨论的单元。

它们和集成测试之间的界线到底在哪里?

集成测试是不模拟依赖项的单元测试。

在不模拟计算器的情况下测试Person.calculate方法的测试是否可以视为单元测试?

不需要。您需要将计算器依赖项注入此代码中,并且可以在模拟版本或真实版本之间进行选择。如果使用模拟的单元测试,则使用模拟的单元测试。

但是,请注意。您是否真的在乎人们认为应该调用您的测试吗?

但是您真正的问题似乎是这样的:

Google对嘲笑的快速搜索显示了成千上万的文章,其中声称“嘲笑是一种代码味道”,应该(尽管不完全)避免。

我认为这里的问题是很多人使用模拟来完全重新创建依赖关系。例如,我可能在您的示例中嘲笑计算器为

public class MockCalc : ICalculator
{
     public Add(int a, int b) { return 4; }
}

我不会做类似的事情:

myMock = Mock<ICalculator>().Add((a,b) => {return a + b;})
myPerson.Calculate()
Assert.WasCalled(myMock.Add());

我认为这将是“测试我的模拟”或“测试实现”。我会说“ 别写傻瓜! *那样”。

其他人会不同意我的看法,我们会在博客上展开有关“最佳模拟方法”的大规模火焰大战,除非您了解各种方法的整个背景并且确实没有提供很多价值,否则这实际上是没有意义的给只想编写好的测试的人。


感谢您提供详尽的答案。当关心其他人对我的测试的看法时,实际上我想避免编写半集成,半单元测试,因为随着项目的进行,这些测试往往变得不可靠。
亚历山大·洛米亚

没有问题,我认为问题在于这两个事物的定义并不是每个人都100%同意的。
伊万

我会将您的课程重命名MockCalcStubCalc,并将其称为存根而不是模拟。martinfowler.com/articles/...
BDSL

@bdsl这篇文章是15岁
伊万

4
  1. 单元测试应如何正确实施?

我的经验法则是适当的单元测试:

  • 针对接口而非实现进行编码。这有很多好处。首先,它可以确保你的类遵循依赖倒置原则固体。另外,这是您其他班级的工作(对吗?),因此您的测试也应这样做。同样,这允许您在重用许多测试代码的同时测试同一接口的多个实现(仅初始化和某些断言会更改)。
  • 是自成体系的。如您所说,任何外部代码的更改都不会影响测试结果。这样,单元测试可以在构建时执行。这意味着您需要模拟来消除任何副作用。但是,如果您遵循依赖倒置原则,这应该相对容易。诸如Spock之类的良好测试框架可用于动态提供任何接口的模拟实现,以最少的编码即可在您的测试中使用。这意味着每个测试类仅需要执行来自一个实现类的代码,再加上测试框架(可能还有模型类[“ beans”])。
  • 不需要单独的正在运行的应用程序。如果测试需要“交谈”,无论是数据库还是Web服务,它都是集成测试,而不是单元测试。我在网络连接或文件系统处画线。例如,在我看来,如果您真的需要纯内存SQLite数据库,则对于单元测试来说这是公平的游戏。

如果框架中有实用程序类使单元测试复杂化,那么您甚至可能会发现创建非常简单的“包装”接口和类以简化对这些依赖关系的模拟很有用。这些包装器将不必进行单元测试。

  1. 它们(单元测试)和集成测试之间的界限到底在哪里?

我发现这种区别是最有用的:

  • 单元测试模拟“用户代码”,对照代码级接口的所需行为和语义验证实现类的行为。
  • 集成测试可以模拟用户,并根据指定的用例和/或正式的API 验证正在运行的应用程序的行为。对于Web服务,“用户”将是客户端应用程序。

这里有灰色区域。例如,如果您可以在Docker容器中运行应用程序并在构建的最后阶段运行集成测试,然后再销毁该容器,那么可以将这些测试作为“单元测试”包括在内吗?如果这是您的激烈辩论,那么您来个不错的地方。

  1. 几乎每个单元测试都需要模拟吗?

否。某些单独的测试用例将针对错误情况,例如null作为参数传递并验证是否获得异常。像这样的许多测试不需要任何模拟。同样,没有副作用的实现(例如字符串处理或数学函数)可能不需要任何模拟,因为您只需验证输出即可。但是,我认为大多数值得拥有的类都将在测试代码中的某处至少需要一个模拟。(越少越好。)

您提到的“代码异味”问题是在您的类过于复杂时出现的,该类需要一长串模拟依赖项才能编写测试。这是一个线索,您需要重构实现并进行分解,以使每个类的足迹更小,职责更明确,因此更易于测试。从长远来看,这将提高质量。

只有一个单元测试应该被测试单元中的错误打破。

我认为这不是一个合理的期望,因为它可以防止重用。private例如,您可能有一个方法,该public方法由您的接口发布的多个方法调用。一种方法中引入的错误可能会导致多个测试失败。这并不意味着您应该将相同的代码复制到每个public方法中。


3
  1. 它们不应被代码库中其他地方不相关的代码更改破坏。

我不确定这条规则的用处。如果一个类/方法/任何方面的改变都可能破坏生产代码中另一种的行为,那么实际上这些事情就是合作者,并且并非无关紧要。如果您的测试失败,而您的生产代码没有,则您的测试是可疑的。

  1. 与集成测试相反(集成测试可能会中断),只有一个单元测试应该通过被测试单元中的错误来破坏。

我也会怀疑这一规则。如果您真的足够擅长构造代码并编写测试,以使一个bug恰好导致一个单元测试失败,那么您就意味着您已经确定了所有潜在的bug,即使在代码库演变为用例时,没想到

它们和集成测试之间的界线到底在哪里?

我认为这不是重要的区别。无论如何,代码的“单位”是什么?

尝试找到可以根据该级别的代码正在处理的问题域/业务规则编写“有意义的”测试的入口点。通常,这些测试本质上在某种程度上是“功能性的”-输入,然后测试输出是否符合预期。如果测试表达了系统的期望行为,那么即使生产代码不断发展和重构,它们也往往保持相当稳定。

在不进行广泛模拟的情况下,应该如何编写单元测试?

不要对“单元”一词有太多的了解,而是倾向于在测试中使用实际的生产类,而不必担心如果在一个测试中涉及多个生产类。如果其中之一很难使用(因为它需要大量初始化,或者需要访问真实的数据库/电子邮件服务器等),那么您的想法就变成了嘲笑/伪造。


无论如何,代码的“单位”是什么? ”一个很好的问题,它可能具有意想不到的答案,甚至可能取决于谁在回答。通常,大多数单元测试的定义都将它们解释为与方法或类有关,但这并不是在所有情况下对“单元”的真正有用的度量。如果我有一个Person:tellStory()方法将一个人的详细信息合并到一个字符串中,然后返回该字符串,则“故事”可能是一个单位。如果我创建了一个私有的助手方法来删除一些代码,那么我不相信我已经引入了一个新的单元-我不需要单独测试。
VLAZ

1

首先,一些定义:

单元测试可以与其他单元隔离地测试单元,但是这没有任何权威来源具体定义的含义,因此让我们对其进行更好的定义:如果I / O边界被越过(无论I / O是网络,磁盘,屏幕或用户界面输入),我们可以画一条半客观的地方。如果代码依赖于I / O,则它跨越了单元边界,因此它将需要模拟负责该I / O的单元。

在这个定义下,我看不出有什么令人信服的理由来模拟诸如纯函数之类的东西,这意味着单元测试适合于纯函数或没有副作用的函数。

如果要对具有效果的单元进行单元测试,则应模拟负责效果的单元,但也许应该考虑进行集成测试。因此,简短的答案是:“如果您需要模拟,请问问自己,您真正需要的是集成测试吗?” 但是,这里有一个更好的更长的答案,而且兔子的洞更深了。嘲笑可能是我最喜欢的代码味道,因为有很多东西可以学到。

代码闻起来

为此,我们将转向维基百科:

在计算机编程中,代码气味是程序源代码中的任何特征,它可能表示更深层次的问题。

以后再继续...

“气味是代码中的某些结构,它们表明违反了基本设计原则并对设计质量产生了负面影响”。Suryanarayana,Girish(2014年11月)。重构软件设计的气味。摩根·考夫曼。p。258。

代码气味通常不是错误。它们在技术上不是不正确的,并且不会阻止程序运行。相反,它们表示设计上的弱点可能会减慢开发速度,或增加将来出现错误或故障的风险。

换句话说,并不是所有的代码气味都是不好的。相反,它们是通常的指示,即某些内容可能无法以最佳形式表示,并且气味可能表明有机会改进所讨论的代码。

在嘲笑的情况下,气味表明似乎在要求嘲笑的单位取决于要嘲笑的单位。这可能表明我们尚未将问题分解为原子可解决的部分,可能表明软件中存在设计缺陷。

所有软件开发的本质是将一个大问题分解成多个较小的独立部分(分解)并将解决方案组合在一起以形成解决该大问题(组合)的应用程序的过程。

当用于将大问题分解为较小部分的单元彼此依赖时,需要进行模拟。换句话说,当我们假定的组成的原子单位不是真正的原子时,就需要进行模拟,而我们的分解策略未能将较大的问题分解为较小的独立问题来解决。

嘲笑代码的原因并不在于嘲笑本身没有任何错误-有时它非常有用。使其成为代码异味的原因是,这可能表明您的应用程序中存在耦合问题。有时,删除耦合源比编写模拟要有效得多。

耦合的种类很多,有些比其他的要好。理解模拟是一种代码异味,可以教会您在应用程序设计生命周期的早期识别并避免最坏的情况,然后再将其发展为更糟的情况。


0

即使在单元测试中,模拟也只能作为不得已的手段。

方法不是单位,甚至类也不是单位。单元是任何有意义的代码逻辑分隔,无论您如何称呼它。拥有经过良好测试的代码的一个重要因素是能够自由重构,而部分能够自由重构意味着您不必为此而更改测试。模拟越多,重构时就越需要更改测试。如果您将方法视为单位,则每次重构时都必须更改测试。而且,如果您以班级为单位,那么每次要将班级分解为多个班级时,都必须更改测试。当您必须重构测试以重构代码时,它使人们选择不重构其代码,这几乎是项目可能发生的最糟糕的事情。至关重要的是,您可以将一个类别分为多个类别,而不必重构测试,否则您最终将获得超过500行的意大利面条类别。如果使用单元测试将方法或类视为单元,则可能不是在进行面向对象的编程,而是对对象进行某种形式的突变函数编程。

隔离用于单元测试的代码并不意味着您可以模拟其外部的所有内容。如果确实如此,则必须模拟语言的Math类,而且绝对没有人认为这是一个好主意。内部依赖关系与外部依赖关系不应有任何区别。您相信它们已经过了良好的测试,并且可以按预期工作。唯一真正的区别是,如果内部依赖关系破坏了模块,则可以停止您要进行修复的工作,而不必在GitHub上发布问题,然后深入研究您不知道要修复的代码库或希望最好。

隔离代码仅意味着您将内部依赖项视为黑匣子,并且不测试内部发生的事情。如果您有接受输入1、2或3的模块B,并且有调用它的模块A,则没有对模块A进行的测试就可以选择其中的每个选项,而只需选择其中一个即可使用。这意味着您对模块A的测试应该测试对待模块B响应的不同方式,而不是传递给模块B的东西。

因此,如果您的控制器将一个复杂的对象传递给一个依赖项,并且该依赖项执行了几种可能的操作,则可能将其保存到数据库中并可能返回各种错误,但是您的控制器实际上所做的只是检查它是否返回是否有错误并传递该信息,那么您在控制器中进行的测试就是对是否返回错误进行传递的一项测试,以及对是否未返回错误进行传递的一项测试。您无需测试是否将某些内容保存在数据库中或该错误是哪种错误,因为这将是一个集成测试。为此,您不必模拟依赖项。您已经隔离了代码。

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.