Arclin

Advocate Technology. Enjoy Technology.

0%

iOS测试覆盖率方案

本文讲述iOS测试覆盖率方案

编译参数注入

LLVM本身自带覆盖率插桩工具,只要编译参数中添加指定参数,即可在编译期间插桩,得到代码执行情况。
具体操作

Other C Flags添加参数-sanitize-coverage=func,trace-pc-guard

Other Swift Flags添加参数-sanitize-coverage=func-sanitize=undefined

找个地方实现插桩方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
void *PC = __builtin_return_address(0);
Dl_info info;
dladdr(PC, &info);
printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n\n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
}

然后我们随便写个Demo, 比如写个tableView, 然后再didSelect回调里面打断点,进入汇编页面,看看插桩是否成功

可以看出,插桩成功,程序在调用didSelect之前,会先调用__sanitizer_cov_trace_pc_guard
简单分析汇编代码插入函数位置,可以得到每个“代码块”都有对应的插桩函数

再对Swift插桩进行验证,同样发现了插桩代码__sanitizer_cov_trace_pc_guard

由此可以得出结论,OC与Swift都可以通过插桩方式拿到回调

获取测试覆盖率数据

1.先声明几个LLVM关键函数

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <Foundation/Foundation.h>
#ifndef PROFILE_INSTRPROFILING_H_
#define PROFILE_INSTRPROFILING_H_

// https://clang.llvm.org/docs/SourceBasedCodeCoverage.html
int __llvm_profile_runtime = 0;
void __llvm_profile_initialize_file(void);
const char *__llvm_profile_get_filename(void);
void __llvm_profile_set_filename(const char *);
int __llvm_profile_write_file(void);
int __llvm_profile_register_write_file_atexit(void);
const char *__llvm_profile_get_path_prefix(void);
#endif /* PROFILE_INSTRPROFILING_H_ */

2.指定覆盖率数据文件的输出路径,建议在程序启动时调用

1
2
3
4
5
6
7
8
9
let name = "\(moduleName).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)
} catch {
print(error)
}

3.在合适的时机调用__llvm_profile_write_file()写入覆盖率数据,如切后台,退出app时等。

1
2
3
- (void)sceneDidEnterBackground:(UIScene *)scene {
__llvm_profile_write_file();
}

生成测试覆盖率报告

测试app:

启动app, 进行操作,然后进入后台。

在控制台可以看到生成的数据路径:

/Users/arclin/Library/Developer/CoreSimulator/Devices/EEFDE0CE-9572-4D0D-B5F8-5ED4D6F6362C/data/Containers/Data/Application/F9FB0C18-AEE6-404A-B97A-5C6FEEE3B15C/Documents/Demo.profraw

收集Demo.profraw、app产物、转换脚本,放在文件夹里,结构如下

1
2
3
4
5
6
7
8
9
10
$ tree -L 2
.
├── MachOFiles
│ └── Demo.app
├── gitdiff
│ └── utils
│ └── diffParser.rb
│ └── trollop.rb
├── Demo.profraw
└── coverage_report.sh

转换脚本代码coverage_report.sh内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/bin/bash

cleanup() {
echo "清理中间生成的临时文件..."
[ -n "$profdata_path" ] && rm -f "$profdata_path"
[ -n "$binname" ] && rm -f "${binname}.info"
rm -f gitdiff.diff
echo "清理完成。"
}

trap cleanup EXIT

