Files
knowledge-kit/Chapter1 - iOS/1.108.md
2024-07-15 20:03:01 +08:00

40 KiB
Raw Blame History

精准测试最佳实践

背景

下面这张图是22年整理的我们移动中台对于质量的一些把控手段也是一个有效的 checklist。对于一个业务项目或者技术项目来说QA 给的测试用例全部通过不能说明代码没有问题。单测覆盖率的最佳实践是针对于基础 SDK对于业务侧的代码来说由于经常变化所以还是以人工测试为主一些核心的不变的核心链路沉淀出 UI 自动化用例,每周迭代的时候,交付测试后,开始 UI 自动化回归。

但,这些回归还是不能 cover 所有问题,我们需要为我们写的每行代码买单,如何衡量每行代码的效果呢?这就是精准测试要做的事情。

iOS 工程来说跨端项目暂时不在本文范畴Native 侧主要是 OC 和 Swift 为主。本文将会从 OC/ Swift 2个技术栈展开说说如何获取精准测试覆盖率报告。

Objective-C 代码覆盖率

理论分析

覆盖率检测原理

统计代码覆盖率的实现抓手就是对代码进行插桩OC 是 C 语言的一个超集,而 LLVM 诞生自 GCC我们可以使用 GCC 的插桩器对 OC 代码进行编译插桩,具体流程如下:

在编译阶段指定 -fprofile-arcs -ftest-coverage 等测试选项LLVM 会做这么几件事:

  • 在输出目标文件中留出一段存储区保存统计数据

    打开一个插桩工程,查看 MachO 文件可以印证。可以看到 __llvm_prf_cnts__llvm_prf_data__llvm_prf_names__llvm_prf_vnds__llvm_covfun__llvm_covmap 等 section 就是存储插桩信息的空间。

  • 在源代码中为每个 Basic Block 进行插桩Basic Block 下文会讲)

    可以看到 showAssets 方法内存在一个 if即2个 Basic Block所以通过汇编查看的话存在2个插桩点。

  • 产生 .gcno 文件,它包含 Basic Block 和相应的源码行号信息

  • 在最终可执行文件中,进入 main 函数之前调用 gcov_init 内部函数初始化统计数据区,并将 gcov_init 内部函数注册为exit_handers,用户代码调用 exit 正常结束时,gcov_exit 函数得到调用,并继续调用 __gcov_flush 输出统计数据到 .gcda 文件。

生成覆盖率报告,首先需要在 Xcode 中配置编译选项,编译后会为每个可执行文件生成对应的 .gcno 文件;之后在代码中调用覆盖率分发函数,会生成对应的 .gcda 文件。

其中,.gcno 包含了代码计数器和源码的映射关系, .gcda 记录了每段代码具体的执行次数。覆盖率解析工具需要结合这两个文件给出最后的检测报表。

.gcno

利用 Clang 分别生成源文件的 AST 和 IR 文件对比发现AST 中不存在计数指令,而 IR 中存在用来记录执行次数的代码。查看 LLVM 源码可以看到 GCDAProfiling.c ,该文件主要作用是:覆盖率映射关系生成源码。

覆盖率映射关系生成源码是 LLVM 的一个 Pass用来向 IR 中插入计数代码并生成 .gcno 文件(关联计数指令和源文件)。

Basic Block

从编译器角度出发基本块Basic BlockBB是代码执行的基本单元LLVM 基于 BB 进行覆盖率计数指令的插入。BB 特点是:

  • 只有1个入口
  • 只有1个出口
  • 只要 BB 中第一条指令被执行,那么 BB 中所有指令都会按顺序执行1次

1个 BB 中,不包含其他的 jump/return/if/switch 等流程控制语句,也就是一个最小可执行单元。

基本块 BB 是程序中一个顺序执行的语句序列,同一个 BB 中所有语句的执行次数一定相同,一般由多个顺序执行语句后跟一个跳转语句组成。

从一个 BB 到另一个 BB 的跳转称为一个 ARC。

GCOV 工作原理

如果跳转语句是有条件的就产生了一个分支ARC该基本块就有2个基本块作为目的地。如果把每个基本块当作一个节点那么一个函数中 的所有基本块就构成了一个有向图, 称之为基本块图. 只要知道 BB 或 ARC 的执行次数就可以推算出所有 的 BB 和所有的 ARC 的执行次数. GCOV 根据 BB 和 ARC 的统计情况来统计各 BB 内各行代码执行情况, 从而计算整个程序的覆盖率情况。

也就是说插桩的数量和函数内的代码行数、函数数量都不是一一对应的关系。插桩数量和 BB 个数一一对应

