覆盖率是衡量自动化效果的一个重要指标。而在我们实现自动化测试覆盖率的时候遇到了一些问题,接下来简单讲一下怎么解决的。
一.覆盖率数据的收集
await page.coverage.startJSCoverage()
let jsCoverage = await page.coverage.stopJSCoverage()
puppeteer的覆盖率数据收集两个方法,一个是在页面open时候开始统计覆盖率,另一个是页面关闭之前拿到这个覆盖率数据。coverage数据格式如下:
url是页面加载的js文件,text是js文件内容,ranges是覆盖率行信息。是一个带起始位置的数组。如下:
这里是一个页面的覆盖率数据。而我们的自动化目前有近100个用例,那就产生一百个覆盖率数据。然后又分别对应n个js文件的数据。所以自然而然的想到我们需要将这些数据按js文件合并。这样才能整合每个用例的覆盖率数据来生成汇总信息。
所以刚开始我以为在jest的global里面可以存储全局数据。比如PuppeteerEnvironment,或者一个全局单例来存储数据,但是通通不行。得出的结论是每个测试用例的运行环境是完全独立的。包括PuppeteerEnvironment,包括全局单例。所以jest是真正的多进程并发运行用例的。
内存缓存不行,那就只能使用文件缓存的。所以我们的实现方式是stopJSCoverage后,将coverage信息存储到文件。存储文件有个小技巧。因为我们是要按js文件来统计覆盖率的。所以我们还是要按js文件来存储覆盖率。首先把js文件url简单hash,作为文件目录名称。这里我们要存储url,text,range、所以分别新建对应文件和目录。
range里面存储每个页面的覆盖信息。这里之前遇到个小问题,在命名文件名的时候,刚开始用url hash
+ 时间戳来命名。后面发现jest的多进程下面同一个时间戳很有可能存在多个用例的写入,造成某个用例执行了却丢失了覆盖率。所以加上了随机数。range文件命名格式为newDate().getTime() + Math.floor(Math.random() * 10000)。
二.覆盖率数据的合并
保存覆盖率数据后,等待所有自动化用例执行结束,我们就需要来统计覆盖率数据了。最开始我以为range信息既然是数组。那我们简单的把多个range文件数组合并。生成一个大的range信息,传递给PuppeteerToIstanbul就可以了。这个实现是没什么问题。但是当我们运行起来后,单单覆盖率的生成大约需要20分钟,并且大概率出现OOM异常,造成node直接崩溃。所以我们的解决办法是range信息必须合并。干掉冗余信息。
1.)去重
在上面的截图中可以看到range信息是有序的增序排序的数组,而每个数组内容是start,end。所以我们可以定制一个二分查找。
/**
* 有序的二分查找,返回-1或存在的数组下标。不使用递归实现。
* @param target 要查找的数据
* @param ranges_array 已经解析的range数组
* @returns {*}
*/
function binarySearch(target, ranges_array) {
let start = 0;
let end = ranges_array.length - 1;
while (start <= end) {
let mid = parseInt(start + (end - start) / 2);
const array_item = ranges_array[mid];
if (target.start === array_item.start && target.end === array_item.end) {
return mid
} else if (target.start > array_item.end) {
start = mid + 1
} else if (target.end < array_item.start) {
end = mid - 1
} else {
if (target.start <= array_item.end && target.end > array_item.end) {
array_item.end = target.end;
return mid
} else if (target.end >= array_item.start && target.start < array_item.start) {
array_item.start = target.start;
return mid
}
break
}
}
return -1
}
let url, ranges = [], text;
url = fs.readFileSync(temp_path + '/' + filename + '/url', 'utf8');
text = fs.readFileSync(temp_path + '/' + filename + '/text', 'utf8');
let sourceMapUrl = url + '.map';
const temp_range = JSON.parse(fs.readFileSync(temp_path + '/' + filename + '/range/' + sub_file, 'utf8'));
temp_range.forEach(range_item => {
const index = binarySearch(range_item, ranges);
if (index === -1) {
ranges.push(range_item)
}
})
经过这一步之后,我们已经去重了绝大部分的range信息。但是问题又来了,可能有重叠的数据我们没有优化,并且上面的解析的push之后的数据已经不是有序的了。所以需要第二步的合并。这里为什么不一次合并重叠数据是因为,合并了一次之后,原始数据就已经不是有序的了。那就没办法使用快速二分查找,这样的效率相比于我的方法会大大降低。(重复覆盖率大概有80%左右,经过这一轮去重覆盖率数据大大降低,第一步首先去重是必要的)。
2).合并重叠区域和重新排序
经过上面的ranges.push(range_item)操作后,我们的覆盖率数据已经不是有序排列的了。并且覆盖率数据之间的重叠我们没有处理。
左边是包含关系,我们可以把下面的直接剔除掉。右边图是重叠关系。则可以用start1,end2来表示新的区间。
let formatRangeMap = {};
ranges.forEach(item => {
let temp = formatRangeMap[item.start];
if (temp) {//判断是否已经有对应的start位置。如果有,说明覆盖率数据是重合的。
formatRangeMap[item.start] = {start: item.start, end: Math.max(item.end, temp.end)}
} else {
formatRangeMap[item.start] = item
}
});
先定义一个按start为key的map对象。如果start位置已经有值。则有重叠。end取对应的两者最大值。
let formatRangeArray = [];
const sortKeys = Object.keys(formatRangeMap).sort((a, b) => a - b);//从小到大排序
sortKeys.forEach(key => {
const target = formatRangeMap[key];//这里因为key值是从小到大排序的。所以新的target的start值是绝对大于等于formatRangeArray[length-1]的start的。所以只用判断最后一个
if (formatRangeArray.length > 0) {
const end_array = formatRangeArray[formatRangeArray.length - 1];
if (target.start <= end_array.end) {
end_array.end = Math.max(end_array.end, target.end)
} else {
formatRangeArray.push(target)
}
} else {
formatRangeArray.push(target)
}
});
console.log('ranges=', ranges.length, formatRangeArray.length);
先将formatRangeMap的key值按从小到大排序。然后取对应的range内容,这里因为key值是从小到大排序的。所以拿到的target的start值是绝对大于等于formatRangeArray[length-1]的start的。所以只用判断最后一个。判断target的start是否小于end_array的end。如果是的话,则重新修改一下end_array的end值。这样已经解决完全包含问题。
三.解析sourcemap
webpack编译后的文件如果压缩混淆后,remap-istanbul无法解析正确的内容。所以我们在自动化测试使用webpack编译代码时候不要加代码的压缩混淆(不要用webpack-parallel-uglify-plugin)。并且需要生成对应的sourcemap。编译后生成的sourcemap文件和js文件在同一目录。sourcemap内容简单如下:
主要是sources数组字段,里面的文件名都是webpack开头的。所以我们需要把他替换成对应的src目录。
const loadSourceMap = () => {
return new Promise(resolve => {
if (options.url_regexp.test(url)) {
const module_path = RegExp.$1;
const file_path = pathLib.resolve(options.source_map_dir, module_path + '.map');
console.log('module_path=', module_path);
fs.readFile(file_path, 'utf8', function (err, data) {
if (err) {
resolve(null)
} else {
resolve(data)
}
})
} else {
resolve(null)
}
})
};
const sourceMap = await loadSourceMap();
const sourceMapObject = JSON.parse(sourceMap);
if (sourceMapObject) {
sourceMapObject.sources = sourceMapObject.sources.map(url => {
console.log('src_dir=', url.replace('webpack:///./src', options.src_dir));
return url.replace('webpack:///./src', options.src_dir)
})
}
options.url_regexp是需要加载js的sourcemap的一个正则表达式。因为有些无关js文件我们并不想生成覆盖率数据。
options.src_dir是我们代码目录。这样替换成一个可直接打开的源码目录。
allCoverage.push({url, ranges: formatRangeArray, text, sourceMapUrl, sourceMap: JSON.stringify(sourceMapObject)})
最终生成的中间层数据格式为url,ranges,text,sourcemapurl,sourcemap;
四.转化生成nyc能识别的数据
转化istanbul参考代码https://github.com/istanbuljs/puppeteer-to-istanbul。它的问题是在于生成的覆盖率是编译之后的js文件的覆盖率。而不是我们源代码的覆盖率。这种覆盖率对我们来说只是个数字。而对我们如何提高覆盖率如何看源代码是没有帮助的。
所以我们要结合remap-istanbul来使用。这个是将Istanbul代码覆盖率信息通过sourcemap重新映射到其原代码位置。(一般我们不传递sourcemap地址给remap-istanbul,是因为它默认加载js文件同级目录的.map文件)
核心代码如下:
writeIstanbulFormat() {
var fullJson = {};
this.puppeteerToV8Info.forEach(jsFile => {
const script = v8toIstanbul(jsFile.url);
script.applyCoverage(jsFile.functions);
let istanbulCoverage = script.toIstanbul();
let keys = Object.keys(istanbulCoverage);
fullJson[keys[0]] = istanbulCoverage[keys[0]]
});
mkdirp.sync(this.options.nyc_output_path);
const path = pathLib.resolve(this.options.nyc_output_path, 'out.json')
console.log('path=', path);
fs.writeFileSync(path, JSON.stringify(fullJson), 'utf8');
remapIstanbul(path, {
'json': path,
});
const text = JSON.parse(fs.readFileSync(path, 'utf8'));
Object.keys(text).map(key => {
const coverage_item = text[key];
const path = coverage_item['path'];
if (fs.existsSync(path) && (!this.options.filterCoverageFile || !this.options.filterCoverageFile(path))) {
} else {
delete text[key]
}
});
fs.writeFileSync(path, JSON.stringify(text), 'utf8');
}
第一部分是将覆盖率文件通过v8-to-istanbul将v8 coverage转化成istanbul的json格式文件存储到nyc_output_path
第二部分是通过remapIstanbul转化成源代码的覆盖率信息。
第三部分是过滤一下部分文件的覆盖率。因为有些js文件或者css文件,可以剔除其覆盖率。生成的最终out.json就是nyc能识别的覆盖率数据。
五.生成覆盖率
上面最终生成的/.nyc_output/out.json,这个文件就是Istanbul需要的覆盖率源文件。
这个时候我们调用脚本就可以生成html形式的html。
nyc report --reporter=html
html内容如图:
查看具体文件覆盖信息:
生成简单缩略信息。
nyc report --reporter=text-summary