代码覆盖从简到繁 – 划分Block
上一篇博客 《代码覆盖从简到繁
一》介绍了Visual Studio所采用的Block覆盖中Block是如何定义的,并且展示了代码行与Block之间其实并不是严格对应的。本篇博客将通过.NET中间语言(IL)进一步分析Visual
Studio是如何划分Block的,从而更准确回答代码行与Block不能严格对应的原因。
使用Visual Studio获取code coverage数据是非常简单的,只需要在配置中选择“Code
Coverage”选项,然后执行测试用例就可以了,覆盖数据会直接在"Code Coverage
Results”窗口中呈现出来,这些在《代码覆盖从简到繁
(一) 》中都有介绍。其实要获取覆盖数据,首先要对被测试的.exe或者.dll进行instrument,所谓instrument实际上就是向文件注入特定的用于收集覆盖数据的代码;然后,启动覆盖数据的监听服务,刚才注入代码会在被指定到时项监听程序发出报告;接下来就是要执行你的测试用例(可以是自动或者手动测试用例);停止监听服务,生成代码覆盖报告。为了易于使用,Visual
Studio自动为执行了上述很多工作。除了Visual Studio IDE, 还可以通过命令行工具 VsInstr.exe,VsPerfmon和VsPerfCmd来完成获取覆盖数据的操作,Code
Coverage Basics with Visual Studio Team System 中有详细的介绍,这里就不再赘述!这里需要注意:这些命令不只是用于代码覆盖,而是性能Profiling的工具。这里我们用到了VsInstr.exe
-coverage命令,它负责instrument我们前面编写的代码,然后使用.NET的 Ildasm.exe
在IL层观察上一篇博客中使用的GetInteger()函数是如何被划分block的,下面就是Instrument之后的GetInteger()函数的IL代码(这里使用的Visual
Studio 2010带的C#编译器,编译器不同产生的代码也会不同):
.method public hidebysig instance int32 GetInteger(int32
arg1, int32 arg2) cil managed
{
// Code size 204 (0xcc)
.maxstack 3
.locals init ([0] int32 CS$1$0000, [1] bool CS$4$0001)
IL_0000: call void Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::Register()
IL_0005: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_000a: ldc.i4 0x5
IL_000f: ldelem.i8
IL_0010: ldc.i8 0x1
IL_0019: add
IL_001a: conv.i
IL_001b: ldc.i4.1
IL_001c: stind.i1
IL_001d: nop 判断 arg1 > 0
IL_001e: ldarg.1
IL_001f: ldc.i4.0
IL_0020: ble.s IL_0043 如果 arg1 <= 0,跳转到0043处。
IL_0022: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_0027: ldc.i4 0x5
IL_002c: ldelem.i8
IL_002d: ldc.i8 0x2
IL_0036: add
IL_0037: conv.i
IL_0038: ldc.i4.1
IL_0039: stind.i1
IL_003a: ldarg.2 判断 arg2 < 0
IL_003b: ldc.i4.0
IL_003c: clt
IL_003e: ldc.i4.0
IL_003f: ceq 如果 arg2 < 0, 向求值栈(evaluation stack)加载
0;否则为1;
IL_0041: br.s IL_005c
IL_0043: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_0048: ldc.i4 0x5
IL_004d: ldelem.i8
IL_004e: ldc.i8 0x3
IL_0057: add
IL_0058: conv.i
IL_0059: ldc.i4.1
IL_005a: stind.i1
IL_005b: ldc.i4.1 (arg1 <= 0时)向求值栈(evaluation stack)加载
1
IL_005c: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_0061: ldc.i4 0x5
IL_0066: ldelem.i8
IL_0067: ldc.i8 0x4
IL_0070: add
IL_0071: conv.i
IL_0072: ldc.i4.1
IL_0073: stind.i1
IL_0074: stloc.1 判断 arg1 > 0 && arg2 <
0 最终结果
IL_0075: ldloc.1
IL_0076: brtrue.s IL_0095
IL_0078: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_007d: ldc.i4 0x5
IL_0082: ldelem.i8
IL_0083: ldc.i8 0x5
IL_008c: add
IL_008d: conv.i
IL_008e: ldc.i4.1
IL_008f: stind.i1
IL_0090: nop 准备return 0
IL_0091: ldc.i4.0
IL_0092: stloc.0
IL_0093: br.s IL_00b2
IL_0095: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_009a: ldc.i4 0x5
IL_009f: ldelem.i8
IL_00a0: ldc.i8 0x6
IL_00a9: add
IL_00aa: conv.i
IL_00ab: ldc.i4.1
IL_00ac: stind.i1
IL_00ad: nop 准备return 1
IL_00ae: ldc.i4.1
IL_00af: stloc.0
IL_00b0: br.s IL_00b2
IL_00b2: ldsfld uint64[] Microsoft.VisualStudio.Coverage.Init_bbf9568946f2545aaa9b589093700f85::m_vscov
IL_00b7: ldc.i4 0x5
IL_00bc: ldelem.i8
IL_00bd: ldc.i8 0x7
IL_00c6: add
IL_00c7: conv.i
IL_00c8: ldc.i4.1
IL_00c9: stind.i1
IL_00ca: ldloc.0
IL_00cb: ret
} // end of method Program::GetIntege
与没有instrument过的IL代码相比,被instrument的代码是多出了上面用灰色标识的部分,它们就是真正用来标记哪些代码被执行的。仔细数数正好是
7 段,每一段标识了一个block划分的开始,数组的索引值(例如:IL_0010: ldc.i8 0x1
)给每个block从 1 到 7 进行了编号。当这些标识block代码被执行,则代表它们所标识真正被测试代码一定被执行到,代码覆盖收集的监听程序,会时刻监听和收集这些标记代码的执行情况,并由此生成最终的覆盖报告。再对照上篇博客多提到的block的定义
- a single entry point, a single exit point, and a set
of instructions that are all run in sequence - 仔细检查一下,确实是这样每一个block都是只有一个唯一入口和一个为出口,block标记到大都是加载在br.s、brtrue.s、ble.s等分支跳转语句前面。
对于GetInteger()而言,最有意思就要数 if( arg1 >0
&& arg2 < 0 ),别看只有一行代码,但由于条件与操作&&的存在,在IL级这一行代码时间上是被划分4个block的,如上面的粗体代码所示,这些代码并不是很难理解。这里出个小问题:对于GetInteger()函数,测试用例(arg1
=1, arg2 = -1),能够对 if( arg1 >0 && arg2 <
0 ) 行进行完全覆盖吗?答案:不能,因它漏掉了仅有一条IL指令(IL_005b: ldc.i4.1)的哪个block,随意仍是部分覆盖。要想达到对该if行的完全覆盖,最少需要两个用例,
例如:(arg1 = -1, arg1 =-1)和 (arg1 =1, arg2 = -1)。
最后需要提示一下:Reflector工具可以将IL代码反编译为C#等语言代码,这样阅读起来会更方便一些,但是有一些instrument过的IL,Reflector反编译的结果可能会丢失一些block划分信息。例如:GetInteger()的发编译结果如下。其中,Block#2和#3并没有显示在代码中体现出来,所以在有些情况下,直接阅读IL代码能更准确把握block的划分情况。
view plain
public int GetInteger(int arg1, int arg2)
{
int num;
Init_bbf9568946f2545aaa9b589093700f85.Register();
Init_bbf9568946f2545aaa9b589093700f85.m_vscov[5][(int)
((ulong) 1L)] = 1;
Init_bbf9568946f2545aaa9b589093700f85.m_vscov[5][(int)
((ulong) 4L)] = 1;
if ((arg1 > 0) && (arg2 < 0))
{
Init_bbf9568946f2545aaa9b589093700f85.m_vscov[5][(int)
((ulong) 5L)] = 1;
num = 0;
}
else
{
Init_bbf9568946f2545aaa9b589093700f85.m_vscov[5][(int)
((ulong) 6L)] = 1;
num = 1;
}
Init_bbf9568946f2545aaa9b589093700f85.m_vscov[5][(int)
((ulong) 7L)] = 1;
return num;
}
代码覆盖从简到繁 – 为代码签入把门儿
在《代码覆盖从简到繁
(一) 》中曾经介绍过,获取和分析代码覆盖数据是为了发现被测试产品中可能存在的测试漏洞(Test
Holes),同时也是衡量当前测试覆盖效率的重要指标。代码覆盖率是测试团队的重要工具和测试活动之一,但由于要涉及代码的走查和分析,所以需要开发人员的参与和配合。
那么除了发现测试漏洞,代码覆盖率还有其它什么作用吗?在回答这个问题之前,让我先谈谈软件测试的目的。记得Google的段念在第二届(杭州)互联网测试技术交流会上有一个题为《颠覆者生存:互联网产品测试观点》的演讲。其中,他提出了4个PK,第一个PK是:度量质量的测试
vs. 提高质量的测试。中心思想是说,如果测试仅是以找到缺陷为目标,那么它就是质量度量测试,而如果通过软件测试活动辅助改善软件开发流程,在早期就有效避免缺陷的发生,那就是软件测试一个更高的境界
– 提高软件质量的测试。讲得很有道理!那么这和代码覆盖率又有什么关系呢?其实,代码覆盖率也可以作为一种手段,用来辅助我们改善开发活动。
持续集成是敏捷开发中重要的一环,无论是代码签入前验证(Pre-check-in
validation)还是签入后检查(Post-check-in validation),都要求签入的代码达到比较全面的测试,这通常是通过执行一组事先选定好的测试用例,这组用例都通过,也就任务认为签入的代码达到了比较全面的测试。那么该如何选择测试用例才能达到比较全面的测试呢?对于实际的工程而言,“比较全面的测试”就是一个模糊的定义,用例的选择带有一定的主观和随意性。在这方面,代码覆盖率可以提供更为客观和量化的度量手段。基本的假设是:在大多数情况下,代码覆盖率的高低与测试的覆盖程度成正比,代码覆盖率高的情况下缺陷,特别是回归缺陷(Regression
Bug),发生的概率更低。所以,如果能够将代码覆盖率作为持续集成中代码签入的约束条件之一,就能够更客观的保证签入代码确实经过比较全面的测试。
例如,你的应用程序可能有多个模块组成:商务逻辑模块、数据访问模块和界面模块等。根据重要性级别的不同,可以为模块设置不同级别的代码覆盖率约束条件。比如说,商务逻辑模块>75%,
数据访问模块>60%, 界面模块>30%。由于有了这些约束条件的存在,可以保证任何代码的签入都需要是经过比较全面测试的,否则它签入不到代码库中。有了代码覆盖率约束在此处“把门儿“,可以带来以下的好处:
1) 测试团队可以更准确、及时的把握和调整测试的重点。比如:针对regression
bug频发的模块,可以适当提高代码覆盖率约束条件的比例。
2) 促使开发人员更积极地编写单元测试用例,特别是在对产品代码有比较大的改动时。
3) 促使开发人员和测试更积极的互动, 包括:及时通报重大产品变化、交流测试重点、协调单元测试、功能测试等合理覆盖比例。
上述的内容也仅是本人的想法,俗话说得好:“光说不练假把式,光练不说傻把式,又练又说真把式。”说了这么多想法,有什么ALM(Application
Lifecycle Management,应用生命周期)工具实现了上面所提到的代码覆盖约束了吗?本人也只是对微软ALM工具Team
Foundation Server (简称 TFS)比较了解,但对其它的ALM工具了解的比较少。TFS的最新的版本TFS
2010及其早先的版本中是没有代码覆盖约束。不过也不用着急,由于TFS 2010的Team Build功能采用Workflow
Foundation(WF)技术,所以对它的定制和二次开发就更为容易,有关这方面的内容请请参见《Develop
a Customized Build Process 》和《Create a Custom WF Activity
to Sync Version and Build Numbers》。
我花了点时间尝试着实现了一个名为CodeCoverageChecker
WF activity,用它扩充了TFS 2010自带的DefaultTemplate.xaml过程模板,实现了支持的代码覆盖约束的新模板-
CodeCoverageCheckTemplate.xaml。 如下图所示,在CodeCoverageCheckTemplate.xaml模板中,CodeCoverageChecker被放置在编译并执行完测试工作之后。
有了这个过程模板,就可以定义TFS Build Definition,并在其中加入针对代码签入到代码库所需要的代码覆盖率约束条件,
这些条件可以是针对模块一级的(如:.exe 、.dll),也可以是针对类型(如:class、struct等),甚至还可以是函数或者方法。例如:下面是基于CodeCoverageCheckTemplate.xaml的Build
Definition,在其中我定义各种覆盖约束规则。
如下图所示,在代码覆盖条件编辑器中可以跟更清楚地看到设置条件的内容,也可以在此继续编辑这些条件。这4条覆盖约束条件依次对应的是:模块(CalculationLib.dll=68%)、名字空间(CalculationLib.dll/Currency.Library=68%)、类(CalculationLib.dll/Currency.Library/CurrencyConverter=10%)和方法(CalculationLib.dll/Currency.Library/CurrencyConverter/CalculateCrossRate(float64,float64)=76%)。
有了这些约束条件的把关,当有任何不符合这些条件的代码需要签入时,签入操作的都会失败。例如,下图就是用户由于不满足代码覆盖约束检查,而未能通过
TFS Gated Check-in 的错误提示页。在这个结果中,我们可以看到,代码编译成果,测试全部通过,但签入的代码没能通过覆盖约束检查,所以签入失败。只有在你添加了足够的测试并和代码一起签入时,才会成功地签入进去。这绝对不是开发人员的“杯具”,而是整个团队的“洗具”,呵呵!
通过上面的介绍,我们可以看到,代码覆盖不仅是测试团队用来查找测试漏洞的工具,它还可以帮助团队来改进开发流程,代码覆盖率约束就是其中之一。虽然还没有现成的工具支持这样的约束,但Team
Foundation Server 2010的Team Build功所具备的良好的扩展性,使我们可以很容易的在其平台实现代码覆盖约束。在接下来的博客,如果大家需要,我会介绍更多实现这样一个约束检查功能的细节。 |