这样设计的好处是BB 的概念存在已久利用现有能力进行功能拓展插桩分析覆盖率而不是为每行原始代码都插桩从而大大减少了可执行文件的大小并且提高了执行的速度同时还能够精确分析到所有代码的执行情况。x

覆盖率计数指令的插入会进行两次循环,外层循环遍历编译单元中的函数,内层循环遍历函数的基本块。函数遍历仅用来向 .gcno 中写入函数位置信息。

对下面方法展示控制流程图展示:

- (void)showAssets {
    NSLog(@"I am a rich man");
    if (self.name) {
        [self.cat play];
    } else {
        NSLog(@"I am nobody");
    }
}

.gcon 计数符号和文件位置关联信息

.gcon 文件存储着计数插桩位置和源文件之间的关联信息。GCOVPass 通过2层循环插入计数指令的同时会将文件及 BB 信息写入 .gcon 文件。

  • 创建 .gcno 文件,写入 Magic number(oncg + version)
  • 随着函数遍历写入文件地址、函数名和函数在源文件中的起止行数(标记文件名,函数在源文件对应行数)
  • 随着 BB 遍历,写入 BB 编号、BB 起止范围、BB 的后继节点编号(标记基本块跳转关系)
  • 写入函数中BB对应行号信息标注基本块与源码行数关系

.gcon 文件由4部分组成

  • 文件结构
  • 函数结构
  • BB 结构
  • BB 行结构

.gcda

关于 .gcda 的逻辑可以查看源码的 GCDAProfiling.c 文件,也是覆盖率相关的核心逻辑。

void GCOVProfiler::emitGlobalConstructor(
    SmallVectorImpl<std::pair<GlobalVariable *, MDNode *>> &CountersBySP) {
  Function *WriteoutF = insertCounterWriteout(CountersBySP);
  Function *ResetF = insertReset(CountersBySP);

  // Create a small bit of code that registers the "__llvm_gcov_writeout" to
  // be executed at exit and the "__llvm_gcov_reset" function to be executed
  // when "__gcov_flush" is called.
  FunctionType *FTy = FunctionType::get(Type::getVoidTy(*Ctx), false);
  Function *F = createInternalFunction(FTy, "__llvm_gcov_init", "_ZTSFvvE");
  F->addFnAttr(Attribute::NoInline);

  BasicBlock *BB = BasicBlock::Create(*Ctx, "entry", F);
  IRBuilder<> Builder(BB);

  FTy = FunctionType::get(Type::getVoidTy(*Ctx), false);
  auto *PFTy = PointerType::get(FTy, 0);
  FTy = FunctionType::get(Builder.getVoidTy(), {PFTy, PFTy}, false);

  // Initialize the environment and register the local writeout, flush and
  // reset functions.
  FunctionCallee GCOVInit = M->getOrInsertFunction("llvm_gcov_init", FTy);
  Builder.CreateCall(GCOVInit, {WriteoutF, ResetF});
  Builder.CreateRetVoid();

  appendToGlobalCtors(*M, F, 0);
}

二进制代码加载时,调用了 llvm_gcov_init(fn_ptr wfn, fn_ptr rfn) 函数,传入了 __llvm_gcov_writeout 方法用于写 .gcov 文件,__llvm_gcov_reset 方法用于 reset 保存的数据。

然后 emitGlobalConstructor 函数调用 insertGlobalConstructorCode 函数,后者负责插入全局构造函数所需的代码。insertGlobalConstructorCode 函数进一步调用 initializeGCOVDataStructures 函数和 setupCodeCoverageEnvironment 函数,分别用于初始化 .gcov 数据结构和设置代码覆盖率测试环境。

COMPILER_RT_VISIBILITY
void llvm_gcov_init(fn_ptr wfn, fn_ptr rfn) {
  static int atexit_ran = 0;

  if (wfn)
    llvm_register_writeout_function(wfn);

  if (rfn)
    llvm_register_reset_function(rfn);

  if (atexit_ran == 0) {
    atexit_ran = 1;

    /* Make sure we write out the data and delete the data structures. */
    atexit(llvm_delete_reset_function_list);
#ifdef _WIN32
    atexit(llvm_writeout_and_clear);
#endif
  }
}

代码注释是 __gcov_flushLLVM 老版本的 __gcov_flush )已经更新为 __gcov_dump ,调用 __gcov_dump 会将覆盖率信息写入文件。

void __gcov_dump(void) {
  for (struct fn_node *f = writeout_fn_list.head; f; f = f->next)
    f->fn();
}

