iOS Code Coverage -- 原理和技术 | 来自缤纷多彩的灰

iOS Code Coverage -- 原理和技术 @ WHlcj | 2024-07-11T00:16:47+08:00 | 9 分钟阅读

本文主要介绍代码覆盖率,以及其在 iOS 中的实现的理论基础

什么是代码覆盖率

代码覆盖率也叫做测试覆盖率,是软件测试中的一种度量,描述程序中源代码被测试的比例和程度。代码覆盖率高的程序在测试过程中执行了更多的源代码,相对于代码覆盖率较低的程序,未检测到的缺陷风险更低。

为什么要加入代码覆盖率?

没有代码覆盖率前,在开发中会出现以下 case :

  1. 自测质量无法保障
  2. 对于历史代码不敢改动和剔除,不能有效控制包的大小

没有代码覆盖率前,在测试中会出现以下 case :

  1. 测试范围不足,漏测
  2. 测试回归范围大,成本大,需要精细化核心测试 case
  3. 版本上线质量不统一,测试质量主要靠 QA 通过自己的经验来评估,没有客观标准

正常情况下,可以通过写单测来保证新增代码的覆盖率,但在实际开发中,由于单测部署成本高、项目排期比较紧张、需求变化频繁、团队成员能力不足等多种原因,单测在互联网行业普及程度并不理想。

作用和意义

  • 提供度量指标:
    • 统一衡量自测/测试质量标准
    • 测试范围/进度可视化
  • 帮助代码优化:
    • 分析未覆盖部分的代码,帮助 RD 反向思考程序自身或者测试用例的缺陷
    • 检测出程序中的无用代码,便于控制包大小,提升代码质量

代码覆盖率只是一个工具,并不能保证代码本身100%没有缺陷

基本的覆盖率准则

以下列出一些基本的覆盖率准则:

  • 函数覆盖率(Function coverage):程序中的每个函数(或副程序)都被调用了吗?
  • 语句覆盖率(Statement coverage):程序中的每条语句都被执行了吗?
  • 边覆盖率(Edge coverage):若用控制流图表示程序,控制流图中的每个边都被执行了吗?
  • 分支覆盖率(Branch coverage):每个控制结构中(例如 if 和 case 语句)的每个分支(也称为决策到决策路径)是否均被执行?例如,给定一个 if 语句,其 true 和 false 分支是否均被执行?(此为边覆盖率的子集)
  • 条件覆盖率(Condition coverage):也称为谓词覆盖率(predicate coverage),每一个布尔子表达式是否均被取过真值和假值?

覆盖率检测方式

Runtime Profiling

指在程序运行时监控和收集程序行为和性能数据。通常使用虚拟机提供的接口来进行监控和数据收集。

Instrumentation

指在程序的字节码或源代码中插入额外的代码,以便在程序运行时收集数据或改变程序的行为。这些插入的代码通常称为"探针(探测指针)"。

Runtime Profiling VS Instrumentation

  1. 精准性和细粒度控制
  • Instrumentation:允许在类加载时修改字节码,插入特定代码来跟踪代码的执行情况。这种方法可以精确控制哪些方法或代码块需要插入探针,从而精确地监控代码覆盖率。
  • Runtime Profiling:通常涉及全局监控,可能导致大量不必要的数据收集,并且难以达到 Instrumentation 的精确性。
  1. 性能开销
  • Instrumentation:因为可以精确控制探针的插入位置,只在需要监控的地方插入探针,因此对应用程序性能的影响较小。现代 Instrumentation 工具通常优化了插入代码的效率,使得运行时开销更低。
  • Runtime Profiling:需要持续监控运行时事件,可能导致显著的性能开销,特别是在处理高频率事件时(如方法调用、线程切换等)。
  1. 灵活性
  • Instrumentation:提供了对字节码的动态修改能力,可以在类加载时甚至运行时修改代码。这种灵活性允许开发者根据需要调整探针的位置和行为,适应不同的分析需求。
  • Runtime Profiling:通常限制于预定义的一组事件和监控点,缺乏对特定代码块的灵活控制。
  1. 可维护性
  • Instrumentation:工具和框架如 Java 的 java.lang.instrument 包、ASM 库和其他字节码操作库,使得开发和维护 Instrumentation 工具相对简单和高效。开发者可以使用这些库和框架方便地插入和移除探针。
  • Runtime Profiling:由于其复杂性和对虚拟机的深层次依赖,开发和维护 Runtime Profiling 工具更为困难。尤其是 JVMPI 已经被废弃,JVMTI 虽然强大,但复杂度较高。
  1. 成熟的工具和生态系统
  • Instrumentation:已经有许多成熟的代码覆盖率工具,如 JaCoCo、Clover、Cobertura 等,广泛使用Instrumentation技术。这些工具经过多年的发展,功能完善,稳定性和性能都得到了广泛的验证。
  • Runtime Profiling:虽然强大,但由于前述原因,实际应用中的代码覆盖率工具较少采用这种方法。

