变异驱动测试——当TDD不那么香的时候的时候

2021-04-05   出处:software.rajivprab.com  作/译者:佚名/Elaine66  

        

        作为一个乐于讨论软件工艺和最佳实践的人,测试驱动开发(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个需求,以及它们的实现和测试。

        要求:

  • 该方法可以取012个用逗号分隔的数字
  • 对于空字符串,该方法将返回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");

}

        要求说,该方法可以使用012个数字”。“所以……我们应该检查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}


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
136°|1363 人阅读|0 条评论

登录 后发表评论
最新文章