构建 .NET 代码覆盖率工具






4.94/5 (32投票s)
本文将指导您完成构建 .NET 代码覆盖率工具的过程
目录
引言
本文将指导您完成构建 .NET 代码覆盖率工具的过程。
在花费了一些时间配置构建工具以使用 NCover 社区版(必须注册才能下载免费的社区版),并听取了关于其测试版冻结状态以及商业版本高昂价格的抱怨后,我决定研究其他替代方案。NCover 的一个替代方案是一个源代码插桩的开源项目,也称为 NCover - NCover 开源版。修改源代码无疑是一种解决方案,但对于大多数大型项目来说,这是不可取的。另一个代码覆盖率工具集成在 VS Team Edition 中,但它不是免费的。此外,它仅限于 Microsoft Unittesting Framework。
商业 NCover 的主要替代方案是一个基于开源分析器的 PartCover 工具。该工具拥有自己的覆盖率浏览器,并且还集成在最新的 SharpDevelop 中。
网上其他的 .NET 代码覆盖率工具有:Clover.NET (现已弃用),Prof-It for C# 等。所有这些似乎要么是商业的,要么是已弃用的。
问题
代码覆盖率工具背后的理念似乎很简单。为了构建一个程序集的覆盖率,需要对其进行插桩,并在执行期间记录所有序列点的命中次数。有关代码覆盖率的更多详细信息,请参阅 Wikipedia 上的 代码覆盖率文章。
虽然 .NET Profiling API 可以对程序集进行执行时插桩,但它是基于 COM 的,因此需要非托管的平台相关代码。
另一种可以解决覆盖率计算问题的方法是对已编译的程序集进行插桩。我们将进一步专注于这种方法。根据这种方法,覆盖率计算问题可以分为两个阶段:插桩和执行。
在本文中,我们将介绍解决方案的开发过程,该解决方案将对程序集进行插桩,并生成一个代码覆盖率报告文件,其中包含序列点列表和指向相应序列点的源代码片段的书签。此文件应使用仪表化程序集执行期间序列点命中的统计信息进行更新。
注意:作为上述文件的格式,我决定重用 NCover 社区版的报告格式,以便能够将其与基于它的现有工具(如 NCoverExplorer 等)一起使用。
解决方案可以分为三个步骤
让我们仔细看看这些阶段。PDB 解析
PDB(程序数据库)文件存储了程序集中序列点列表及其地址,以及声明序列点的源代码文件名和行号。Google 建议使用两个现有的 PDB 解析器:mono.tools.pdb2mdb
和 Microsoft Mdbg.CorApi
示例中的 pdb2xml
。Microsoft Mdbg.CorApi
示例使用了 COM 对象,并且不是跨平台的。因此,我们将使用 Pdb2mdb
来解析 PDB。但是,mono.tools.pdb2mdb
的主要目标不是 PDB 读取功能(其 PDB 读取器相关类被标记为 Internal)。我们将 PDB 读取的功能隐藏在一个简单的接口后面。
public interface IProgramDatabaseReader
{
/// Loads program database file that corresponds to assembly
void Initialize(string assemblyFilePath);
/// Retrieves source code segment locations with
/// corresponding offsets in compiled assembly for given method
///
/// Returns Dictionary:
/// Key - an instruction offset
/// Value - source code segment location
IDictionary GetSegmentsByMethod(MethodDefinition methodDef);
}
有了这个接口,替换 pdb2mdb
为备用的 PDB 读取引擎,或者例如添加对 mdb 文件解析的支持以构建 Mono 编译的程序集的覆盖率,应该足够容易了。
程序集修改
Mono.Cecil 框架提供的功能足以进行程序集插桩,但有一些困难值得指出。首先,为了插桩一个强类型程序集,它的名称和其他强类型程序集的引用应该被弱化。这可以通过以下代码实现:
assembly.Name.PublicKey = null;
assembly.Name.PublicKeyToken = null;
assembly.Name.HasPublicKey = false;
var refs = assembly.MainModule.AssemblyReferences;
foreach (AssemblyNameReference reference in refs)
{
var original = reference.ToString();
reference.HasPublicKey = false;
reference.PublicKeyToken = null;
reference.PublicKey = null;
}
此外,我们需要考虑以下事实:属性中的类型引用与程序集引用分开存储。例如,
[Test]
[ExpectedExceptionAttribute(typeof(SomeCustomException))]
public void TestSomeCustomException() {}
将被编译成类似
.custom instance void [nunit.framework]
NUnit.Framework.ExpectedExceptionAttribute::
.ctor(class [mscorlib]System.Type) = ( some bytes )
其中 some bytes 代表一个 string
的字节表示,例如
"MyApp.Exceptions.SomeCustomException, MyApp, Version=1.0.0.0,
Culture=neutral, PublicKeyToken=c7192dc5380945e7".
而关于这一点最困难的部分是,Reflector 无法跟踪此信息,因为 Reflector 会自动将此字符串替换为指向类型的超链接(尽管可以使用 ILdasm)。
总而言之,我们可以说,为了实现引用弱化 - 不仅程序集清单应被更改为包含更弱的引用,而且程序集所有成员的所有自定义属性都应检查是否包含强引用的 string
并相应地进行修改。
注意: 通用语言运行时检测到无效程序
在程序集插桩过程中可能发生的最令人费解的异常之一是 CLR 产生的奇怪错误:“通用语言运行时检测到无效程序”。我找到追踪此错误根源的方法是执行 ngen 工具对损坏的程序集。结果,这个错误会变得稍微有用一些,并会指向一个损坏的方法。以下是可能的情况列表以及如何克服它们:
- 一些短格式的“
goto
”(分支)操作符在方法体增大后(插桩期间)可能会出现操作数溢出。
使用methodDef.Body.Simplify(); methodDef.Body.Optimize();
方法对可以很容易地解决这个问题(来源)。 - 为了插桩一个特定的指令,我们需要在其之前插入插桩指令,并将所有指向该指令(正在被插桩的指令)的引用更改为我们插入的指令的第一条插桩指令。结果,所有指向被插桩指令的引用将被更新以指向我们的插桩代码。
Try
和catch
命令与指令分开存储,并且需要单独进行插桩:try
块的开始/结束偏移量应被移动,以便指向相应序列点的第一条插桩指令,而不是序列点本身。
有关 2 和 3 的更多详细信息,请参阅 Coverage.Instrument.InstrumentorVisitor.VisitMethodPoint
。
/// <summary>
/// Instruments method instruction, that has corresponding segment of source code
/// </summary>
public override void VisitMethodPoint( ..... )
{
..........
///Change references in operands from "instruction"
///to first counter invocation instruction (instrLoadModuleId)
foreach (Instruction instr in context.MethodWorker.GetBody().Instructions)
{
SubstituteInstructionOperand
(instr, instruction, instrLoadModuleId);
}
var exceptionHandlers = context.MethodWorker.GetBody().ExceptionHandlers;
foreach (ExceptionHandler handler in exceptionHandlers)
{
SubstituteExceptionBoundary
(handler, instruction, instrLoadModuleId);
}
}
序列点命中计数器 / 报告更新器
所有命中次数都将存储在内存中,并在任何 AppDomain.CurrentDomain.DomainUnload
或 AppDomain.CurrentDomain.ProcessExit
事件上将更改刷新到 XML 文件。XML 文件的路径可以通过 getter Coverage.Counter.CoverageFilePath
检索 - 此 getter 已通过 Mono.Cecil 更改为返回实际路径。包含计数器的 DLL 文件 (Coverage.Counter.dll) 被复制到被插桩程序集的文件夹(因为被插桩的程序集引用了计数器 DLL)。
结果
以下是针对 NCover 测试该工具的结果。我在 NHibernate 单元测试(trunk nhibernate 3 alpha)上运行了这两个工具。
NCover 结果