.gcda 文件/函数结构和 .gcno 基本一致,包含了弧跳变的次数和其他概要信息。利用 gcov -f Person.gcda 就可以可视化查看 .gcda 文件内容

Xcode 导出 .gcda 的时候,断点查看汇编如下

.info 文件

拿到 .gcno.gcda 文件后,我们可以使用 LCOV 工具(基于 gcov )来生成这个源代码文件的覆盖率信息。

覆盖率信息 .info 文件包含以下内容:

  1. TN测试用例名称
  2. SF源码文件路径
  3. FN函数名及行号
  4. FNDA函数名及执行次数
  5. FNF函数总数
  6. FNH函数执行数
  7. DA代码行及执行次数
  8. LF代码总行数
  9. LH代码执行行数

在增量覆盖率信息统计的步骤中,覆盖率信息文件新增了用于统计增量信息的字段:

  1. CA差异代码行及执行次数
  2. CF差异代码行总数
  3. CH差异代码行执行数

完整流程

  • 编译前, 在编译器中加入编译器参数 -fprofile-arcs -ftest-coverage
  • 源码经过编译预处理, 在生成汇编文件的阶段完成插桩,生成可执行文件,并且生成关联 BB 和跳转次数 ARC 的 .gcno 文件
  • 运行可执行文件,随着功能被执行,打点插桩的计数值不断更新,收集程序的执行信息
  • 生成具有 BB 和 ARC 的执行统计次数等数据的 .gcda 文件
  • 通过 lcov、genhtml 将代码覆盖率信息生成 html 格式的报告

工程实践

第一步,在 Xcode Build Settings 中,修改 Clang 编译参数 Instrument Program FlowGenerate Legacy Test Coverage File 为 true打开后即开启插桩能力

第二步,为了控制代码覆盖率保存的位置和文件名,需要我们设置一下 GCC 提供的环境变量

  • GCOV_PREFIX 环境变量用于指定代码覆盖率文件的存储路径
  • GCOV_PREFIX_STRIP 环境变量用于指定在存储路径中去除的前缀部分。
NSString *covFilePath = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/coverage_files"];
setenv("GCOV_PREFIX", [covFilePath cStringUsingEncoding: NSUTF8StringEncoding], 1);
setenv("GCOV_PREFIX_STRIP", "100", 1);

第三步,开启插桩后即拥有了原始 BB 信息,也开启了插桩。等待用户操作 App 后,即记录了 BB 执行信息,这些信息需要被写入 .gcda 中。早期版本是 gcov_flush()。可以看到 _gcov_flush 已经不能用了,发现官方已经是 _gcov_dump 。修改后编译通过。

extern void __gcov_dump(void);
__gcov_dump();

第四步,运行代码。完成测试后,我在屏幕点击事件里,将 BB 执行情况写入到 .gcda 中。

第五步,获取 .gcno 信息。编译器生成与源代码同名的 .gcno 文件note file这种文件含有重建基本块依赖图和将源代码关联至基本块及源代码行号的必要信息。

Xcode 选择 productsshow In Finder。然后上上层的 Intermediates.noindex 目录存储,继续往下寻找,我个人电脑上路径为:/Users/unix_kernel/Library/Developer/Xcode/DerivedData/CodeCoverageDemo-enpprvshxhvihgavktgzcmeoertf/Build/Intermediates.noindex/CodeCoverageDemo.build/Debug-iphonesimulator/CodeCoverageDemo.build/Objects-normal/x86_64,存储了 .gcno 信息。

第六步,将 .gcno.gcda 文件,保存到一个文件夹下

第七步,利用 lcov 指令可以将 .gcno 文件和 .gcda 文件结合生成代码覆盖率结果 info 文件

指令格式为:lcov -c -d . -o CodeCoverage.info ,其中 . 代表当前目录

CodeCoverage.info 文件内容大概如下(各个字段代表什么上面 info 文件这一节有说明)。

第八步,利用指令 genhtml -o html CodeCoverage.info 将 info 文件和源代码文件结合转化为可视化网页形式。

注意:执行 genhtml 指令必须保证和项目源代码Xcode 项目叫 CodeCoverageDemo源码则在 CodeCoverageDemo/CodeCoverageDemo 下)在同一文件夹下否则会报错。

访问覆盖率路径为 html 目录下,和项目同名的文件夹里面的 index.html

第九步,通过类的列表,针对覆盖率低的文件,点进去看看,看看那些代码没有被执行。思考是什么原因造成的:

  • if...else 代码是由于测试条件不满足,测试 case 不充足,导致另一个 case 没有被覆盖??
  • 某些兜底代码太多,根本走不到???

