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

背景

对苹果开发者而言,因为平台审核周期较长,客户端代码致使的线上问题影响时间每每比较久。若是在开发、测试阶段可以提早暴露问题,就有助于避免线上事故的发生。代码覆盖率检测正是帮助开发、测试同窗提早发现问题,保证代码质量的好帮手。php

对于开发者而言,代码覆盖率能够反馈两方面信息:html

  1. 自测的充分程度。
  2. 代码设计的冗余程度。

尽管代码覆盖率对代码质量有着上述好处,但在 iOS 开发中却使用的很少。咱们调研了市场上经常使用的 iOS 覆盖率检测工具,这些工具主要存在如下四个问题:node

  1. 第三方工具备时生成的检测报告文件会出错甚至会失败,开发者对覆盖率生成原理不了解,遇到这类问题容易弃用工具。
  2. 第三方工具每次展现全量的覆盖率报告,会分散开发者的不少精力在未修改部分。而在绝大多数状况下,开发者的关注重点在本次新增和修改的部分。
  3. Xcode 自带的覆盖率检测只适用于单元测试场景,因为需求变动频繁,业务团队开发单元测试的成本很高
  4. 已有工具很难和现有开发流程结合起来,须要额外进行测试,运行覆盖率脚本才能获取报告文件。

为了解决上述问题,咱们深刻调研了覆盖率报告的生成逻辑,并结合团队的开发流程,开发了一套嵌入在代码提交流程中、基于单次代码提交(git commit)生成报告、对开发者透明的增量代码测试覆盖率工具。开发者只须要正常开发,经过模拟器测试开发代码,commit 本次代码(commit 和测试顺序可交换),推送(git push)到远端,就能够在本地看到此次提交代码的详细覆盖率报告了。c++

本文分为两部分,先从介绍通用覆盖率检测的原理出发,让读者对覆盖率的收集、解析有直观的认识。以后介绍咱们增量代码测试覆盖率工具的实现。git

覆盖率检测原理

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

其中,.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 的特色是:

  1. 只有一个入口。
  2. 只有一个出口。
  3. 只要基本块中第一条指令被执行,那么基本块内全部指令都会顺序执行一次

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

一个函数中基本块的插桩方法以下:

  1. 统计全部 BB 的后继数 n,建立和后继数大小相同的数组 ctr[n]。
  2. 之后继数编号为序号将执行次数依次记录在 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!");
    }
}

复制代码

例1 猜数字游戏

这段代码若是开启了覆盖率检测,会生成一个长度为 6 的 64 位数组,对照插桩位置,方括号中标记了桩点序号,图 1 中代码前数字为所在行数。

图 1 桩点位置

.gcno计数符号和文件位置关联

.gcno 是用来保存计数插桩位置和源文件之间关系的文件。GCOVPass 在经过两层循环插入计数指令的同时,会将文件及 BB 的信息写入 .gcno 文件。写入步骤以下:

  1. 建立 .gcno 文件,写入 Magic number(oncg+version)。
  2. 随着函数遍历写入文件地址、函数名和函数在源文件中的起止行数(标记文件名,函数在源文件对应行数)。
  3. 随着 BB 遍历,写入 BB 编号、BB 起止范围、BB 的后继节点编号(标记基本块跳转关系)。
  4. 写入函数中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 代码示例

  1. __llvm_gcov_flush先调用了__llvm_gcov_writeout,来向 .gcda 写入覆盖率信息。
  2. 最后将计数数组清零__llvm_gcov_ctr.xx

__llvm_gcov_writeout逻辑为:

  1. 生成对应源文件的 .gcda 文件,写入 Magic number。

  2. 循环执行 llvm_gcda_emit_function: 向 .gcda 文件写入函数信息。

    llvm_gcda_emit_arcs: 向 .gcda 文件写入BB执行信息,若是已经存在 .gcda 文件,会和以前的执行次数进行合并

  3. 调用llvm_gcda_summary_info,写入校验信息。

  4. 调用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 团队基于以上原理作的增量代码测试覆盖率工具。

增量代码覆盖率检测原理

方案权衡

因为 gcov 工具(和前面的 .gcov 文件区分,gcov 是覆盖率报告生成工具)生成的覆盖率检测报告可读性不佳,如图 9 所示。咱们作的增量代码测试覆盖率工具是基于 lcov 的扩展,报告展现如上节末尾图 8 所示。

图9 gcov 输出,行前数字表明执行次数,#### 表明没执行

比 gcov 直接生成报告多了一步,lcov 的处理流程是将 .gcno 和 .gcda 文件解析成一个以 .info 结尾的中间文件(这个文件已经包含所有覆盖率信息了),以后经过覆盖率报告生成工具生成可读性比较好的 HTML 报告。

结合前两章内容和覆盖率报告生成步骤,覆盖率生成流程以下图所示。考虑到增量代码覆盖率检测中代码增量部分须要经过 Git 获取,比较天然的想法是用 git diff 的信息去过滤覆盖率的内容。根据过滤点的不一样,存在如下两套方案:

  1. 经过 GCOVPass 过滤,只对修改的代码进行插桩,每次修改后需从新插桩。
  2. 经过 .info 过滤,一次性为全部代码插桩,获取所有覆盖率信息,过滤覆盖率信息。

图10 覆盖率生成流程

分析这两个方案,第一个方案须要自定义 LLVM 的 Pass,进而会引入如下两个问题:

  • 只能使用开源 Clang 进行编译,不利于接入正常的开发流程。
  • 每次从新插桩会丢失以前的覆盖率信息,屡次运行只能获得最后一次的结果。

