Lua杂谈系列,就以代码覆盖率测试的luacov开头吧

简介

说到lua的覆盖率测试,我们一般都会想到用luacov做代码覆盖率测试
在干货|使用luacov统计lua代码覆盖率一文中,介绍了基本的luacov用法,但是缺少对luacov深入挖掘的相关内容。并且同时,原生的luacov提供了一套简洁的覆盖率测试实现以及报告输出形式,但是在实际许多场景中,采用原生luacov还是远远满足不了需求的
因此,本文旨在通过分析luacov的实现,帮助希望了解lua代码覆盖率测试或是使用、二次开发luacov的同学尽快上手

获取代码覆盖率数据

luacov获取代码覆盖率数据,得益于lua自带的debug库。我们从luacov的主类runner中,可以一探究竟

// 初始化runner
function runner.init(configuration)
	// 读取设置
	runner.configuration = runner.load_config(configuration)
	// 重载os.exit,在原生os.exit()前把剩下数据存掉,或者输出报告之类
	os.exit = function(...)
	   on_exit()
	   raw_os_exit(...)
	end
	// 在'l'事件加debug hook
	debug.sethook(runner.debug_hook, "l")
	// 如果每个thread都有独立的hook
	if has_hook_per_thread() then
		// 重载coroutine.create,打包函数之前先在'l'事件sethook
		local rawcoroutinecreate = coroutine.create
		coroutine.create = function(...)
			local co = rawcoroutinecreate(...)
			debug.sethook(co, runner.debug_hook, "l")
			return co
		end
		// coroutine.wrap用的error handler
		local function safeassert(ok, ...)
			if ok then
			    return ...
			else
			    error(..., 0)
			end
		end
		// 重载coroutine.wrap,打包函数之前先在'l'事件sethook
		coroutine.wrap = function(...)
		    local co = rawcoroutinecreate(...)
		    debug.sethook(co, runner.debug_hook, "l")
		    return function(...)
		       return safeassert(coroutine.resume(co, ...))
		    end
		end
	end
end

lua的debug.sethook([thread], hook, mask)函数可以使得我们的lua脚本在运行过程中,遇到特定的条件(mask)时执行相应的函数(hook)。当mask为'l'时,表示lua脚本已经执行到了新的一行。因此,为了统计覆盖率,只需要在我们hook'l'事件的函数中,寻找执行的文件和行号就好了

hook函数

在luacov.runner中,定义的debug hook为:

runner.debug_hook = require(cluacov_ok and "cluacov.hook" or "luacov.hook").new(runner)

因此我们可以以luacov.hook模块为例观察具体实现:

function hook.new(runner)
	// 忽略的文件列表
	local ignored_files = {}
	// hook执行的次数count
	local steps_after_save = 0
	// hook函数参数为(事件evt, 行数line_nr, 栈层次level)
	return function(_, line_nr, level)
		// level默认值为2
		// 栈层次为1位调用hook的luacov,栈层次为2即为待测覆盖率的文件
		level = level or 2
		// 判断runner是否初始化
		if not runner.initialized then
		    return
		end
		// 获取栈层次level的source源文件信息,即文件名
		// 这个时候,我们就已经获得了想要的信息:文件名name与行数line_nr
		local name = debug.getinfo(level, "S").source
		// 判断文件名前面有没@,以及是不是loadstring读取的(不然就不是文件名)
		local prefixed_name = string.match(name, "^@(.*)")
		if prefixed_name then
		    name = prefixed_name
		elseif not runner.configuration.codefromstrings then
		    return
		end
		// 读取临时缓存runner.data里边的数据
		local data = runner.data
		local file = data[name]
		// 判断该文件的数据是否要存储
		if not file then
		    if ignored_files[name] then
		        return
		    elseif runner.file_included(name) then
		        file = {max = 0, max_hits = 0}
		        data[name] = file
		    else
		        ignored_files[name] = true
		        return
		    end
		end
		// 修正该文件最大hit到的行数
		if line_nr > file.max then
		    file.max = line_nr
		end
		// 更新该文件行的hit数
		local hits = (file[line_nr] or 0) + 1
		file[line_nr] = hits
		if hits > file.max_hits then
		    file.max_hits = hits
		end
		// 判断tick步长,决定是否存储数据
		if runner.tick then
		    steps_after_save = steps_after_save + 1
		    if steps_after_save == runner.configuration.savestepsize then
		        steps_after_save = 0
		        if not runner.paused then
		        	runner.save_stats()
		      	end
		    end
		end
	end
end

可以看到整一个hook中最有价值的部分还是local name = debug.getinfo(level, "S").source。lua原生的debug.getinfo相较于c api的性能差,因此建议实际需求使用中引入cluacov的hook模块作为hook函数

总结

lua覆盖率信息的收集,总体无非如我们在luacov所看到的:在'l'事件的hook函数中获取文件名与相应行数,然后保证每一个lua线程(协程)都能打上hook。
luacov实现总体而言也并不复杂,优化空间非常多,比如save_stats()可以修改为socket、websocket一类实时传送数据,从而避免原生luacov设置step过小时导致报告文件io频繁,造成数据丢失。当然,有了网络传输,原配的很多参数都不需要了。
再深入一点,代码文件翻译成机器码,毕竟是状态机使然。如果细心观察luacov覆盖率的结果的话,会发现有很多该hit的行会hit不到。这些种种,就留待后续发掘啦~