其中:蓝色部分代码已经执行的代码,橘色代表未执行的代码

第十步假设我们在另一台设备上进行了测试对剩余的测试任务内容进行完善这个时候该怎么处理Demo 以针对 Person 类的覆盖率完善为例。

  1. 在另一台测试剩余 case 的机器上,执行测试流程。得到测试结果,即 .gcda 文件

  2. 新建测试数据分析文件夹 CodeCoverageAnalysis2

  3. 将上一步得到的 .gcda 文件拷贝到 ``CodeCoverageAnalysis2` 里面

  4. 进入打包产物 App 所在文件夹,进入文件夹 Build/Intermediates.noindex/CodeCoverageDemo.build/Debug-iphonesimulator/CodeCoverageDemo.build/Objects-normal/x86_64,可以看到一堆类似 AppDelegate.dAppDelegate.dia AppDelegate.gcnoAppDelegate.o 这样的文件。同样移动到 ``CodeCoverageAnalysis2` 里面

  5. CodeCoverageAnalysis2 目录下利用指令 lcov -c -d . -o CodeCoverage2.info 生成新的一份覆盖率信息 CodeCoverage2.info

  6. 然后利用 locv -a 指令合并2个 .info 文件。指令为 lcov -a CodeCoverage2.info -a https://github.com/FantasticLBP/knowledge-kit/raw/master/CodeCoverageAnalysis/CodeCoverage.info -o CodeCoverageCombined.info

  7. 然后利用 genhtml 生成合并后的覆盖率可视化 html 文件 genhtml -o html CodeCoverageCombined.info

  8. 查看分析最新的覆盖率报告

缺陷

Person 类的 showAssets 方法,内部有 Cat 相关逻辑,且 Cat 是 Swift 代码。为什么在代码覆盖列表上看不到 Cat

因为clang 是一个基于 LLVM 的编译器前端,它可以编译多种编程语言,包括 C、C++、Objective-C 和 Objective-C++。然而,虽然 clang 本身基于 LLVM但它并不是 Swift 语言的默认编译器。Swift 语言的官方编译器是 swiftc,是基于 LLVM 但专门为 Swift 语言设计的。

接下来看看 Swift 代码,如何获取代码覆盖率

工程化

工程化要解决的3个问题是

  • 一般来说iOS 现在采用模块化的方式:壳工程 + 各个业务域子工程 + 3方模块。可通过 ruby 脚本修改壳工程和相应的业务工程的编译配置,开启编译插桩能力。一般对于 Debug 包来说不插桩,所以需要有个配置文件,来对各个模块进行配置。
  • 单个版本不断测试,生成的代码覆盖率信息如何合并
  • 多版本增量覆盖率
  • 打包平台及其服务侧

模块化配置

对于各个模块在什么模式下插桩的配置, CodeCoverageConfig.rb

ENABLE_PROJECTS = {
    "XXX/XXXPhone.xcodeproj"               => "Enterprise",
    "XXXHD/XXXHD.xcodeproj"           		 => "Enterprise",
		"Pods/XXXGoods.xcodeproj"              => "Enterprise",
		// ...
}

Ruby 脚本利用 xcodeproj 对每个 target 的编译参数 GCC_INSTRUMENT_PROGRAM_FLOW_ARCSGCC_GENERATE_TEST_COVERAGE_FILES进行修改以开启插桩能力

require 'xcodeproj'
CONFIG_DIR = Pathname.new(File.join(File.dirname(__FILE__), ".https://github.com/FantasticLBP/knowledge-kit/raw/master/../..")).realpath
CONFIG_FILE = File.join(CONFIG_DIR, "CodeCoverageConfig.rb")

def update(args)
    enable = args[0] == "true" ? "YES" : "NO"
    debug = args[1] == "true" ? true : false 
    load "#{CONFIG_FILE}"
    projects = ENABLE_PROJECTS
    projects.each do | proj, conf | 
        proj_file = File.join(CONFIG_DIR, proj)
        project = Xcodeproj::Project.open(proj_file)
        project.build_configurations.each do |config|
            next if debug && config.name != "Debug"
            next if !debug && config.name != conf
            config.build_settings['GCC_INSTRUMENT_PROGRAM_FLOW_ARCS'] = enable
            config.build_settings['GCC_GENERATE_TEST_COVERAGE_FILES'] = enable
        end
        project.save
    end    
end

update(ARGV)

单版本覆盖率

