【软件测试】变异驱动测试——当TDD不那么香的时候的时候
作为一个乐于讨论软件工艺和最佳实践的人,测试驱动开发(TDD)对我来说是一个痛点。首先我要说,我喜欢TDD对测试的重视。有太多的软件项目在测试上做得还不够。然而再要解决其带来的苦果就并非一朝一夕的事情,甚至其棘手程度都会让人唯恐避之不及。
不过,我从来都不是TDD的忠实粉丝。一方面,它太严格了。它坚持先编写测试,这常常会妨碍探索性的工作。然而确定正确的接口、方法和OO-structure应该是什么之前,探索性的工作是必需的。
但另一方面,具有讽刺意味的是,TDD又过于宽容。许多实践者认为,因为他们是在实践TDD,所以他们的测试套件当然坚如磐石。可在现实中,我见过太多在TDD期间编写的测试用例仍然受到覆盖不全的困扰。而覆盖不全的问题是会导致产品缺陷的。就测试方法而言,弥补这些覆盖漏洞应该是您的首要任务。
因此,我最喜欢的测试哲学可以通过变异驱动测试来加以说明。它遵循以下步骤:
1. 尝试达到一个既拥有代码且测试通过的状态
至于是先编写代码还是先编写测试用例的问题,可以另当别论了。当然欢迎您能使用TDD达到这一目的
2. 逐行检查新添加/修改的代码,然后手动注入一个bug
在确定什么是“合理的错误”时,我们只考虑那些粗心、懒惰、缺乏经验和能力局限之类的原因,而非“恶意”设计。因为使用测试来捕获恶意bug的难度是指数级的,而且不太现实。
3. 验证某些测试当前已经失败
4. 如果测试没有失败,请审查您的测试用例中有哪些覆盖漏洞,并通过添加新的测试用例或更新现有测试用例来修复它们
编写测试用例的能力越强,这种情况发生的频率就越低
5. 撤销您刚刚注入的bug,并验证您的测试现在可以通过
6. 回到第2步并重复,直到你已经注入了所有你能想到的bug,或者已到计划时间
当您掌握了变异驱动测试的诀窍后,您将掌握一种直观的技巧,来帮助找出哪些bug最有可能在普通的测试套件中成为漏网之鱼。这将极大地加快这个过程,并能帮助你编写更全面的测试
变异驱动测试背后的理念很简单。评估测试套件可靠性的唯一方法,是在出现bug时查看它是否会失败。所以去注射那个“细菌”吧!使用测试结果来找出您的覆盖漏洞在哪里,并恰当地改进它们。这种方法不仅是为了捕获您刚刚注入的特定bug,对任何其他类似类型的bug同样有效。通过这样做,您可以识别测试套件中的盲点,并相应地加强测试覆盖。
旁注:有一些工具试图将上述形式的变异测试以自动化方式来实现。我期待着有一天它们会成为主流而且同样全面。但目前,本文将重点关注手动注入变异。
A TDD Example
如果你比较关注,可能会听到成千上万的TDD支持者在抗议。
“可是如果你已经做过TDD,你就不需要做变异测试了!”如果你正确地使用TDD,那么每一项功能都会有一个专门的测试,所以你永远不会遇到覆盖不全!”
在此,我将以“TDD示例”在google的首个搜索结果为例,来解释为什么上述说法是不正确的,同时也来说明变异驱动测试的优势。
有人可能会说,示例的作者不是TDD的一个良好失范。“真正的TDD从业者绝不会”会像上面的例子那样编写测试。但我认为这倒有点是“吃不到葡萄说葡萄酸”。在我看来,作者在编写测试的时候已经比较全面且简单了。TDD在实际的工作场景下的现实情况是,大多数从业者并不完美,而且总是容易出现一些疏忽,而它们是可以通过变异驱动测试来标记和修复的。
转入正题,让我们深入研究这个例子——创建一个简单的基于字符串的计算器。为了简洁起见,让我们只看一下示例中的前3个需求,以及它们的实现和测试。
要求:
- 该方法可以取0、1或2个用逗号分隔的数字
- 对于空字符串,该方法将返回0
- 方法将返回它们的数字之和
Tests:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | private static final TddExample EXAMPLE = new TddExample();
@Test(expected = RuntimeException.class) public final void whenMoreThan2NumbersAreUsedThenExceptionIsThrown() { EXAMPLE.add("1,2,3"); }
@Test public final void when2NumbersAreUsedThenNoExceptionIsThrown() { EXAMPLE.add("1,2"); Assert.assertTrue(true); }
@Test(expected = RuntimeException.class) public final void whenNonNumberIsUsedThenExceptionIsThrown() { EXAMPLE.add("1,X"); }
@Test public final void whenEmptyStringIsUsedThenReturnValueIs0() { Assert.assertEquals(0, EXAMPLE.add("")); }
@Test public final void whenOneNumberIsUsedThenReturnValueIsThatSameNumber() { Assert.assertEquals(3, EXAMPLE.add("3")); }
@Test public final void whenTwoNumbersAreUsedThenReturnValueIsTheirSum() { Assert.assertEquals(3+6, EXAMPLE.add("3,6")); } |
Implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public int add(final String numbers) { int returnValue = 0; String[] numbersArray = numbers.split(","); if (numbersArray.length > 2) { throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed"); } for (String number : numbersArray) { if (!number.trim().isEmpty()) { // After refactoring returnValue += Integer.parseInt(number); } } return returnValue; } |
当然,这看起来像是一个涵盖了所有的功能的很好的测试套件。但是它能经受住变异驱动测试的考验吗? 在现实中,我会一次注入一个bug,并在每次注入之后运行测试。但是为了简洁起见,让我们一次注入所有相关的bug试试看。
变异1: Empty vs Blank
1 | if (!number.trim().isEmpty()) |
值得指出,如果我们从实现中删除trim()调用会怎样呢?这似乎是一个合理的疏忽。
1 | if (!number.isEmpty()) |
变异2: Return 0 for Empty String
1 2 3 | if (!number.trim().isEmpty()) { // After refactoring returnValue += Integer.parseInt(number); } |
要求为空字符串返回0。根据作者的设计,这可能意味着任何空的子字符串都应该被视为0,而在此之前的非空子字符串仍然应该被求和。但如果这个设计做了一些不同的事情,并在看到任何空字符串时都返回0,该怎么办?
1 2 | if (number.trim().isEmpty()) { return 0; } returnValue += Double.parseDouble(number); |
变异3: Three Inputs are not allowed
1 2 3 | if (numbersArray.length > 2) { throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed"); } |
要求说,该方法可以使用“0、1或2个数字”。“所以……我们应该检查3个数字并抛出一个异常?
无可否认,这是一个相当愚蠢的错误,但永远不要低估傻瓜的创造性。
1 2 3 | if (numbersArray.length == 3) { throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed"); } |
变异4: Double vs Int
1 | returnValue += Integer.parseInt(number); |
当想要构建一个“字符串->数字”的计算器,并带有一个整数的最终结果,是有很多不同的方法来实现字符串转换的:
- 将string转换为int,执行int操作,返回int结果。如果输入的字符串不是int类型,则抛出异常
- 将string类型转换为double类型,将double类型转换为int类型,执行int操作,返回int结果
- 将string转换为double,执行double操作,将最终结果转换为int并返回
以上3种方法在给出“1.5,1.5”这样的输入时都会产生完全不同的输出。在这个例子中,作者实现了第一种情况。让我们假设这确实是我们想要的行为。但如果他错误地执行了第三种情况呢?
1 | returnValue += Double.parseDouble(number); |
把所有情况整合到一起
在实践中,我们一次只注入一个bug。但是为了简洁起见,让我们把它们都结合起来,这就出现了下面的情况:
1 2 3 4 5 6 7 8 9 10 11 12 | public int add(final String numbers) { double returnValue = 0; String[] numbersArray = numbers.split(","); if (numbersArray.length == 3) { throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed"); } for (String number : numbersArray) { if (number.isEmpty()) { return 0; } returnValue += Double.parseDouble(number); } return (int) returnValue; } |
令人惊讶的是,没有一个测试的结果是失败的!尽管我们在每隔一行中注入了貌似可信的bug,但作者编写的每一个测试仍然是通过的。这就证明了我们的测试套件存在以下漏洞:
- 它没有测试blank输入
- 它没有测试应该被测到的empty / blank子字符串的数字
- 它没有测试任意数量的输入
- 它没有测试非整形数字输入
一旦你发现了上面的漏洞,你就可以通过添加更多的测试来填补它们。现在测试是失败的,并在您恢复所有注入的bug后开始通过:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @Test(expected = NumberFormatException.class) public void doubleInputProvided_shouldThrowException() { EXAMPLE.add("1.5,1.5"); }
@Test public void blankString_shouldReturn0() { Assert.assertEquals(0, EXAMPLE.add(" ")); }
@Test public void emptyStringAfterNumbers_shouldIgnoreIt() { Assert.assertEquals(1, EXAMPLE.add("1, ")); }
@Test public void arbitrarilyManyNumbersProvided_shouldThrowException() { StringBuilder inputs = new StringBuilder("3,4"); for (int i=0; i<10; i++) { inputs.append("," + ThreadLocalRandom.current().nextInt()); try { int result = EXAMPLE.add(inputs.toString()); Assert.fail("No exception thrown. Got result: " + result + ", for input: " + inputs.toString()); } catch (RuntimeException e) { Assert.assertEquals("Up to 2 numbers separated by comma (,) are allowed", e.getMessage()); } } } |
让我澄清一下——上述测试当然不是完美的。随着变异测试的进一步迭代,您当然可以识别出上面的测试所遗漏的更多覆盖不全的情况。此外,您的可以使用更复杂的测试技术,从而以一种简洁的方式增强您的测试覆盖。
但至少这个过程可以帮助我们更好地理解覆盖漏洞在哪里,并让我们尽可能多地消除最明显的漏洞。
但这值得吗?
诚然,完成上述过程需要额外的努力和时间,最终的结果将是一套冗长得多的测试,其中许多对没有受过训练的人来说似乎还是多余的。这真的值得吗?
一如既往,这取决于你的优先级。如果您正在构建一个快速且粗糙的原型,并且不介意哪些较小且比较边缘的覆盖不全的存在,那么您可能不会遇到什么问题。但是如果生产bug让您感到担忧,那么您就绝对应该投入时间和精力来增强您的测试套件。一个具备覆盖尽可能完整的可靠测试套件是对抗产品bug的最佳实践。从长远来看,它实际上是会提高开发速度的,因为它允许人们安全地重构和快速部署变更,而不需要将大量的时间花费在手工测试上。
人们经常谈论TDD,好像它是“拯救”测试的杀手锏。显然,事实并非如此。也许,您当然可以如大神般设计出完美的测试套件。但是对于我们这些普通人来说,识别和修复测试套件中覆盖不全的最好方法,还是要通过实际经验将其放到测试中。
{测试窝原创译文,译者:Elaine66}
以上是 【软件测试】变异驱动测试——当TDD不那么香的时候的时候 的全部内容, 来源链接: utcz.com/a/130710.html