而第二个方案相对更加轻量,只须要过滤中间格式文件,不只能够解决咱们在文章开头提到的问题,也能够避免上述问题:

  • 能够很方便地加入到日常代码的开发流程中,甚至对开发者透明。
  • 未修改文件的覆盖率能够叠加(有修改的那些控制流程图结构可能变化,没法叠加)。

所以咱们实际开发选定的过滤点是在 .info 。在选定了方案 2 以后,咱们对中间文件 .info 进行了一系列调研,肯定了文件基本格式(函数/代码行覆盖率对应的文件的表示),这里再也不赘述,具体能够参考 .info 生成文档

增量代码测试覆盖率工具的实现

前一节是实现增量代码覆盖率检测的基本方案选择,为了更好地接入现有开发流程,咱们作了如下几方面的优化。

下降使用成本

在接入方面,接入增量代码测试覆盖率工具只需一次接入配置,同步到代码仓库后,团队中成员无需配置便可使用,下降了接入成本。

在使用方面,考虑到插桩在编译时进行,对所有代码进行插桩会很大程度下降编译速度,咱们经过解析 Podfile(iOS 开发中较为经常使用的包管理工具 CocoaPods 的依赖描述文件),只对 Podfile 中使用本地代码的仓库进行插桩(可配置指定仓库),下降了团队的开发成本。

对开发者透明

接入增量代码测试覆盖率工具后,开发者无需特殊操做,也不须要对工程作任何其余修改,正常的 git commit 代码,git push 到远端就会自动生成并上传此次 commit 的覆盖率信息了。

为了作到这一点,咱们在接入 Pod 的过程当中,自动部署了 Git 的 pre-push 脚本。熟悉 Git 的同窗知道,Git 的 hooks 是开发者的本地脚本,不会被归入版本控制,如何经过一次配置就让这个仓库的全部使用成员都能开启,是作好这件事的一个难点。

咱们考虑到 Pod 自己会被归入版本控制,所以利用了 CocoaPods 的一个属性 script_phase,增长了 Pod 编译后脚本,来帮助咱们把 pre-push 插入到本地仓库。利用 script_phase 插入还带来了另一个好处,咱们能够直接获取到工程的缓存文件,也避免了 .gcno / .gcda 文件获取的不肯定性。整个流程以下:

图11 pre-push 分发流程

覆盖率累计

在实现了覆盖率的过滤后,咱们在实际开发中遇到了另一个问题:修改分支/循环结构后生成的 .gcda 文件没法和以前的合并。 在这种状况下,__gcov_flush会直接返回,再也不写入 .gcda 文件了致使覆盖率检测失败,这也是市面上已有工具的通用问题

而这个问题在开发过程当中很常见,好比咱们给例 1 中的游戏增长一些提示,当输入比预设数字大时,咱们就提示出来,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSInteger targetNumber = 10;
    NSLog(@"Welcome to the game");
    if (guessNumber == targetNumber) {
        NSLog(@"Bingo!");
    } else if (guessNumber > targetNumber) {
        NSLog(@"Input number is larger than the given target!");
    } else {
        NSLog(@"Input number is smaller than the given target!");
    }
}
复制代码

这个问题困扰了咱们好久,也推进了对覆盖率检测原理的调研。结合前面覆盖率检测的原理能够知道,不能合并的缘由是生成的控制流程图比原来多了两条边( .gcno 和旧的 .gcda 也不能匹配了),反映在 .gcda 上就是数组多了两个数据。考虑到代码变更后,原有的覆盖率信息已经没有意义了,当发生边数不一致的时候,咱们会删除掉旧的 .gcda 文件,只保留最新 .gcda 文件(有变更状况下 .gcno 会从新生成)。以下图所示:

图12 覆盖率冲突解决算法

总体流程图

结合上述流程,咱们的增量代码测试覆盖率工具的总体流程如图 13 所示。

开发者只需进行接入配置,再次运行时,工程中那些做为本地仓库进行开发的代码库会被自动插桩,并在 .git 目录插入 hooks 信息;当开发者使用模拟器进行需求自测时,插桩统计结果会被自动分发出去;在代码被推到远端前,会根据插桩统计结果,生成仅包含本次代码修改的详细增量代码测试覆盖率报告,以及向远端推送覆盖率信息;同时若是测试覆盖率小于 80% 会强制拒绝提交(可配置关闭,百分比可自定义),保证只有通过充分自测的代码才能提交到远端。

图13 增量代码测试覆盖率生成流程图

总结

以上是咱们在代码开发质量方面作的一些积累和探索。经过对覆盖率生成、解析逻辑的探究,咱们揭开了覆盖率检测的神秘面纱。开发阶段的增量代码覆盖率检测,能够帮助开发者聚焦变更代码的逻辑缺陷,从而更好地避免线上问题。

做者介绍

丁京,iOS 高级开发工程师。2015 年 2 月校招加入美团到店餐饮事业群,目前负责大众点评 App 美食频道的开发维护。

王颖,iOS 开发工程师。2017 年 3 月校招加入美团到店餐饮事业群,目前参与大众点评 App 美食频道的开发维护。

招聘信息

到店餐饮技术部交易与信息技术中心,负责点评美食用户端业务,服务于数以亿计用户,经过更好的榜单、真实的评价和完善的信息为用户提供更好的决策支持,致力于提高用户体验;同时承载全部餐饮商户端线上流量,为餐饮商户提供多种营销工具,提高餐饮商户营销效率,最终达到让用户“Eat Better、Live Better”的美好愿景!咱们的团队包含且不限于 Android、iOS、FE、Java、PHP 等技术方向,已完备覆盖先后端技术栈。只要你来,就能点亮全栈开发技能树。诚挚欢迎投递简历至 wangkang@meituan.com

参考资料

相关文章
相关标签/搜索