结论

现代代码覆盖率工具更多使用 Instrumentation 技术而非 Runtime Profiling,主要是因为 Instrumentation 提供了更高的精确性、更低的性能开销、更大的灵活性和可维护性,以及更成熟的工具和生态系统。这些优势使得 Instrumentation 成为检测代码覆盖率的首选方法。

iOS 插桩工具 & 原理

GCOV (GCC Coverage)

GCOV 是一个 GNU 的本地覆盖测试工具伴随 GCC 发布,配合 GCC 共同实现对 C/C++ 文件的语句覆盖和分支覆盖率测试。iOS 原来是用 GCOV 收集代码覆盖率,后面 Apple 启用了 LLVM 项目,实现了代码覆盖率收集功能,LLVM 自带的覆盖率工具是 ’llvm-cov’。它与 GCOV 类似,用法完全兼容 GCOV ,而且生成的代码覆盖率统计文件的格式也兼容 GCOV,可与 Clang / Swiftc 编译器前端结合使用,生成覆盖率报告。

主流程

要开启 llvm-cov 功能,需要在源码编译参数中加入-fprofile-arcs -ftest-coverage

  • -ftest-coverage:在编译的时候产生 .gcno 文件,它包含代码结构信息和控制流图,用于静态分析
  • -fprofile-arcs:在程序运行的时候产生 .gcda 文件,它记录了基本块和边的执行计数,用于动态分析

LLVM

LLVM 是一套编译器基础设施项目, LLVM 最早是 Low Level Virtual Machine 的缩写,但随着项目的繁荣壮大,这个名称已经不能描述 LLVM 的作用了;因此 LLVM 现在只是一个代号,没有所谓的全称。

LLVM 和传统编译器最大的不同点在于,前端输入的任何语言,在经过编译器前端处理后,生成的中间码都是 IR 格式的:前端接收源代码,并产生通用的 LLVM IR 代码。优化器接收 IR 代码,产生优化后的 IR 代码。后端接收 IR 代码,并产生平台相关的机器码。LLVM 的目标是为编译器构建提供灵活性、可扩展性和高效性。

LLVM 的核心是一个高度模块化的编译器和工具链基础设施库。它为编译器开发提供了一组可重用的组件和 APIs,用于构建前端、优化 IR 和生成后端代码。下面列出了几个重要的命令行工具,光看名字就可以知道它们大概在做什么:

  • llvm-as:把 LLVM IR 从人类能看懂的文本格式汇编成二进制格式。注意:此处得到的不是目标平台的机器码。
  • llvm-dis:llvm-as的逆过程,即反汇编。 不过这里的反汇编的对象是 LLVM IR 的二进制格式,而不是机器码。
  • opt:优化 LLVM IR。输出新的 LLVM IR。
  • llc:把 LLVM IR 编译成汇编码。需要用as进一步得到机器码。
  • lli:解释执行 LLVM IR。

IR

Intermediate Representation 是编译器设计中一个关键概念,用于在源代码和目标代码之间建立一个抽象层,简化和统一编译过程中的分析和优化步骤。通过 IR,编译器可以更有效地进行代码优化和生成,提高编译效率和生成代码的质量。 IR 可以有多种表示形式,常见的包括以下几种:

  1. 抽象语法树(AST):
  • 定义:一种树状结构,表示程序的语法结构,其中每个节点代表一个语法结构的元素。
  • 特点:与源代码结构紧密对应,易于进行语法和语义分析。
  1. 控制流图(CFG):
  • 定义:一种图结构,表示程序的控制流,其中节点表示基本块,边表示控制流路径。
  • 特点:便于进行控制流分析和优化,如循环检测和分支预测。
  1. 三地址码(Three-Address Code):
  • 定义:一种线性代码表示形式,每条指令包含最多三个操作数(地址)。
  • 特点:简化表达式计算,便于进行数据流分析和优化。
  1. 静态单赋值形式(SSA,Static Single Assignment Form):
  • 定义:一种变体的 IR 形式,每个变量在定义后只赋值一次,通过引入φ(phi)函数处理变量的多次赋值。
  • 特点:简化数据流分析,便于进行依赖分析和优化。
  1. 字节码(Bytecode):
  • 定义:一种与虚拟机指令集对应的中间表示,如 Java 字节码。
  • 特点:独立于具体机器,便于跨平台执行。