代码不变的情况下,发现 QA 或者开发自己测试的情况下,发现代码覆盖率不高,测试没有全面,则继续测试。这样生成多分 .gcda 文件,

  • 生成覆盖率:lcov -c -d {$SOURCE} -o {$DEST_INFO},比如 lcov -c -d . -o CodeCoverage2.info
  • 合并覆盖率:lcov -a {$SOURCE_INFO_1} -a {$SOUCE_INFO_2} -o {$DEST_INFO},比如 lcov -a CodeCoverage2.info -a https://github.com/FantasticLBP/knowledge-kit/raw/master/CodeCoverageAnalysis/CodeCoverage.info -o CodeCoverageCombined.info

多版本增量覆盖率

一个常见的场景是,开发同学基于业务需求 A 做完功能QA 测试后导出覆盖率报告,发现覆盖率较低;或者 QA 提了3个测试 Bug开发针对这2个情况去修改了代码重新打包让 QA 回归。这个时候 QA 不会重新点点,较好的做法是只回归遗漏或者有问题的代码。

核心思路是:基于上个版本的覆盖率数据,利用 git diff 查找出变化的部分,然后将旧版本覆盖率 .info 里面喝 git diff 得出的变化的部分关联,将值更新到新测试后的覆盖率 .info 里面。

git diff 如何解读

其中

  • index.txt 是文件名
  • @@ -3,6 +3,6 @@ - 代表删除,+ 代表增加。整体意思为从第3行开始删除了6行从第3行开始增加了6行

所以步骤如下:

  • 解析 git diffFile

    • 根据文件名匹配规则 diff --git (.*) 将 diffFile 解析为若干个文件的数组集合 diffInfoList并且保存文件信息
    • 根据 diff 块匹配规则 @@(.*)@@ 将每个文件的 diffInfo 解析为若干个 diff 块的 blockInfoList并且保存块信息
    • 根据增 / 删代码匹配规则 (\+|\-)(.*) 将每个块的 blockInfo 解析为若干个修改行号的增 / 删行数,并保存增 / 删信息 {'delLine': 3, 'delCount': 6, 'addLine': 3, 'addCount': 6}
  • 解析 info 文件

    • 根据文件名匹配 SF:*end_of_record: 规则将 info 解析为若干个文件的 fileInfoList并且保存文件信息
    • 根据函数行、函数执行次数、代码行及执行次数匹配规则 FN、FNDA、DA 将每个文件的 fileInfo 解析为若干个执行信息的 daList并且保存数据信息 {'lineNo': 12, 'exeCount': 1, 'funName': 'eat'}
  • 生成 info 文件

    • 根据 diffFile 解析结果,遍历 blockInfo 匹配起始修改行号 delLine 及修改行数 diffline = addCount - delCount,将 info 的解析结果进行行号匹配和增 / 删操作 if (lineNo > delLine) lineNo += diffLine,修改 fileInfoList 。这一步其实就是根据 git diff 信息,将新的覆盖率中的 lineNo 进行更新
    • 将新的 fileInfoList 中的数据根据 info 的结构进行写入文件操作

完成行号平移之后,两个版本的 .info 文件中的数据已经对齐了行号,可以用上述 LCOV 工具进行合并,合并完成后,用行号标记来统计差异的代码覆盖率数据。

打包平台及其服务侧

  • 编写脚本在打包插桩后,将 .gcno 和源代码等信息上传到文件服务器上
  • 移动端各个测试设备测试后App 可视化导出精准测试覆盖率报告,一键将 .gcda 文件上传到文件服务器上
  • 上传 .gcda 触发任务,利用 lcov 处理展示报告,同时也保存到文件服务器上
  • 最后 lark、企业微信通知能力发送报告链接给开发、QA和相关人员
  • 同时 mPass 项目平台,买票上高铁的项目列表也有入口可以展示查看精准覆盖率报告

Swift 代码覆盖率

这部分我将介绍:

  • 如何生成 .profraw 文件并通过命令行测量代码覆盖率

  • 如何在 Swift 项目里调用 c/c++ 方法

  • 如何在 Xcode 中测量完整 Swift App 项目的代码覆盖率

理论支撑

编译器参数支持

思路同 Objective-C 一样,参看 swiftc 编译器的编译参数 swiftc --help 可以看到

可以看到这2个参数是大概收集代码覆盖率相关的。

MachO 和汇编插桩验证

利用 MachOView 查看产物里的 Mach-O 文件发现MachO 多了一些和 LLVM 相关的 section这些 section 看名字猜出来都是用来统计覆盖率的。

当 Xcode 开启 Swift 插桩统计后,打断点查看汇编代码可以发现,在 sayHi 方法也就是只有1个 Basic Block 的情况下编译器只插入1个桩插桩1次。

