什么需要进行单元测试

3

我有些困惑应该花费多少时间来编写单元测试。

假设我有一个简单的函数,像这样:

appendRepeats(StringBuilder strB, char c, int repeats)

[此函数将字符 c 重复多次附加到字符串 strB 中。 例如:

strB = "hello"
c = "h"
repeats = 5
// result
strB = "hellohhhhh"

对于这个函数的单元测试,我感觉已经有很多可能性了:

  • AppendRepeats_ZeroRepeats_DontAppend(追加重复零次,不追加)
  • AppendRepeats_NegativeRepeats_DontAppend(追加负数次,不追加)
  • AppendRepeats_PositiveRepeats_Append(追加正数次,追加)
  • AppendRepeats_NullStrBZeroRepeats_DontAppend(空字符串B追加重复零次,不追加)
  • AppendRepeats_NullStrBNegativeRepeats_DontAppend(空字符串B追加负数次,不追加)
  • AppendRepeats_NullStrBPositiveRepeats_Append(空字符串B追加正数次,追加)
  • AppendRepeats_EmptyStrBZeroRepeats_DontAppend(空串B追加重复零次,不追加)
  • AppendRepeats_EmptyStrBNegativeRepeats_DontAppend(空串B追加负数次,不追加)
  • AppendRepeats_EmptyStrBPositiveRepeats_Append(空串B追加正数次,追加)
  • 等等等等。字符串B可以为空或有值。C可以为空或有值。重复次数可以是负数、正数或零。

这似乎已经有 3 * 2 * 3 = 18 个测试方法了。如果这些函数也需要测试特殊字符、Integer.MIN_VALUE、Integer.MAX_VALUE 等等,可能还会有更多的测试方法。我的停止线应该是什么呢?

对于我的程序,我应该假设:

  • 字符串B只能为空或有值
  • C 有值
  • 重复次数只能为空或为正数

抱歉打扰了。我真的很困惑,我应该在单元测试中变得多么谨慎。我应该坚持我的假设范围内吗,还是说这是不好的做法,我应该为每种情况都有一个方法,在这种情况下,单元测试方法的数量将迅速呈指数级增长。


1
也许有些参数比其他参数更重要,如果 StringBuilder 为空,则我假设该方法无法执行任何操作,因此如果 strB 为空,则其他参数值无关紧要,因此只需要进行一次测试。您还应该使用测试覆盖工具,并了解需要覆盖多少百分比,不一定是100%。 - Joakim Danielson
基于属性的测试在这种情况下可能很有趣。 - Harald Gliebe
@NathanHughes 您是对的。我只需要针对 StringBuilder 的空值情况进行测试。但这仍然需要 12 个单元测试来测试一个简单的方法。 - AccCreate
@Khelwood 哇,谢谢。那么我猜每个函数只需要一个单元测试方法来测试所有情况? - AccCreate
@AccCreate 如果您可以使用一个参数化测试覆盖所需的所有情况,那就太好了。 - khelwood
显示剩余2条评论
4个回答

3

没有正确答案,这是个人意见和感受的问题。

但是,有些事情我认为是普遍适用的:

  • 如果你采用测试驱动开发(TDD),在编写任何非测试代码之前必须先编写失败的单元测试。这将引导你编写的测试数量。通过一些TDD的经验,你会逐渐掌握这个技能,即使需要为旧代码编写单元测试,你也能够像编写TDD代码一样编写测试。
  • 如果一个类拥有过多的单元测试,那就说明这个类做了太多的事情。但“太多”的定义很难量化。当你感觉到测试太多时,尝试将该类分解成更多的类,每个类负责更少的职责。
  • 模拟是单元测试的基础——如果不模拟协作者,你就不是在测试这个“单位”了。因此,学习使用模拟框架。
  • 检查空值并对其进行测试可能会增加很多代码。如果你采用一种永远不会产生null的编程风格,则你的代码永远不需要处理null,也就不需要测试在那种情况下会发生什么。
    • 当然,也有例外,例如如果你正在提供库代码,并希望向调用者提供友好的无效参数错误
  • 对于某些方法,属性测试可能是一个可行的方式来进行大量的测试。jUnit的@Theory就是其中一种实现方法。它允许你测试如“plus(x,y)返回任何正x和正y时都是正数”的断言

2

通常的经验法则是,你的代码中每个“分支”都应该有一个测试,这意味着你应该覆盖所有可能的边缘情况。

例如,如果你有以下代码:

Original Answer翻译成"最初的回答"

if (x != null) {
  if (x.length > 100) {
    // do something  
  } else {
    // do something else
  }
} else {
  // do something completely else
}

你应该有三个测试用例-一个针对null,一个针对长度小于100的值,以及一个针对更长的值。 如果你很严格,想要覆盖100%,那就这样做。
无论是不同的测试还是参数化,都不太重要,更多的是风格问题,你可以选择任何一种方式。我认为更重要的是覆盖所有情况。

1
这似乎表明,您的测试结构应该基于您正在测试的方法的实现,而不是基于其行为的期望,独立于实现细节。 - khelwood
1
@khelwood - 如果你在谈论单元测试,那么大多数情况下是需要的。这也取决于你如何将其分解为类和函数。对于系统/集成测试来说情况就不同了,API定义了测试。 - Nir Levy
2
我认为这个建议并不一定是基于实现的,如果你从规范编写测试或者使用TDD编码,你都会遇到这些决策点。区别在于,如果你有一个规范,它很可能是不完整的。 - Nathan Hughes

2
你所开发的测试用例是黑盒测试设计方法的结果,实际上它们看起来像是你应用了分类树方法。在进行单元测试时,暂时采用黑盒视角是完全可以的,但仅限于黑盒测试可能会产生一些不良影响:首先,正如你所观察到的那样,你可能会得到每个输入的所有可能情况的笛卡尔积;其次,你可能仍然无法发现特定于所选择实现的错误。

通过(同时)采用白盒视角,你可以避免创建无用的测试:知道你的代码第一步处理重复次数为负数的特殊情况意味着你不必将此场景与其他所有场景相乘。当然,这意味着你正在利用自己对实现细节的了解:如果你稍后更改了代码,使检查负重复出现在几个位置上,那么最好也调整一下你的测试套件。

由于似乎存在广泛的关于测试实现细节的担忧:单元测试是关于测试实现的。不同的实现具有不同的潜在错误。如果你不使用单元测试来发现这些错误,那么任何其他测试级别(集成、子系统、系统)肯定都不适合系统地发现它们——在一个更大的项目中,你不希望实现级别的错误逃脱到后期开发阶段甚至领域。顺便说一句,覆盖率分析意味着你采用了白盒视角,而TDD也是如此。

然而,测试套件或单个测试不应该不必要地依赖于实现细节——但这是一个与不完全依赖实现细节不同的陈述。因此,一个合理的方法是,有一组从黑盒视角看起来有意义的测试,以及旨在捕获那些特定于实现的错误的测试。当你改变代码时,后者需要进行调整,但可以通过各种手段来减少工作量,例如使用测试辅助方法等。

在你的情况下,采用白盒视角可能会将负重复次数的测试数量减少到一个,还有空字符案例,可能还有NullStrB案例(假设你通过将null替换为空字符串来尽早处理它),等等。


1
首先,使用代码覆盖工具。这将显示测试执行的代码行。IDE有代码覆盖工具插件,可以运行测试并查看执行的代码行。目标是覆盖每一行,对于某些情况可能很难,但对于此类实用程序而言是非常可行的。
使用代码覆盖工具可以突出未覆盖的边缘情况。对于难以实现的测试,代码覆盖会显示测试执行的代码行,因此,如果测试中存在错误,您可以看到它执行了多远。
接下来,要理解没有测试覆盖所有内容。总会有一些你没有测试的值。因此,请选择感兴趣的代表性输入,并避免看起来多余的输入。例如,传递一个空的StringBuilder是否真的很重要?它不影响代码的行为。有一些特殊的值可能会导致问题,比如null。如果您正在测试二分查找,则需要覆盖数组非常大的情况,以查看中点计算是否溢出。寻找重要的情况。
如果您事先验证并排除麻烦的值,则无需进行太多测试工作。一个测试用于传递null StringBuilder以验证是否抛出IllegalArgumentException,一个测试用于负重复值以验证是否为其抛出异常。

最后,测试是为开发人员而设计的。做对你有用的事情。


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