LLVM Pass

官方文档原文:The LLVM Pass Framework is an important part of the LLVM system, because LLVM passes are where most of the interesting parts of the compiler exist. Passes perform the transformations and optimizations that make up the compiler, they build the analysis results that are used by these transformations, and they are, above all, a structuring technique for compiler code.

Framework 是 Mac OS / iOS 平台特有的文件格式。 Framework 实际上是一种打包方式,将库的二进制文件、头文件和有关的资源打包到一起,方便管理和分发。

LLVM Pass 是对 LLVM IR 进行特定转换或分析的单元。它们可以优化代码、分析代码特性、或者在编译过程中的不同阶段收集信息。它包含 5 个类型:

  • Function Pass:对每个函数进行处理。
  • Loop Pass:对每个循环进行处理。
  • Basic Block Pass:对每个基本块进行处理。
  • Module Pass:对整个模块进行处理。
  • Call Graph Pass:对调用图进行处理。 LLVM Pass 可以遍历一遍 LLVM IR 同时对它做一些操作。 在实现上,我们可以按照需求自定义 Pass 去继承和实现上面这些 Pass 类。

最后 LLVM 的编译器前端会把它翻译得到的 IR 传入 Pass 里,进行遍历和修改。显然它的一个用处就是插桩:在 Pass 遍历 LLVM IR 的同时,自然就可以往里面插入新的代码。LLVM 的覆盖率映射关系生成源码中提供了一个 Pass 用于生成 .gcon 文件(下文简称 GCOVPass

Basic Block

基本块(Basic Block)是代码执行的基本单元,它是满足下列条件的代码序列:

  • 单入口:只有一个入口
  • 单出口:只有一个出口
  • 顺序执行:在执行过程中,中间没有分支跳出指令

下面演示了把一段代码拆解为 BB 块:

Control Flow Graphs

当将一个中间代码程序划分成为基本块之后,我们用一个流图来表示它们之间的控制流。流图 (flow graph) 的结点就是这些基本块。流图就是通常的图,它可以用任何适合表示图的数据结构来表示。通常我们还会增加两个分部称为入口 (entry) 和出口 (exit) 的结点。它们不和任何可执行的 IR 对应。

Flow Edges

从基本块 A 到基本块 B 之间有一条边当且仅当基本块 B 的第一个指令紧跟在 A 的最后一个指令之后执行。存在这样一条边的原因有两种:

  • 有一个从 A 的结尾跳转到 B 的开头的条件或无条件跳转语句。
  • 按照原来的三地址语句序列中的顺序,B 紧跟在 A 之后,且 A 的结尾不存在无条件跳转语句。

我们说 A 是 B 的前驱 (predecessor), 而 B 是 A 的一个后继 (successor)。从入口到流图的第一个可执行结点有一条边(edges)。从任何包含了可能是程序的最后执行指令的基本块到出口有一条边。如果程序的最后指令不是一个无条件转移指令,那么包含了程序的最后一条指令的基本块是出口结点的一个前驱。但任何包含了跳转到程序之外的跳转指令的基本块也是出口结点的前驱。

插桩策略

覆盖率计数指令的插入会进行两次循环:

  • 外层循环:遍历编译单元(如一个源文件)中的函数,收集和记录函数的元数据信息,并准备在函数内插入覆盖率计数指令。
  • 内层循环:遍历当前函数中的每个基本块,插入覆盖率计数指令,并记录基本块的位置信息。 一个函数中基本块的插桩方法如下:
  • 统计所有 BB 的后继数 n,创建和后继数大小相同的数组 ctr[n]。
  • 以后继数编号为序号将执行次数依次记录在 ctr[i] 位置,对于多后继情况根据条件判断插入。

根据生成流图的规则,可以很容易得到探针位置,ctr[i] 中的 i 就是插入的探针序号。

生成代码覆盖率报告

最后通过编译和运行生成 .gcno 和 .gcda 文件以及源代码文件就可以生成覆盖率报告了,生成 .gcno 和 .gcda 文件的过程和原理可以直接参考iOS 覆盖率检测原理与增量代码测试覆盖率工具实现,这里就不多赘述。

终~