重复的代码在单元测试中是否更容易被容忍?

149
我一段时间前犯了一个错误,当我重构单元测试以使它们更加DRY时,我毁掉了几个单元测试--每个测试的意图不再清晰。似乎在测试的可读性和可维护性之间存在权衡。如果我在单元测试中保留重复的代码,它们会更易读,但是如果我改变SUT,我就必须追踪并更改每个重复代码的副本。
你认为这种权衡存在吗?如果是这样,你更喜欢你的测试是可读的还是可维护的?
11个回答

236

在测试中易读性比其他要素更为重要。如果一个测试失败了,你希望问题显而易见。开发者不应该需要浏览许多经过大量分解的测试代码才能确定到底哪里出了错。你不希望自己的测试代码变得太过复杂以至于需要编写单元测试的测试。

然而,消除重复通常是一件好事,只要它不会掩盖任何东西,并且消除测试中的重复可能会导致更好的API。只需确保不超过收益递减点即可。


xUnit和其他测试框架中的断言调用中包含一个“message”参数。添加有意义的短语以便开发人员能够快速找到失败的测试结果是个好主意。 - seand
1
@seand 你可以尝试解释一下你的断言在检查什么,但是当它失败并且包含有些模糊的代码时,开发人员仍然需要去理清它。在我看来,让代码自我描述更为重要。 - IgorK
对于报告的可读性比测试更重要,特别是在进行集成或端到端测试时,场景可能足够复杂,以至于避免浏览一点点都有困难。找到失败很好,但对我来说,在报告中的失败应该足够清楚地解释问题。 - Anirudh
3
在测试和非测试代码中,可读性同样重要。重构不应该破坏可读性,而是应该提高可读性。 - Logan Pickup
2
这里提出的一个好理由来自于DRY和单元测试背后的基本假设:重复自己是不好的,因为它会增加改变行为所需的工作量。另一方面,如果您经常更改单元测试,那么您的单元测试很可能过于严格-它们正在测试“如何”而不是“什么”。理想的单元测试在布置后不会更改。这意味着将DRY应用于单元测试并没有像应用于应用程序代码时那样带来太多利润。DRY是对变化的响应。单元测试不应该改变。 - Błażej Michalik
掌声响起:您不希望测试代码变得如此复杂,以至于您需要编写单元测试测试。 - Christian H

84

重复的代码在单元测试代码中与其他代码一样容易引起问题。如果测试中有重复的代码,这会使得重构实现代码变得更加困难,因为需要更新的测试数量是不成比例的。测试应该帮助您自信地进行重构,而不是成为阻碍您对被测试代码进行工作的大负担。

如果复制出现在 fixture 设置中,请考虑更多地使用setUp方法或提供更多(或更灵活的)创建方法

如果复制出现在操作 SUT 的代码中,请问一下自己,为什么多个所谓的“单元”测试要执行完全相同的功能。

如果复制出现在断言中,那么也许您需要一些自定义断言。例如,如果多个测试具有类似于以下字符串的断言:

assertEqual('Joe', person.getFirstName())
assertEqual('Bloggs', person.getLastName())
assertEqual(23, person.getAge())

也许你需要一个单独的assertPersonEqual方法,这样你就可以写assertPersonEqual(Person('Joe', 'Bloggs', 23), person)。(或者也许你只需要在Person上重载等号运算符。)

正如你所提到的,测试代码的可读性非常重要。特别是,测试的意图必须清晰明了。我发现,如果许多测试看起来大致相同(例如,四分之三的行相同或几乎相同),那么在不仔细阅读和比较它们的情况下很难发现和识别重要的差异。因此,我发现重构以消除重复有助于可读性,因为每个测试方法的每一行都与测试的目的直接相关。这对读者比直接相关的行和只是样板文件的行的随机组合更有帮助。

尽管如此,有时测试正在测试类似但仍然显着不同的复杂情况,很难找到减少重复的好方法。使用常识:如果您觉得测试可读并清楚地表达其意图,并且您对需要更新的测试数量超过理论上最小数量的情况感到满意,则接受不完美并转向更有成效的事情。当灵感来临时,您总是可以回来重构测试!


42
“重复代码在单元测试代码中同样会产生气味,就像在其他代码中一样。” 不是。 "如果您的测试中有重复的代码,则会使重构实现代码变得更加困难,因为您需要更新大量测试。" 这是因为您正在测试私有API而不是公共API。 - user11617
30
为了防止在单元测试中出现重复的代码,通常需要引入新的逻辑。我认为单元测试不应该包含逻辑,否则就需要对单元测试进行测试。 - Petr Peller
@user11617 请定义“私有 API”和“公共 API”。在我看来,公共 API 是对外可见的 API,并通过 SemVer 或类似方式明确版本控制,其他任何内容都是私有的。根据这个定义,几乎所有单元测试都在测试“私有 API”,因此更容易出现代码重复,我认为这是正确的。 - KolA
1
@KolA “Public”并不意味着第三方消费者 - 这不是一个Web API。类的公共API指的是旨在由客户端代码使用的方法(通常不应该经常更改) - 通常是“public”方法。私有API是指内部使用的逻辑和方法。这些不应该从类外部访问。这是正确封装类中的逻辑使用访问修饰符或所使用语言中的约定非常重要的原因之一。 - Nathan
@Nathan 任何库/动态链接库/nuget包都有第三方消费者,它不一定是Web API。我所指的是,声明公共类和成员并不是供库消费者直接使用的非常普遍(或者最好将它们设置为内部的,并用InternalsVisibleToAttribute注释程序集),只是为了允许单元测试直接访问它们。这会导致大量测试与实现耦合在一起,使它们更像负担而不是优势。 - KolA
2
有趣的是,我同意spiv和Kristopher Johnson的答案。我的解释是:你总是想避免在测试中重复实现细节,但永远不要以隐藏上下文为代价。移除重复代码的不良选择是继承链和类型组合,它们会隐藏上下文。移除重复代码的好选择是工厂、构建器和对象生成器。 - Derek Greer