if [ $# -lt 1 ]; then
echo "用法: $0 <profraw文件> [oldCommit]"
exit 1
fi

profraw_path=$1
profbase=${profraw_path%.profraw} # 去掉 .profraw 后缀
profdata_path=${profbase}.profdata

echo "profraw_path: $profraw_path"
echo "profbase: $profbase"
echo "profdata_path: $profdata_path"

if [ $# -ge 2 ]; then
oldCommit=$2
else
oldCommit=$(git rev-parse --short=7 HEAD~1)
fi

# 1. 生成 git diff
currentCommit=$(git rev-parse --short=7 HEAD)
git diff $oldCommit $currentCommit --unified=0 > gitdiff.diff

# 2. 合并 profraw
xcrun llvm-profdata merge -sparse "$profraw_path" -o "$profdata_path"

# 3. 找到第一个 Mach-O 文件
macho_bin=$(find ./MachOFiles -type f -exec file {} + | grep 'Mach-O' | head -n1 | cut -d: -f1)

if [ -z "$macho_bin" ]; then
echo "未找到 Mach-O 二进制文件"
exit 2
fi

binname=$(basename "$macho_bin")

echo "macho_bin: $macho_bin"

# 4. 导出 lcov
xcrun llvm-cov export "$macho_bin" -instr-profile="$profdata_path" -format=lcov > ${binname}.info


# 5. 解析增量覆盖率
ruby gitdiff/utils/diffParser.rb --diff-file=gitdiff.diff --coverage-info-file=${binname}.info

# 6. 生成 html 报告
genhtml -o ${binname}_html ./${binname}_gather.info --ignore-errors category

# 7. 打开报告
open ${binname}_html/index.html

使用方式

1
sh coverage_report.sh Demo.profraw acf2d5a

其中acf2d5a是你要比较的上一次commit id

以上这段脚本,会比较当前commit id与 指定的旧commitID的diff, 保存成一个gitdiff.diff文件

然后把测试覆盖率源文件进行两次转换转成可读的info文件,info文件里面会包含所有代码的测试覆盖情况。

然后把info文件和gitdiff文件通过ruby脚本进行匹配,筛选出diff代码的测试覆盖情况,然后生成新的info文件。

匹配时需要了解info文件里面的符号所代表的含义

标识符及描述 备注
SF: <absolute path to the source file> 文件的绝对路径
FN: <line number of function start>,<function name> 方法的开始行数
FNDA: <execution count>,<function name> 该方法执行的次数
FNF: <number of functions found> 该方法被发现的次数
FNH: <number of function hit> 该方法被命中次数(疑惑)
DA: <line number>,<execution count>[,<checksum>] 代码行数,该行代码执行的次数
LH: <number of lines with a non-zero execution count> 可执行的代码总行数,行代码覆盖率的分母
LF: <number of instrumented lines> 执行到的代码总行数,行代码覆盖率的分子

核心ruby代码内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
require 'json'
require_relative 'trollop'

module GitUtil
def white_adpator(line, white_list)
# 注意:使用该代码获取文件名,会在后面多一个换行符,这里需要剔除换行符
extn = File.extname(line).delete!("\n")
white_list.map { |item|
if item == extn
return true
end
}
return false
end

def code_diff_map(diff_file, white_list)
file_map = {}
current_file = nil
# 是否是白名单格式的文件
whiteFile = false

File.open(diff_file).each do |line|
# 新的文件改动标识
if line.start_with? 'diff --git'
# 判断是白名单格式的文件
if white_adpator(line, white_list)
whiteFile = true
next
else
whiteFile = false
next
end
end

if whiteFile == false
next
end

if line.start_with? '+++'
# 提取文件路径
file_path = line[/\/.*/, 0][1..-1]
if file_path
current_file = file_path
file_map[current_file] = []
end
end

if line.start_with? '@@'
# 提取新增代码行,格式为 " +66,5"
change = line[/\+.*?\s{1}/, 0]
# 消除"+"
change = change[1..-1]
# flat
if change.include? ','
base_line = change.split(',')[0].to_i
delta = change.split(',')[1].to_i
delta.times { |i| file_map[current_file].push(base_line + i) if current_file}
else
file_map[current_file].push(change.to_i) if current_file
end
end
end

diff_file_path = File.dirname(diff_file)
diff_file_name = File.basename(diff_file, ".*")
diff_json_file_path = "#{diff_file_path}/#{diff_file_name}.json"
diff_json_file = File.new(diff_json_file_path, "w+")
diff_json_file.syswrite(file_map.to_json)

return file_map
end

def gatherCoverageInfo(coverage_info_file, file_map)
gather_file = false
gather_file_lines = []
coverage_info_path = File.dirname(coverage_info_file)
coverage_file_name = File.basename(coverage_info_file, ".*")
coverage_gather_file_path = "#{coverage_info_path}/#{coverage_file_name}_gather.info"
puts "======"
puts "coverage_info_path: #{coverage_info_path}"
puts "coverage_info_file: #{coverage_info_file}"
puts "coverage_gather_file_path: #{coverage_gather_file_path}"
# puts file_map
puts "======"
coverage_gather_file = File.new(coverage_gather_file_path, "w+")
File.open(coverage_info_file).each do |line|
# 代码覆盖率info的文件开头标识
if line.start_with? 'SF:'
# 获取文件名称,包含后缀
gather_file = false
basen = File.basename(line).delete!("\n")
# puts "basen:#{basen}"
file_map.each_key { |key|
if key.to_s.include?("/")
last_key = key.split("/")[1]
end
if last_key == basen
gather_file = true
gather_file_lines = file_map[key]
coverage_gather_file.syswrite(line)
# puts "gather_file:#{gather_file}"
# puts "gather_file_lines:#{gather_file_lines}"
next
end
}
end

if gather_file == false
next
end

# 该类中的每一行信息的标识
# DA:20,1
if line.start_with? 'DA:'
line_number = line.split("DA:")[1]
real_line = line_number.split(",")[0].to_i
# puts "gather_file_lines:#{gather_file_lines}"
# puts "real_line: #{real_line}"
if gather_file_lines.include?(real_line)
# puts "gather_line: #{line}"
coverage_gather_file.syswrite(line)
end
else
coverage_gather_file.syswrite(line)
end
end

return coverage_gather_file
end

end

if __FILE__ == $0
include GitUtil

opts = Trollop::options do
opt :diff_file, 'Path for diff file', :type => :string
opt :coverage_info_file, 'Path for covage info file', :type => :string
end

Trollop::die :diff_file, 'must be provided' if opts[:diff_file].nil?
Trollop::die :coverage_info_file, 'must be provided' if opts[:coverage_info_file].nil?

white_list = ['.m', '.mm', '.swift']
# 通过git diff获取简洁可用的增量信息 file_map
file_map = GitUtil.code_diff_map(opts[:diff_file], white_list)
# 结合file_map 和覆盖率文件,得到增量覆盖率文件
coverage_gather_file = GitUtil.gatherCoverageInfo(opts[:coverage_info_file], file_map)
end

trollop.rb代码参考:trollop.rb

转换结果

oc和swift都可以检查覆盖率

参考

https://juejin.cn/post/7049973143007395877

https://github.com/JerryChu/UnitTestParser/tree/master