测试覆盖(率)到底有什么用?

本文探讨了测试覆盖率作为质量指标的有效性,并介绍了代码变异测试这一更为深入的测试方法。通过实际案例展示了代码变异测试如何帮助开发者改进代码质量和测试有效性。
引言
  经常有人问我这样的问题:“我们在做 单元测试,那 测试覆盖率要到多少才行?”。而我的答案很简单,“作为指标的测试覆盖率都是没有用处的。”
  Martin Fowler(重构那本书的作者)曾经写过一篇博客来讨论这个问题,他指出:把测试覆盖作为质量目标没有任何意义,而我们应该把它作为一种发现未被测试覆盖的代码的手段。
  Brian Marick( 敏捷宣言最早的17个签署人之一)也说过,作为一名程序员,我当然期望我的代码有较高的测试覆盖率。但是,当我的经理要求这样的指标时,那就有别的目的了(绩效考核?)。
  我认为,高的测试覆盖率应该是每个“认真”写单元测试的程序员得到的必然结果,管理者把一个结果作为指标来衡量,本身就是没有意义的。如果你把“万能”的程序员逼急了,他就会从 “神秘的工具箱”中拿出一两个“法宝”来,“高效”地达成指标。我就见过很多这样的“法宝”,比如在单元测试中连一个“assert”也没有,或者写很多get和set方法的单元测试(写起来简单啊)来提高整体的覆盖率等等。更何况,测试充分的代码也有可能无法达到100%的覆盖率,本文的后面就有这样的例子。
  那你大概会问:“那测试覆盖到底有什么用呢?”。我的答案还是很简单,“测试覆盖是一种 学习手段”。学习什么呢?学习为什么有些代码没有被覆盖到,以及为什么有些代码变了测试却没有失败。理解“为什么”背后的原因,程序员就可以做相应的改善和提高,相比凭空想象单元测试的有效性和代码的好坏,这会更加有效。
  接下来,我会给大家介绍一些传统的测试覆盖方法和一种称为“代码变异测试”(Mutation  Test)的方法。大家将会看到这些方法都可以产生什么样的学习点,以及代码变异测试相比传统方法更有价值的地方。如果你是一名程序员(我不会区分你是开发人员还是测试人员,那对我来说都一样),希望你看完这篇 文章之后,可以找到一些提高测试和代码质量的方法。如果你是一位管理者,不论你正在用还是想要用“测试覆盖率”来做度量,希望你看完这篇文章之后,可以放弃这个想法,做点更有意义的事情(比如去写点代码)。
   传统的测试覆盖方法
  传统的测试覆盖方法常见的有以下几种:
  函数覆盖(Function Coverage)
  语句覆盖(Statement Coverage)
  决策覆盖(Decision Coverage)
  条件覆盖(Condition Coverage)
  还有一些其他覆盖方法,如Modified Condition/Decision Coverage,就不在这里讨论了。
  函数覆盖:顾名思义,就是指这个函数是否被测试代码调用了。以下面的代码为例,对函数foo要做到覆盖,只要一个测试——如assertEquals(2, foo(2, 2))——就可以了。如果连函数覆盖都达不到,那应该想想这个函数是否真的需要了。如果需要的话,那又为什么写不了一个测试呢?
  语句覆盖:(也称行覆盖),指的是某一行代码是否被测试覆盖了。同样的代码要达到语句覆盖也只需要一个测试就够了,如assertEquals(2, foo(2, 2))。但是,如果把测试换成assertEquals(0, foo(2, -1)),那就无法达到所有行覆盖的效果了。通常这种情况是由于一些分支语句导致的,因为相应的问题就是“那行代码(以及它所对应的分支)需要吗?”,或者“用什么测试可以覆盖那行代码所代表的分支呢?”。


  条件覆盖:指的是分支中的每个条件(即与,或,非逻辑运算中的每一个条件判断)是否被测试覆盖了。之前的代码要达到全部的条件覆盖(也就是x>0和y>0这两个条件为真和假的情况均至少出现一次)需要更多的测试,如assertEquals(2, foo(2, 2)),assertEquals(2, foo(2, -1))和assertEquals(2, foo(-1, -1))。如果有一个条件分支没有被覆盖(比如缺少测试assertEquals(2, foo(-1, -1))),那么大家应该想想“那个条件判断是否还需要呢?”,或者“用什么测试可以覆盖那个条件所对应的逻辑呢?”。
  通过上面对几种传统的测试覆盖方法的介绍,大家不难发现,这些方法的确可以帮我们找到一些显而易见的代码冗余或者测试遗漏的问题。不过,实践证明,这些传统的方法只能产生非常有限的“学习”代码和测试中问题的机会。很多代码和测试的问题即使在达到100%覆盖的情况下也无法发现。然而,我接下来要介绍的“代码变异测试”这种方法则,它可以很好的弥补传统方法的缺点,产生更加有效的“学习”机会。
   代码变异测试(Mutation Test)
  代码变异测试是通过对代码产生“变异”来帮助我们学习的。“变异”指的是修改一处代码来改变代码行为(当然保证语法的合理性)。简单来说,代码变异测试先试着对代码产生这样的变异,然后运行单元测试,并检查是否有任何测试因为这个代码变异而失败。如果有测试失败,那么说明这个变异被“消灭”了,这是我们期望看到的结果。如果没有测试失败,则说明这个变异“存活”了下来,这种情况下我们就需要去研究一下“为什么”了。
  是不是感觉有点绕呢?让我们换个角度来说明一下,可能就容易理解了。测试驱动开发相信大家一定都听说过,它的一个重要观点是,我们应该以最简单的代码来通过测试(刚好够,Just Enough)。基于这个前提,那么几乎所有的代码修改(即“变异”)都应该会改变代码的行为,从而导致测试失败。这样的话,如果有个变异没有导致测试失败,那要么是代码有冗余,要么就是测试不足以发现这个变异。
  另一方面,大家可以想一下对于自动化测试(包括单元测试)的期望是什么。我觉得一个很重要的期望就是,自动化测试可以防止“任何”错误的代码修改,以减少代码维护带来的风险。错误的代码修改实际上就是一个代码变异,代码变异测试可以帮我们找到一些无法被当前测试所防止的潜在错误。
  举例来说,我们给之前的那段被测代码增加一行,sideEffect(z)。之前的那些可以让传统的测试覆盖方法达到100%覆盖率的测试,在新增这行代码之后,依然会全部通过且覆盖率不变。然而,如果我们再删除那行新代码sideEffect(z),结果有会怎样呢?那些测试还是会全部通过,覆盖率也还是100%。在这种情况下,原来那些测试可以说没有任何意义。相对的,代码变异测试则可以通过删除那一行,再运行测试,就会发现没有任何测试失败。然后,我们就可以根据这个结果想到其实还需要一个测试来验证sideEffect(z)这个行为(如果那行代码不是多余的话)。
  再举一个例子,还是之前的代码,不做任何修改。我们用assertEquals(2, foo(2, 2)),assertEquals(2, foo(2, -1))和assertEquals(2, foo(-1, -1))这三个测试达到了100%的条件覆盖。然而,如果把y > 0的条件改成 y >= 0的话,这三个测试依然会通过。为什么会出现这样的问题呢?那是因为之前的测试对输入参数的选择比较随意,所以让这个代码变异存活了下来。可以看到,在条件覆盖100%的情况下,代码变异测试依然可以帮我们发现这种测试写的不严谨的问题(假设y >= 0这个代码变异是不合理的),从而使修改后的测试可以防止产生这样的错误代码。
  通过上面两个例子,相信大家已经发现代码变异测试可以给我们提供大量的学习代码合理性和测试有效性的机会。实际上,类似的代码变异还有很多种。下面是常见变异的列表,更详细的内容可以参考http://pitest.org/quickstart/mutators/。
  对关系运算(<, <=, >, >=)进行变异,上面第二例子就是这种变异
  反向条件变异(Negate Conditionals Mutator)
  对关系运算(==, !=, <, <=, >, >=)进行变异,例如把“==”变成“!=”
   数学运算变异(Math Mutator)
  对数学运算(+, -, *, /, %, &, |, ^, >>, <<, >>>)进行变异,例如把“+”变成“-”
  增量运算变异(Increments Mutator)
  对递增或者递减的运算(++, --)进行变异,例如把“++”变成“--”
  负值翻转变异(Invert Negatives Mutator)
  对负数表示的变量进行变异,例如把“return -i”变成“return i”
  内联常量变异(Inline Constant Mutator)
  对代码中用到的常量数字进行变异,例如把“int i=42”变成“int i=43”
  返回值变异(Return Values Mutator)
  对代码中的返回值进行变异,例如把“return 0”变成“return 1”或者把“return new Object();”变成“new Object(); return null;”
  无返回值方法调用变异(Void Method Calls Mutator)
  对代码中的无返回值方法调用进行变异,也就是把那个方法调用删除掉,上面的第一个例子就是这种变异。
  有返回值方法调用变异(Non Void Method Calls Mutator)
  对代码中的有返回值函数调用进行变异,也就是接收返回值的变量赋值将被替换成为返回值类型的语言默认值,例如把“int i = getSomeIntValue()”变成“int i = 0”
  构造函数调用变异(Constructor Calls Mutator)
  对代码中的构造函数调用进行变异,例如把“Object o = new Object()”变成“Object o == null”
   测试驱动开发和代码变异测试
  测试驱动开发(TDD)是我推崇和实践的写代码(做设计)方法。我在前面曾经提到,代码变异测试的假设是“实现代码是刚好够通过测试的最简单代码”,而这也是TDD中的重要实践之一。大家可能会问,如果做了TDD,代码变异测试的结果又会如何呢?还会产生学习的机会吗?答案是肯定的,一定会。
  让我们通过例子来看一下。我经常会做一些Kata来练习编程技巧,PokerHands(如上图)就是其中之一(其实大体就是实现梭哈的五张比较规则http://codingdojo.org/cgi-bin/wiki.pl?KataPokerHands)。每次我把Kata做完之后,都会用运行一下代码变异测试(sonar中有插件)。Java的代码变异测试工具有个比较好的叫pitest。下面是我用这个工具跑出来的结果,代码可以在这里找到https://github.com/JosephYao/Kata-PokerHands。
  如大家所见,红色那一行中有一个存活下来的代码变异。而这个代码变异是把“index < CARD_COUNT - 1”中的“<”换成“>”。看上去很不可思议吧,因为进行这样的代码变异意味着整个for循环都不会被执行了,应该不可能没有一个测试失败吧?
  让我们来看一下相关的单元测试。在下面这个测试中有三个assert,它们都是在验证“一对”之间通过对子的点数来比较大小的情况。大家仔细观察就可以发现,其实这三个assert中的牌如果作为High Card(就是比一对小一点的牌组)来比较的话,也都是成立的。这也就是那个代码变异可以存活下来的原因,因为即使忽略了一对之间的比较,通过High Card比较出来的大小关系也是一样的。我从中学到的是,只要把 assertPokerHandsLargerThan("2S 3H 5S 8C 8D","2S 3H 5S 7C 7D")改为 assertPokerHandsLargerThan("2S 3H 5S 8C 8D","2S 3H 9S 7C 7D")就可以清除这个代码变异了。
  从这个例子中可以看到,即使以TDD的方法来写代码,也是无法完全避免出现代码变异存活下来的情况的(当然,存活变异的数量要非常明显的少于不用TDD而写出来的代码)。做过TDD的人可能都有这样的感觉,就是有时很难抑制自己写出复杂代码的冲动(也就是说代码不是“刚好够”的)。有时,即使实现代码是最简单的,也可能因为代码过于直接,就会很“随意”的写出一个让当前代码失败的测试。上面的例子就是这种情况,这样不太“有效”的测试通常在TDD过程中很难意识到,从而给之后的代码维护造成隐患。
  除了上面那个有学习意义的代码变异之外,其实工具还帮我找到了一个“没意义”但存活下来的代码变异。
  这里存活下来的代码变异是指把“index < CARD_COUNT - 2”中的“<”变成“<=”。之所以说这个代码变异没意义,是因为根据代码上下文,在for循环中一定会在index等于CARD_COUNT - 2之前就找到那个三张的点数。因为工具无法理解上下文,所以产生了这个没意义的代码变异(也叫做Equivalent Mutation)。之所以举这个例子,只是想提醒大家不要迷信代码变异测试工具。对于他产生的结果一定去分析和学习,不然很容易走上考核指标的那条不归路。
   小结
  总而言之,测试覆盖这种方法是一种不错的学习手段,可以帮助我们提高代码和测试质量。代码变异测试则比传统的测试覆盖方法可以更加有效的发现代码和测试中潜在的问题,提供更多的学习机会。在这里,我要郑重警告那些妄图把代码变异测试变成一种新的考核指标的管理者们,这样做只会迫使程序员从他的神秘工具箱中找出新的法宝来对付你(比如,修改编译器等等)。
  代码变异测试的概念其实早在30年前就被提出了。之所以到目前为止还没有被业界广泛接纳,一个重要原因是由于需要对每个代码变异反复运行测试。如果不是单元测试(运行速度慢),代码变异测试工具执行时将消耗大量的时间。正因如此,单元测试可能是唯一符合代码变异测试要求的一种测试了。如果你对代码变异测试的历史和发展过程感兴趣的话,你可以参考这篇研究报告http://crestweb.cs.ucl.ac.uk/resources/mutation_testing_repository/TR-09-06.pdf。
最新内容请见作者的GitHub页:http://qaseven.github.io/

<think>首先,用户的问题是关于黑盒测试覆盖和白盒测试覆盖之间的主要区别。用户说:“我想了解黑盒测试覆盖和白盒测试覆盖之间的主要区别请问黑盒测试覆盖与白盒测试覆盖的主要区别是什么?” 我需要根据系统级指令来构建我的回答。关键点包括:-使用中文回答。-结构清晰,逐步解决问题。-确保回答真实可靠。 -在行内数学表达式使用$...$格式,独立公式使用$$...$$格式。-生成相关问题。 -在引用的段落末尾添加引用标识,如[^1]。参考提供的引用:-引用[1]、[2]、[3]、[4]、[5]讨论了黑盒测试、白盒测试和灰盒测试的定义、方法、优缺点等。这些引用提供了背景信息,但用户的问题具体针对覆盖率。从引用中提取相关信息: -引用[1]:提到灰盒测试,但焦点在测试用例设计。 -引用[2]:详细描述了白盒测试的各种覆盖方法(语句覆盖、判定覆盖等),黑盒测试的常用方法(等价类划分、边界值分析等),和灰盒测试。-引用[3]:定义黑盒测试,关注功能和接口,不关心内部实现。 -引用[4]:讨论黑盒测试的方法如等价类划分、边界值分析,并提到优缺点。-引用[5]:解释等价类划分在黑盒测试中的应用。用户的问题是关于覆盖率的区别: -黑盒测试覆盖:基于功能需求或规格的覆盖。-白盒测试覆盖:基于代码结构的覆盖。我需要解释两者的主要区别: 1. **定义**:-黑盒测试覆盖:衡量测试用例覆盖功能需求的程度。例如,需求覆盖率或规格覆盖率。-白盒测试覆盖:衡量测试用例覆盖代码内部结构的程度。例如,语句覆盖率、分支覆盖率等。 2. **测量方式**:-黑盒:使用需求文档或规格来定义覆盖率指标。-白盒:使用代码分析工具来计算覆盖率指标。 3. **目的**:-黑盒:确保所有功能点都被测试。-白盒:确保所有代码路径都被执行。 4. **优缺点**:-黑盒:容易实现,但可能遗漏内部错误。-白盒:更全面,但需要代码访问权。 从引用中: -引用[2]提到白盒测试的覆盖方法:语句覆盖、判定覆盖、条件覆盖等。 -引用[4]和[5]讨论黑盒测试的方法,如等价类划分,但不直接提到覆盖率。我的回答结构: 1. **引言**:简要介绍黑盒和白盒测试覆盖。2.**主要区别**:逐步解释差异。-定义差异。-测量指标差异。-应用场景差异。-优缺点对比。 3. **总结**:强调关键区别。4.**相关问题**:生成几个相关问题。确保使用LaTeX格式:-行内数学表达式:例如,覆盖率指标如$覆盖率=\frac{被覆盖的项}{总项}\times100\%$。 -独立公式:如果需要,但可能不必要。添加引用标识: -在引用内容末尾添加[^1]、[^2]等。基于提供的引用,我应该引用[2]因为它详细描述了覆盖方法。 引用[2]说: -白盒测试:1.1语句覆盖,1.2判定覆盖,1.3条件覆盖等。 -黑盒测试:2.1常用方法如等价类划分。-灰盒测试:3.1常用方法。 所以,在回答中,当提到白盒覆盖率时,引用[2]。生成相关问题:-根据系统指令:“生成相关问题”,在末尾添加§§相关问题§§部分。-例如:1.如何计算白盒测试覆盖?2.黑盒测试覆盖有哪些指标?3.灰盒测试如何影响覆盖率? 最终回答结构: -开头:直接回答问题。-主体:分点解释区别。 -结尾:总结。-然后:§§相关问题§§。</think>### 黑盒测试覆盖与白盒测试覆盖的主要区别 黑盒测试覆盖和白盒测试覆盖是软件测试中衡量测试完整性的关键指标,但它们在定义、测量方法、目标和应用场景上有显著差异。以下我将逐步解释这些主要区别,确保回答基于可靠的测试理论,并参考相关文献。 #### 1. **定义差异** - **黑盒测试覆盖**:基于软件的功能需求或规格来评估测试的完整性。它关注外部行为,不涉及代码内部结构。例如,衡量测试用例覆盖需求规格的程度,如需求覆盖率或功能点覆盖率[^3][^4]。 - **白盒测试覆盖**:基于代码内部结构(如控制流或数据流)来评估测试的完整性。它需要访问源代码,并测量测试用例执行代码路径的程度,例如语句覆盖率或分支覆盖率[^2]。 关键点:黑盒覆盖率是“外部视角”,只关心输入输出是否符合需求;白盒覆盖率是“内部视角”,确保代码逻辑被充分执行[^2][^3]。 #### 2. **测量指标差异** - **黑盒测试覆盖**: - 常用指标:需求覆盖率(例如,覆盖的需求项数除以总需求项数),公式表示为:$ \text{需求覆盖率} = \frac{\text{被覆盖的需求数}}{\text{总需求数}} \times 100\% $。 - 方法:通过等价类划分、边界值分析或决策表测试来设计测试用例,确保所有功能场景被覆盖[^4][^5]。 - 示例:测试一个登录功能时,覆盖所有输入组合(如有效用户名/密码、无效输入等),而不关心内部代码如何实现。 - **白盒测试覆盖**: - 常用指标:包括语句覆盖率(所有代码语句被执行的比例)、分支覆盖率(所有条件分支被执行的比例)等。公式表示为:$ \text{语句覆盖率} = \frac{\text{被执行的语句数}}{\text{总语句数}} \times 100\% $。 - 方法:使用代码分析工具(如JaCoCo或gcov)来跟踪执行路径,确保所有代码行、分支或路径被测试[^2]。 - 示例:测试一个排序算法时,确保每个循环和条件分支(如if-else语句)都被执行。 关键点:黑盒覆盖率依赖于需求文档,指标更抽象;白盒覆盖率依赖于代码结构,指标更具体和量化[^2][^4]。 #### 3. **目标与优缺点对比** - **目标**: - 黑盒覆盖率:主要目标是验证软件是否满足用户需求和功能规格,确保无遗漏的功能缺陷[^3][^5]。 - 白盒覆盖率:主要目标是检测代码内部的逻辑错误、死代码或未覆盖路径,提高代码质量[^2]。 - **优缺点**: - 黑盒测试覆盖: - 优点:易于实施,不需要代码知识;适合用户验收测试。 - 缺点:可能遗漏内部逻辑错误(如边界条件未覆盖),覆盖率指标较主观,依赖于需求质量[^4][^5]。 - 白盒测试覆盖: - 优点:更全面,能发现深层次代码缺陷;提供客观的量化指标。 - 缺点:需要代码访问权和专业知识;可能过度关注代码而非业务需求,导致测试冗余[^2]。 关键点:黑盒覆盖率强调“做什么”,白盒覆盖率强调“怎么做”;在实践中,两者常结合使用(如灰盒测试)以平衡覆盖[^1][^2]。 #### 4. **应用场景差异** - **黑盒测试覆盖**:常用于系统测试或验收测试阶段,由测试人员或业务分析师执行。例如,在Web应用中,确保所有用户功能点(如表单提交、搜索)被覆盖[^3][^4]。 - **白盒测试覆盖**:常用于单元测试或集成测试阶段,由开发人员执行。例如,在开发库或API时,确保所有代码路径被测试以预防逻辑错误[^2]。 #### 总结 黑盒测试覆盖与白盒测试覆盖的核心区别在于:前者基于外部需求测量功能完整性,后者基于代码结构测量逻辑完整性。黑盒覆盖率更易操作但可能遗漏内部缺陷,而白盒覆盖率更精确但需要技术专长。在实际测试中,结合两者(如通过灰盒测试)能提升整体测试效果[^1][^2]。覆盖率指标的计算公式(如$ \text{覆盖率} = \frac{\text{被覆盖项}}{\text{总项}} \times 100\% $)适用于两者,但应用对象不同[^2][^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值