可以把 __profc_xxx 理解为打点计数信息,具体的地址保存在 MachO 文件的 __DATA__llvm_prf_cnts 节点中。在程序刚启动时所有的计数器信息为0每当该代码BB块被执行1次其计数值会加一。

重要的2个参数

  • -profile-generate:负责插桩代码的生成,是统计插桩信息用来的。__llvm_prf 段。
  • -profile-coverage-mapping :则生成一些 LLVM 相关的 __LLVM_COV 段。

之所以要做这样的拆分,猜测可能的原因是,插桩信息除了可以用于覆盖率分析以外,还可以用来进行 PGO 优化。什么是 PGO即 Profile Guided Optimization ,是编译器用于提升 Application 的性能的一项技术。具体可以查看这篇文章编译器利用 PGO 优化 App 性能

导出原理

llvm-cov 如何生成报告的?因为 .profdata 文件只有 BB 计数器的调用次数,在生成覆盖率的时候传入了源码,那计数器信息和源码关联应该就是靠 MachO 文件了。

LLVM Code Coverage Mapping Format 也说明了该细节

LLVMs code coverage mapping format is designed to be a self contained data format that can be embedded into the LLVM IR and into object files. Its described in this document as a mapping format because its goal is to store the data that is required for a code coverage tool to map between the specific source ranges in a file and the execution counts obtained after running the instrumented version of the program.

The mapping data is used in two places in the code coverage process:

  1. When clang compiles a source file with -fcoverage-mapping, it generates the mapping information that describes the mapping between the source ranges and the profiling instrumentation counters. This information gets embedded into the LLVM IR and conveniently ends up in the final executable file when the program is linked.
  2. It is also used by llvm-cov - the mapping information is extracted from an object file and is used to associate the execution counts (the values of the profile instrumentation counters), and the source ranges in a file. After that, the tool is able to generate various code coverage reports for the program.

LLVM 的代码覆盖率映射格式被设计为一种自包含的数据格式,可以嵌入 LLVM IR 和 .o 文件中。在本文档中,它被描述为映射格式,因为它的目标是存储代码覆盖率工具在文件中的特定源范围和运行插入指令的程序版本后获得的执行计数之间进行映射所需的数据。 在代码覆盖过程中,映射数据用于两个位置:

  • 当 clang 使用 -fcoverage-mapping 编译源文件时它会生成描述源范围和分析检测计数器之间映射的映射信息。这些信息被嵌入LLVM IR中并在链接程序时方便地最终出现在最终的可执行文件中。
  • 它也被 llvm-cov 使用-映射信息从对象文件中提取,用于关联文件中的执行计数(配置文件检测计数器的值)和源范围

在此之后,该工具能够为程序生成各种代码覆盖率报告。

完整流程为:

覆盖率生成流程为:编译阶段使用 -profile-generate-profile-coverage-mapping 参数,其中 -profile-generate 会开启插桩能力,为每个 BB 增加插桩代码,-profile-coverage-mapping 将记录 BB、计数器值、和文件源码的关联映射信息并将这些信息存储在编译产物也就是 __LLVM_COV 段中。编译产物运行的过程中, 随着 BB 被执行,计数器的值会不断增加,并且写入 __DATA 段。运行结束后生成 .profraw 文件,可以处理成 .profdata 文件,该文件记录了每个计数器以及调用次数。

覆盖率解析流程为:利用指令提供的源代码路径,和可执行文件信息,结合 .profdata 信息,产出覆盖率报告。具体原理是:遍历 profdata 中的每一个计数器,先根据可执行文件中存储的映射关系,找到这个计数器所对应统计的那一段源码,从而生成行级别的覆盖率信息。

实验

用简单的单个 Swift 文件进行理论分析。

第一步,创建一个名为 test.swift 的文件,内容如下:

func sayHi() {
    print("Hello swift world")
}

func add(_ x: Int, _ y: Int) -> Int {
    return x + y
}

func minuse(_ x: Int, _ y: Int) -> Int {
    return x - y
}

sayHi()
print(add(2, 4))

第二步,在终端命令行,test.swift 所在路径下执行下面指令 swiftc -profile-generate -profile-coverage-mapping test.swift

传递给编译器的选项 -profile-generate-profile-coverage-mapping 将在编译源码时启用覆盖率特性。基于源码的代码覆盖功能直接对 AST 和预处理器信息进行操作。

