iOS 覆盖率检测原理与增量代码测试覆盖率工具实现(上)

发表于:2022-8-30 09:13

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:丁京 王颖    来源:美团技术团队

  背景
  对苹果开发者而言,由于平台审核周期较长,客户端代码导致的线上问题影响时间往往比较久。如果在开发、测试阶段能够提前暴露问题,就有助于避免线上事故的发生。代码覆盖率检测正是帮助开发、测试同学提前发现问题,保证代码质量的好帮手。
  对于开发者而言,代码覆盖率可以反馈两方面信息:
  自测的充分程度。
  代码设计的冗余程度。
  尽管代码覆盖率对代码质量有着上述好处,但在 iOS 开发中却使用的不多。我们调研了市场上常用的 iOS 覆盖率检测工具,这些工具主要存在以下四个问题:
  第三方工具有时生成的检测报告文件会出错甚至会失败,开发者对覆盖率生成原理不了解,遇到这类问题容易弃用工具。
  第三方工具每次展示全量的覆盖率报告,会分散开发者的很多精力在未修改部分。而在绝大多数情况下,开发者的关注重点在本次新增和修改的部分。
  Xcode 自带的覆盖率检测只适用于单元测试场景,由于需求变更频繁,业务团队开发单元测试的成本很高。
  已有工具很难和现有开发流程结合起来,需要额外进行测试,运行覆盖率脚本才能获取报告文件。
  为了解决上述问题,我们深入调研了覆盖率报告的生成逻辑,并结合团队的开发流程,开发了一套嵌入在代码提交流程中、基于单次代码提交(git commit)生成报告、对开发者透明的增量代码测试覆盖率工具。开发者只需要正常开发,通过模拟器测试开发代码,commit 本次代码(commit 和测试顺序可交换),推送(git push)到远端,就可以在本地看到这次提交代码的详细覆盖率报告了。
  本文分为两部分,先从介绍通用覆盖率检测的原理出发,让读者对覆盖率的收集、解析有直观的认识。之后介绍我们增量代码测试覆盖率工具的实现。
  覆盖率检测原理
  生成覆盖率报告,首先需要在 Xcode 中配置编译选项,编译后会为每个可执行文件生成对应的 .gcno 文件;之后在代码中调用覆盖率分发函数,会生成对应的 .gcda 文件。
  其中,.gcno 包含了代码计数器和源码的映射关系, .gcda 记录了每段代码具体的执行次数。覆盖率解析工具需要结合这两个文件给出最后的检测报表。接下来先看看 .gcno 的生成逻辑。
  .gcno
  利用 Clang 分别生成源文件的 AST 和 IR 文件,对比发现,AST 中不存在计数指令,而 IR 中存在用来记录执行次数的代码。搜索 LLVM 源码可以找到覆盖率映射关系生成源码。覆盖率映射关系生成源码是 LLVM 的一个 Pass,(下文简称 GCOVPass)用来向 IR 中插入计数代码并生成 .gcno 文件(关联计数指令和源文件)。
  下面分别介绍IR插桩逻辑和 .gcno 文件结构。
  IR 插桩逻辑
  代码行是否执行到,需要在运行中统计,这就需要对代码本身做一些修改,LLVM 通过修改 IR 插入了计数代码,因此我们不需要改动任何源文件,仅需在编译阶段增加编译器选项,就能实现覆盖率检测了。
  从编译器角度看,基本块(Basic Block,下文简称 BB)是代码执行的基本单元,LLVM 基于 BB 进行覆盖率计数指令的插入,BB 的特点是:
  只有一个入口。
  只有一个出口。
  只要基本块中第一条指令被执行,那么基本块内所有指令都会顺序执行一次。
  覆盖率计数指令的插入会进行两次循环,外层循环遍历编译单元中的函数,内层循环遍历函数的基本块。函数遍历仅用来向 .gcno 中写入函数位置信息,这里不再赘述。
  一个函数中基本块的插桩方法如下:
  统计所有 BB 的后继数 n,创建和后继数大小相同的数组 ctr[n]。
  以后继数编号为序号将执行次数依次记录在 ctr[i] 位置,对于多后继情况根据条件判断插入。
  举个例子,下面是一段猜数字的游戏代码,当玩家猜中了我们预设的数字10的时候会输出Bingo,否则输出You guessed wrong!。这段代码的控制流程图如图1所示(猜数字游戏 )。
- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSLog(@"Welcome to the game");
    if (guessNumber == 10) {
        NSLog(@"Bingo!");
    } else {
        NSLog(@"You guess is wrong!");
    }
}
  这段代码如果开启了覆盖率检测,会生成一个长度为 6 的 64 位数组,对照插桩位置,方括号中标记了桩点序号,图 1 中代码前数字为所在行数。