代码覆盖率工具结果

精度
方法覆盖率百分比的差异是由于被插桩程序集中的重复序列点。防止这些重复是该工具可能的改进之一。尽管如此,您可以看到覆盖率百分比仍然接近 NCover 的覆盖率,并且显示的已覆盖/未覆盖行是相同的。
性能
对所有 NHibernate 程序集的插桩大约花费了 6-10 秒,但被插桩程序集上的测试运行速度是 NCover 插桩的程序集上相同测试的两倍。此外,在 nunit 终止后,又花费了 5 秒时间将报告 XML 刷新。
可靠性
该工具已在小型程序/库;NHibernate 框架单元测试(约 2000 个测试);NInject 框架单元测试(约 200 个测试)上进行了测试。然而,NInject 框架中只有一个测试被破坏了。
(Ninject.Tests.DebugInfoFixture.DebugInfoFromStackFrameContainsFileInfo
)。
用法
该工具本身是一个控制台应用程序。以下是可能的命令行参数:
coverage.exe {<AssemblyPaths>} [{-[<FilterType>:]NameFilter}] [<commands>[<commandArgs>]]
- AssemblyPaths - DLL/EXE 文件的文件系统掩码,例如:“C:\Temp\Libs\NHibernate* C:\Temp\NInject\NInject.Core*”
- 过滤器类型
- f: - 按名称排除文件
- s: - 按名称排除程序集(如果某个程序集的强名称需要弱化,但其覆盖率报告是多余的,这可能很有用)
- t: - 按完整名称排除类型
- m: - 按完整名称排除方法
- a: 或无 - 按自定义属性名称排除成员
- Commands
- /r - 如果选择了此命令,则被插桩的程序集将替换现有程序集。旧程序集与相应的 pdb 文件一起被备份。
- /x <coverage file path> - 覆盖率 XML 文件的路径
使用示例
coverage.exe C:\Temp\myapp.exe C:\Temp\myapp.lib.dll -CodeGeneratedAttribute
-t:Test /r /x C:\Temp\coverage2.xml
这将生成被插桩的 myapp.exe 和 myapp.lib.dll,并将旧程序集分别移动到 myapp.bak.exe 和 myapp.lib.bak.dll。名称中包含 'CodeGeneratedAttribute
' 的成员,以及完整名称中包含 'Test
' 的类型将被排除在报告之外。
可能的改进
我能想到几件事情(除了让工具无 bug 之外)。
- 移除重复的插桩 - 从而提高性能和精度
- 将命中次数立即刷新到 XML 文件
- 添加对 Mono mdb 文件的支持并将工具移植到 Linux(我不确定这是否必要,因为 Mono 已经有了一个 monocov 工具)
- 为被插桩的程序集创建 pdb[/mdb] 文件,以便能够调试这些文件
- 在没有 pdb 文件的情况下计算覆盖率 - 这将需要对 IL 代码进行语法分析。至于这一点,我曾考虑重用
nop
操作符作为代码分支的指示符,但它需要 DLL 使用调试配置构建,因此不太可能被使用(因为调试构建的程序集通常附带 pdb 文件)。 - 混合模式 - 重用不同构建的 pdb 作为参考
获取源代码
您可以在 这里 获取该项目的最新源代码。
致谢
感谢 Mono.Cecil 邮件列表 的朋友们在我遇到的问题上提供的帮助。
历史
- 2009-08-24 - 文章的原始版本
关于 许可 的说明
- Mono.Cecil 在 MIT 许可协议 下授权
- Mono.Tools.pdb2mdb 在 Microsoft 公共许可证 (Ms-PL) 下授权