第三步,运行二进制文件 ./test。然后在当前目录执行 ls,可以看到多出了一个名为 default.profraw 的文件。该文件由 llvm 生成,目的是衡量代码覆盖率。我们必须使用配套工具 llvm-profdata 来组合多个原始配置文件并同时对其进行索引。

第四步,终端运行指令 xcrun llvm-profdata merge -sparse default.profraw -o coverage.profdata,得到一个名为 coverage.profdata 的文件,进一步处理,它可以用来展示覆盖率报告。

第五步,在终端运行指令得到覆盖率信息

xcrun llvm-cov show ./test -instr-profile=coverage.profdata
xcrun llvm-cov export ./test -instr-profile=coverage.profdata

整个步骤也可以看这张图

test.swift 中编写的3个函数只有2个执行了。查看覆盖率可以证实这一点minuse 函数没有被执行。

工程实践

第一步,创建 Swift 项目,编写测试代码

// Cat.swift
import Foundation
class Cat {
    var kind: String
    init(kind: String) {
        self.kind = kind
    }
    
    func play() {
        print("I am a \(kind) cat, I am playing now.")
    }
}

// Person.swift
import Foundation
class Person {
    var name: String
    var cat: Cat?
    
    init(name: String, cat: Cat? = nil) {
        self.name = name
        self.cat = cat
    }
    
    func sayHi() {
        print("Hello world, I am \(name), I have a \(String(describing: cat?.kind)) cat")
    }
    
    func eat() {
        print("eat")
    }
    
    func sleep() {
        print("sleep")
    }
    
    func play() {
        cat?.play()
    }
}

第二步,选择 Build Settings -> Swift Compiler — Custom Flags,在 Other Swift Flags 添加 -profile-generate-profile-coverage-mapping 选项。

第三步,开启覆盖率收集选项

第四步,要将覆盖率信息导出前,必须要调用 llvm 的一些 c/c++ api所以要将需要用到的方法导出为一个模块。

创建一个名为 InstrProfiling.h 的头文件。内容为:

#ifndef PROFILE_INSTRPROFILING_H_
#define PROFILE_INSTRPROFILING_H_int __llvm_profile_runtime = 0;void __llvm_profile_initialize_file(void);

const char *__llvm_profile_get_filename();
void __llvm_profile_set_filename(const char *);
int __llvm_profile_write_file();
int __llvm_profile_register_write_file_atexit(void);
const char *__llvm_profile_get_path_prefix();

#endif /* PROFILE_INSTRPROFILING_H_ */

创建一个 module.modulemap 文件并将所有内容导出为一个模块(创建的时候 Xcode 选择 empty 模版)

module InstrProfiling {
    header "InstrProfiling.h"
    export *
}

第五步,判断时机,在需要导出覆盖率的地方编写函数。我在 ViewController 点击屏幕的时候导出:

  • 导入模块 import InstrProfiling
  • 编写导出方法 __llvm_profile_set_filename__llvm_profile_write_file
import UIKit
import InstrProfiling

class ViewController: UIViewController {
    var cat: Cat?
    var person: Person?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.cat = Cat(kind: "Ragdoll")
        self.person = Person(name: "FantasticLBP", cat: cat)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.person?.sayHi()
        self.person?.play()
        // Do any additional setup after loading the view.
        print("File Path Prefix: \(String(cString: __llvm_profile_get_path_prefix()) )")
        print("File Name: \(String(cString: __llvm_profile_get_filename()) )")
        let name = "SwiftCodeCoverage.profraw"
        let fileManager = FileManager.default
        
        do {
            let documentDirectory = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor:nil, create:false)
            let filePath: NSString = documentDirectory.appendingPathComponent(name).path as NSString
            __llvm_profile_set_filename(filePath.utf8String)
            print("File Name: \(String(cString: __llvm_profile_get_filename()))")
            __llvm_profile_write_file()
        } catch {
         print(error)
        }
    }
}

第六步, 运行代码,生成 .profraw 格式的文件。

第七步,因为产出覆盖率的时候需要用到 MachO 文件。所以在项目根目录下创建名为 DataAnalysis 的文件夹。在终端利用 mv 将产物里的 MachO 移动到 DataAnalysis 文件夹下。也将 .profraw 移动进去。

第八步,利用指令 xcrun llvm-profdata merge -sparse SwiftCodeCoverage.profraw -o SwiftCodeCoverage.profdata,将 .profraw 转换成 .profdata 文件

第九步,利用指令 xcrun llvm-cov show ./SwiftCodeCoverage.app/SwiftCodeCoverage -instr-profile=SwiftCodeCoverage.profdata 在终端查看代码的覆盖情况