图 1 桩点位置
  .gcno计数符号和文件位置关联
  .gcno 是用来保存计数插桩位置和源文件之间关系的文件。GCOVPass 在通过两层循环插入计数指令的同时,会将文件及 BB 的信息写入 .gcno 文件。写入步骤如下:
  创建 .gcno 文件,写入 Magic number(oncg+version)。
  随着函数遍历写入文件地址、函数名和函数在源文件中的起止行数(标记文件名,函数在源文件对应行数)。
  随着 BB 遍历,写入 BB 编号、BB 起止范围、BB 的后继节点编号(标记基本块跳转关系)。
  写入函数中BB对应行号信息(标注基本块与源码行数关系)。
  从上面的写入步骤可以看出,.gcno 文件结构由四部分组成:
  文件结构
  函数结构
  BB 结构
  BB 行结构
  通过这四部分结构可以完全还原插桩代码和源码的关联,我们以 BB 结构 / BB 行结构为例,给出结构图 2 (a) BB 结构,(b) BB 行信息结构,在本章末尾覆盖率解析部分,我们利用这个结构图还原代码执行次数(每行等高格代表 64bit):
图2 BB 结构和 BB 行信息结构
  .gcda
  入口函数
  关于 .gcda 的生成逻辑,可参考覆盖率数据分发源码。这个文件中包含了 __gcov_flush() 函数,这个函数正是分发逻辑的入口。接下来看看 __gcov_flush() 如何生成 .gcda 文件。
  通过阅读代码和调试,我们发现在二进制代码加载时,调用了 llvm_gcov_init(writeout_fn wfn, flush_fn ffn) 函数,传入了 _llvm_gcov_writeout(写 gcov 文件),_llvm_gcov_flush(gcov 节点分发)两个函数,并且根据调用顺序,分别建立了以文件为节点的链表结构。(flush_fn_node * ,writeout_fn_node *)
  __gcov_flush() 代码如下所示,当我们手动调用 __gcov_flush()进行覆盖率分发时,会遍历flush_fn_node *这个链表(即遍历所有文件节点),并调用分发函数_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函数类型)。
void __gcov_flush() {
    struct flush_fn_node *curr = flush_fn_head;
    
    while (curr) {
        curr->fn();
        curr = curr->next;
    }
}
  具体的分发逻辑
  观察__llvm_gcov_flush的 IR 代码,可以看到:
图3 __llvm_gcov_flush 代码示例
  __llvm_gcov_flush先调用了__llvm_gcov_writeout,来向 .gcda 写入覆盖率信息。
  最后将计数数组清零__llvm_gcov_ctr.xx。
  而 __llvm_gcov_writeout 逻辑为:
  生成对应源文件的 .gcda 文件,写入 Magic number。
  循环执行
  llvm_gcda_emit_function: 向 .gcda 文件写入函数信息。
  llvm_gcda_emit_arcs: 向 .gcda 文件写入BB执行信息,如果已经存在 .gcda 文件,会和之前的执行次数进行合并。
  调用llvm_gcda_summary_info,写入校验信息。
  调用llvm_gcda_end_file,写结束符。
  感兴趣的同学可以自己生成 IR 文件查看更多细节,这里不再赘述。
  .gcda 的文件/函数结构和 .gcno 基本一致,这里不再赘述,统计插桩信息结构如图 4 所示。定制化的输出也可以通过修改上述函数完成。我们的增量代码测试覆盖率工具解决代码 BB 结构变动后合并到已有 .gcda 文件不兼容的问题,也是修改上述函数实现的。
图4 计数桩输出结构
  覆盖率解析
  在了解了如上所述 .gcno ,.gcda 生成逻辑与文件结构之后,我们以例 1 中的代码为例,来阐述解析算法的实现。
  例 1 中基本块 B0,B1 对应的 .gcno 文件结构如下图所示,从图中可以看出,BB 的主结构完全记录了基本块之间的跳转关系。
图5 B0,B1 对应跳转信息
  B0,B1 的行信息在 .gcno 中表示如下图所示,B0 块因为是入口块,只有一行,对应行号可以从 B1 结构中获取,而 B1 有两行代码,会依次把行号写入 .gcno 文件。
图6 B0,B1 对应行信息
  在输入数字 100 的情况下,生成的 .gcda 文件如下:
图7 输入 100 得到的 .gcda 文件
  通过控制流程图中节点出边的执行次数可以计算出 BB 的执行次数,核心算法为计算这个 BB 的所有出边的执行次数,不存在出边的情况下计算所有入边的执行次数(具体实现可以参考 gcov 工具源码),对于 B0 来说,即看 index=0 的执行次数。而 B1 的执行次数即 index=1,2 的执行次数的和,对照上图中 .gcda 文件可以推断出,B0 的执行次数为 ctr[0]=1,B1 的执行次数是 ctr[1]+ctr[2]=1, B2 的执行次数是 ctr[3]=0,B4 的执行次数为 ctr[4]=1,B5 的执行次数为 ctr[5]=1。
  经过上述解析,最终生成的 HTML 如下图所示(利用 lcov):
图8 覆盖率检测报告
  以上是 Clang 生成覆盖率信息和解析的过程,下面介绍美团到店餐饮 iOS 团队基于以上原理做的增量代码测试覆盖率工具。
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号