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


它们不应因代码库中其他地方不相关的代码更改而中断。
只有一个单元测试应被测试单元中的错误破坏,因为相对于集成测试(可能会在堆中破裂)。

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

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

现在,问问题。


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


更新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是不访问“外部世界”的轻量级类)可以视为一个单元测试?

评论

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

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

“几乎每个单元测试都需要模拟”,是吗? “在Google上有关嘲笑的快速搜索显示了成千上万的文章声称“嘲笑是代码的味道””,这是错误的。

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

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

#1 楼


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


Martin Fowler进行单元测试


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


Kent Beck写道在测试驱动开发中,以示例为例


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


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

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

您看到的相互矛盾的建议来自人们

例如,如果您在重构过程中编写测试以支持开发人员,并拆分一个单元,从头到尾是一个应该支持的重构,然后需要提供一些东西。也许这种测试需要一个不同的名称?或者也许我们需要对“单元”有不同的理解。

您可能想要比较:


伊恩·库珀的TDD:哪里出错了
/>
JBRainsberger的集成测试是一个骗局



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


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

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

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

注意这里有两个假设:一个人作为成本评估的一部分,以及一小堆未经验证的收益评估变更。在那些条件不成立的情况下,“隔离”的值会发生很大变化。

另请参见Harry Percival的Hot Lava。

评论


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

– jk。
18年11月28日在12:33

#2 楼


如何在不进行大量模拟的情况下准确编写单元测试?


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

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

有副作用,值得编写模拟测试和非模拟测试。尽管后者需要注意,因为它们本来就很脆弱并且可能很慢。因此,您可能只想在CI服务器上过夜运行它们,而不是每次保存和构建代码时都运行它们。尽管以前的测试应该在可行的情况下尽可能频繁地进行。
关于每个测试是单元测试还是集成测试都变得学术化,避免了关于单元测试和非单元测试的“激烈争论”。

评论


无论是在实践中还是在避免毫无意义的语义辩论方面,这都是正确的答案。

–杰瑞德·史密斯(Jared Smith)
18年11月27日在12:45

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

– Joeri Sebrechts
18年11月27日在12:49

@JoeriSebrechts每个FP都一个?例

–杰瑞德·史密斯(Jared Smith)
18-11-27在17:36

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

– Joeri Sebrechts
18年11月28日在8:16

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

–杰瑞德·史密斯(Jared Smith)
18-11-29在13:40



#3 楼

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

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

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

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

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

评论


唯一的关键问题是缺陷引发的故障太少。这是嘲弄的弱点之一。我们必须对预期的行为进行“编程”,因此,这样做可能会失败,导致测试以“误报”结束。但是,为了达到确定性(测试的最重要条件),模拟是一种非常有用的技术。我尽可能在所有项目中使用它们。他们还向我展示了集成过于复杂或依赖性过紧的情况。

– Laiv
18-11-27在11:08



如果要测试的单元使用其他单元,那么它真的不是集成测试吗?因为从本质上讲,此单元将像集成测试一样测试这些单元之间的交互。

– Alex Lomia
18年11月27日在11:11

@AlexanderLomia:你会叫什么单位?您是否也将“字符串”称为单位?我会的,但是我不会梦想嘲笑它。

–巴特·范·恩根·舍瑙(Bart van Ingen Schenau)
18-11-27在11:36

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

– David Arno
18年11月27日在12:20

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

–James_pic
18年11月27日在17:05

#4 楼

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


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


如您所说,您应该在模拟依赖项并仅测试有问题的单元。


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


集成测试是一个单元测试不模拟依赖项的地方。


可以将不模拟计算器的情况下测试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());

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

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

评论


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

– Alex Lomia
18年11月27日在12:23

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

–伊万
18年11月27日在12:32

我将您的类MockCalc重命名为StubCalc,并将其称为存根而不是模拟。 martinfowler.com/articles/…

–bdsl
19年8月1日在13:18

@bdsl这篇文章已经15岁了

–伊万
19年8月1日在22:02

#5 楼



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



我的经验法则是正确的单元测试:



是针对接口而非实现进行编码的。这有很多好处。首先,它确保您的类遵循SOLID中的Dependency Inversion Principle。另外,这是您其他班级的工作(对吗?),因此您的测试也应这样做。此外,这还允许您在重用许多测试代码的同时测试同一接口的多种实现(仅初始化和某些断言会发生变化)。

是自包含的。如您所说,任何外部代码的更改都不会影响测试结果。这样,单元测试可以在构建时执行。这意味着您需要模拟来消除任何副作用。但是,如果您遵循依赖倒置原则,这应该相对容易。诸如Spock之类的良好测试框架可用于动态提供任何接口的模拟实现,以最少的编码即可在您的测试中使用。这意味着每个测试类只需要执行一个实现类中的代码,再加上测试框架(可能还有模型类[“ beans”])就可以了。

不需要单独的运行应用程序。如果测试需要“交谈”,无论是数据库还是Web服务,它都是集成测试,而不是单元测试。我在网络连接或文件系统处画线。例如,在我看来,如果您确实需要纯内存SQLite数据库,则对于单元测试来说这是公平的游戏。

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



它们之间的界限到底在哪里[单元测试]和集成测试在哪里?

/>

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



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

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



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



不。一些单独的测试用例将针对错误情况,例如,将null作为参数传递,并验证是否获得异常。像这样的许多测试不需要任何模拟。同样,没有副作用的实现(例如字符串处理或数学函数)可能不需要任何模拟,因为您只需验证输出即可。但是,我认为大多数值得拥有的类都将在测试代码中的某处至少需要一个模拟。 (越少越好。)您提到的“代码气味”问题是在您的类过于复杂时出现的,该类需要一长串模拟依赖项才能编写测试。这是一个线索,您需要重构实现并进行分解,以使每个类都具有更小的空间和更清晰的责任,因此更易于测试。从长远来看,这将提高质量。


只有一个单元测试会因被测试单元中的错误而中断。


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

#6 楼

即使在单元测试中,模拟也只能作为最后的选择。

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

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

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

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

#7 楼



它们不应被代码库中其他位置无关的代码更改所破坏。




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



只有一个单元测试应该被测试的单元中的错误破坏,而相反进行集成测试(可能会在堆中破裂)。




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


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


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

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


应该如何准确地编写单元测试而不进行大量模拟?


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

评论


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

– VLAZ
18年11月29日在6:57

#8 楼

首先,有一些定义:

单元测试将单元与其他单元隔离开来进行测试,但这并不意味着任何权威人士都具体定义了什么,因此让我们更好地定义一下:如果I / O边界交叉(无论I / O是网络,磁盘,屏幕还是UI输入),我们可以画一条半客观的地方。如果代码依赖于I / O,则它跨越了单元边界,因此它将需要模拟负责该I / O的单元。

在该定义下,我看不到令人信服的嘲笑诸如纯函数之类的东西的原因,这意味着单元测试适合于纯函数或没有副作用的函数。

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

代码气味

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


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


稍​​后还会继续...


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

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


换句话说,并不是所有的代码气味都是不好的。取而代之的是,它们通常指示某些内容可能无法以其最佳形式表达,并且气味可能表明有机会改进所讨论的代码。似乎需要模拟的单位取决于要模拟的单位。这可能表明我们尚未将问题分解为原子可解决的部分,这可能表明软件中存在设计缺陷。

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

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

模拟的起因是什么代码的味道并不是说模拟本质上是有问题的-有时它非常有用。使其成为代码异味的原因是,这可能表明您的应用程序中存在耦合问题。有时,删除耦合源要比编写模拟程序有效得多。

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

#9 楼

通过不写它们。因为并非所有代码都受益于单元测试。 TDD是一种技术,一种解决问题的工具,而不是单一的True Way™来编写所有代码。
单元测试仅应针对没有依赖性的代码编写。集成和端到端测试无法很好地覆盖代码。
因此,如果您发现自己在考虑使用模拟的情况下,这是一个信号,那就是不值得编写单元测试。因为您的测试将取决于实现细节,并且如果您更改实现,它们将始终中断,并且需要重写。测试的目的是防止回归,这是重构时可以依靠的东西。基于模拟的测试无法实现其本质。