67

实现代码和测试代码是不同的东西,因此它们需要应用不同的规则。

在实现代码中,重复的代码或结构总是一个坏味道。当你在实现中开始出现样板代码时,你需要重新审视你的抽象。

另一方面,测试代码必须保持一定程度的重复。测试代码中的重复达到了两个目标:

  • 保持测试独立。过多的测试耦合可能会使更改一个失败的测试变得困难,并且需要更新因为合同已经改变了。
  • 使测试在单独运行时有意义。当单个测试失败时,必须相对容易地找到它正在测试什么。

我倾向于忽略测试代码中的琐碎重复,只要每个测试方法保持在大约20行以下。我喜欢测试方法中明显的设置-运行-验证节奏。

当测试中的重复出现在“验证”部分时,通常有益于定义自定义的断言方法。当然,这些方法仍然必须测试一个清晰确定的关系,这可以在方法名中表现出来:assertPegFitsInHole -> 好,assertPegIsGood -> 不好。

当测试方法变得冗长和重复时,我有时会发现定义一些填空测试模板是有用的,这些模板需要几个参数。然后,实际的测试方法将被缩减为使用适当参数调用模板方法。

对于编程和测试中的很多事情,没有明确的答案。你需要培养一种口味,最好的方法就是犯错误。


1
+1 for "I like when the setup-run-verify rhythm is apparent in test methods." 就是我想说的。因此,在提取执行两个或多个设置-运行-验证阶段的公共方法之前,我会三思而后行。 - spikemanuk

10

您可以使用几种不同类型的测试工具方法来减少重复。

在测试代码中,我对重复比在生产代码中更加容忍,但有时我会感到沮丧。当您更改类的设计并且必须返回并调整所有执行相同设置步骤的10个不同测试方法时,这很令人沮丧。


9

我同意。权衡存在,但在不同的地方有所不同。

我更倾向于重构重复的设置状态的代码,但不太可能重构实际测试代码部分。话虽如此,如果测试代码部分始终需要多行代码,则我可能认为这是一个问题,并重构受测试的实际代码。这将提高代码和测试的可读性和可维护性。


我认为这是一个好主意。如果你有很多重复的代码,看能否进行重构,创建一个公共的“测试夹具”,可在其下运行许多测试。这将消除重复的安装/拆卸代码。 - Tim Frey

8

Jay Fields创造了“DSL应该是DAMP而不是DRY”的短语,其中DAMP代表描述性和有意义的短语。我认为同样适用于测试。显然,过多的重复很糟糕。但是不惜一切代价消除重复更糟糕。测试应该作为意图揭示的规范。例如,如果您从几个不同的角度指定相同的功能,则可以预期出现某种程度的重复。


3

我认为测试代码需要与生产代码一样的工程水平。虽然可读性是重要的,但根据我的经验,我发现精心因式分解的测试更容易阅读和理解。如果有五个测试看起来完全一样,除了一个变量和最后的断言不同,很难找到这个唯一的不同之处。同样,如果 only 变量和断言可见,那么很容易立即了解测试正在做什么。

在测试时寻找正确的抽象层次可能很困难,但我认为值得去做。


3
"我将它们重构以使它们更符合DRY原则 - 每个测试的意图不再清晰。"
"听起来你在重构时遇到了麻烦。我只是猜测,但如果最后结果变得不够清晰,那么这是否意味着你还需要更多工作,以便设计出既优雅又十分明确的测试套件呢?"
"这就是为什么测试是UnitTest的子类 - 这样您可以设计良好的测试套件,这些套件正确、易于验证和清晰。"
"在过去,我们有使用不同编程语言的测试工具。设计出愉悦、易于操作的测试非常困难(甚至不可能)。"
"您完全拥有任何语言的全部功能 - Python、Java、C#等 - 所以请充分利用该语言。您可以实现漂亮的测试代码,既清晰又不会过于冗余。没有任何折衷。"

3

我喜欢 RSpec,因为它具有以下两个帮助功能:

  • 共享的测试用例组,用于测试常见的行为。
    您可以定义一组测试,然后在实际测试中“包含”该组。

  • 嵌套上下文。
    您可以为特定子集的测试实质上设置“安装”和“拆卸”方法,而不仅仅是类中的每个测试。

.NET/Java/其他测试框架越早采用这些方法,越好(或者您可以使用 IronRuby 或 JRuby 编写测试,我个人认为这是更好的选择)。


1
噢...共享的隐式钩子已经够糟糕了,但是嵌套上下文通常会引起更多麻烦。 你的测试代码80行之前是块级别的“before”设置,但你不知道在那之上的200行甚至有更多的设置。被这样的问题困扰已经发生过太多次了。 - oligofren

2

我认为代码的重复度和可读性没有直接关系。我认为测试代码应该和其他代码一样优秀。当代码重复得好时,非重复代码比重复代码更易读。


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