第十步,终端查看代码执行情况还是不够直观,可以用 llvm-cov 命令生成 HTML 格式的覆盖率报告,指令格式为:

xcrun llvm-cov show\
			-use-color\			# 彩色报告
			-format=html\		# HTML 格式
			-arch=x86_64\		# 架构指令集
			-instr-profile=${.profdata 路径}\	# 指定 .profdata 文件路径
			${MachO 文件路径}\								 # 指定 MachO文件路径
			${SourceCode 路径}								# 项目源代码路径
			-output-dir ${Swift覆盖率报告路径}  # 指定覆盖率报告保存的路径

我这边具体指令为:

xcrun llvm-cov show -use-color -format=html -arch=x86_64 -instr-profile=SwiftCodeCoverage.profdata SwiftCodeCoverage https://github.com/FantasticLBP/knowledge-kit/raw/master/ -output-dir ./SwiftCodeCoverageReport

第十一步,查看整体的覆盖率信息与单个文件的覆盖率,查看代码执行情况

其中 index.html 是所有文件的覆盖率数据汇总,而每个文件精确到行级别的覆盖率信息,则保存在 coverage 文件夹中,每个文件对应一个 HTML 文件。

第十二步,假设我们在另一台 CI 机器上也在执行测试任务。那不同机器上的测试结果如何合并?

生成覆盖率报告是基于插桩实现的,最后 xcrun llvm-cov 生成 html 需要的是Mach-O 文件、源代码路径、.profdata 文件。

看得出来不同 CI 机器上,不同的只有 .profdata 文件,所以处理 .profdata 即可。所幸 llvm-profdata 就支持不同的 .profraw 的合并。

比如第一台机器生成的是 SwiftCodeCoverage.profraw 得到的覆盖率如上图所示。第二台机器生成的是 SwiftCodeCoverage.profraw

接下去利用指令 xcrun llvm-profdata merge SwiftCodeCoverage.profraw SwiftCodeCoverage2.profraw -o SwiftCodeCoverageCombined.profdata 将2份测试原始文件进行合并然后再利用 llvm-cov 生成 html 报告

第十三步,我们有时候有需求会更改操作生成的测试文件,.profdata 是没办法修改的,但 llvm-profdata 指令可以传递参数生成 .text 格式的文件,里面的内容可以修改。修改后再从 .text 转换为 .profdata,最后再利用 llvm-cov 生成 html 报告。

下面演示下如何修改生成的覆盖率数据(注意:不修改 html而是修改 BB 的计数值)

  1. 利用 xcrun llvm-profdata merge SwiftCodeCoverage.profraw SwiftCodeCoverage2.profraw -text -o SwiftCodeCoverageCombined.txt 将2份 .profraw 数据合并为 .txt 格式的文件(记录了 BB 和技术值信息)

  2. 编辑修改 .txt BB 的计数值,此处,故意把 Person:sleep 的1改为0

  3. 利用指令将 .txt 改为 .profdata 格式。xcrun llvm-profdata merge SwiftCodeCoverageCombined.txt -o SwiftCodeCoverageCombinedFromText.profdata

  4. 再根据合并后的 .profdata 生成 html 覆盖率报告。xcrun llvm-cov show -use-color -format=html -arch=x86_64 -instr-profile=SwiftCodeCoverageCombinedFromText.profdata SwiftCodeCoverage https://github.com/FantasticLBP/knowledge-kit/raw/master/ -output-dir ./SwiftCodeCoveragCombinedReportFromText

效果如下:

心得感悟

下面是一个工作中的实际例子,冒烟用例也全部通过了,代码在 CR 后 MR 了,然后买票上车,开始高铁回归阶段。

QA 去回归测试,然后会给开发一个精准测试报告。就是原始的本次业务开发分支上的代码执行情况。程序员去分析,覆盖率低的原因是什么,是兜底代码太多、还是某些技术实现是类似夸端的 Weex、RN、Flutter、还是测试 case 不充分以至于看上去用例通过,但是某些代码还是没有测试到,往往这些没有测试到、执行到的代码是线上用户在极端情况下容易走到的 case。所以需要根据精准测试覆盖率反推 QA 完善用例,或者开发自己优化代码。

精准测试的价值很明显,但 ROI 就见仁见智了,有些人觉得要开发一套 CI 需要耗时耗力,每个项目完成后需要分析精准测试报告、反推 QA 完善用例很麻烦,但有些决策者就觉得这样能 cover 一些平时难以发现的问题。

参考文章

Source-based Code Coverage

llvm-cov - emit coverage information

LLVM Code Coverage Mapping Format