这个工具是根据 《iOS 覆盖率检测原理与增量代码测试覆盖率工具实现》的一次实践(侵删),本篇文章更注重实现细节,原理部分可以参考原文。
最终的效果是通过修改push脚本:
echo '----------------'
rate=$(cd $(dirname $PWD)/RCodeCoverage/ && python coverage.py $proejctName | grep "RCoverageRate:" | sed 's/RCoverageRate:\([0-9-]*\).*/\1/g')
if [ $rate -eq -1 ]; then
echo '没有覆盖率信息,跳过...'
elif [ $(echo "$rate < 80.0" | bc) = 1 ];then
echo '代码覆盖率为'$rate',不满足需求'
echo '----------------'
exit 1
else
echo '代码覆盖率为'$rate',即将上传代码'
fi
echo '----------------'
在每个commit-msg后面附带着本次commit的代码覆盖率信息:
github链接:xuezhulian/Coverage
下面从增量和覆盖率介绍这个工具的实现。
增量
增量的结果根据git得到。
git status
得到当前有几个commit需要提交。
aheadCommitRe = re.compile('Your branch is ahead of \'.*\' by ([0-9]*) commit')
aheadCommitNum = None
for line in os.popen('git status').xreadlines():
result = aheadCommitRe.findall(line)
if result:
aheadCommitNum = result[0]
break
如果当前存在未提交的commit git rev-parse
可以拿到commit的commit-id,git log
可以得到commit的diff。
if aheadCommitNum:
for i in range(0,int(aheadCommitNum)):
commitid = os.popen('git rev-parse HEAD~%s'%i).read().strip()
pushdiff.commitdiffs.append(CommitDiff(commitid))
stashName = 'git-diff-stash'
os.system('git stash save \'%s\'; git log -%s -v -U0> "%s/diff"'%(stashName,aheadCommitNum,SCRIPT_DIR))
if string.find(os.popen('git stash list').readline(),stashName) != -1:
os.system('git stash pop')
else:
#prevent change last commit msg without new commit
print 'No new commit'
exit(1)
根据diff匹配修改的类和行,我们只考虑新添加的,不考虑删除操作。
commitidRe = re.compile('commit (\w{40})')
classRe = re.compile('\+\+\+ b(.*)')
changedLineRe = re.compile('\+(\d+),*(\d*) \@\@')
commitdiff = None
classdiff = None
for line in diffFile.xreadlines():
#match commit id
commmidResult = commitidRe.findall(line)
if commmidResult:
commitid = commmidResult[0].strip()
if pushdiff.contains_commitdiff(commitid):
commitdiff = pushdiff.commitdiff(commitid)
else:
#TODO filter merge
commitdiff = None
if not commitdiff:
continue
#match class name
classResult = classRe.findall(line)
if classResult:
classname = classResult[0].strip().split('/')[-1]
classdiff = commitdiff.classdiff(classname)
if not classdiff:
continue
#match lines
lineResult = changedLineRe.findall(line)
if lineResult:
(startIndex,lines) = lineResult[0]
# add nothing
if cmp(lines,'0') == 0:
pass
#add startIndex line
elif cmp(lines,'') == 0:
classdiff.changedlines.add(int(startIndex))
#add lines from startindex
else:
for num in range(0,int(lines)):
classdiff.changedlines.add(int(startIndex) + num)
至此得到了每次push的时候,有几个commit需要提交,每个提交修改了哪些文件以及对应的行。拿到了增量的部分。
覆盖率
覆盖率信息通过lcov工具分析gcno,gcda两种格式的文件得到。这两种文件在原文中有详细的描述,这里不再赘述。
首先我们要做的是确定gcno和gcda的路径。
xcode->build phases->run script添加脚本exportenv.sh
导出环境变量。
//exportenv.sh
scripts="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
export | egrep '( BUILT_PRODUCTS_DIR)|(CURRENT_ARCH)|(OBJECT_FILE_DIR_normal)|(SRCROOT)|(OBJROOT)|(TARGET_DEVICE_IDENTIFIER)|(TARGET_DEVICE_MODEL)|(PRODUCT_BUNDLE_IDENTIFIER)' > "${scripts}/env.sh"
- SCRIPT_DIR :/Users/yuencong/Desktop/coverage/RCodeCoverage
- SRCROOT :/Users/yuencong/Desktop/coverage/Example
- OBJROOT :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex
- OBJECT_FILE_DIR_normal:/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex/Example.build/Debug-iphonesimulator/Example.build/Objects-normal
- PRODUCT_BUNDLE_ID :coverage.Example
- TARGET_DEVICE_ID :E87EED9C-5536-486A-BAB4-F9F7C6ED6287
- BUILT_PRODUCTS_DIR :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Products/Debug-iphonesimulator
- GCDA_DIR :/Users/yuencong/Library/Developer/CoreSimulator/Devices/E87EED9C-5536-486A-BAB4-F9F7C6ED6287/data/Containers/Data/Application//C4B45B67-5138-4636-8A8F-D042A06E7229/Documents/gcda_files
- GCNO_DIR :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex/Example.build/Debug-iphonesimulator/Example.build/Objects-normal/x86_64
GCNO_DIR
的路径就是OBJECT_FILE_DIR_normal+arch
,我们只在模拟器收集信息,所以这里的arch
是x86_64
。目前我们APP整体架构采用模块化,每个模块对应一个target
,通过cocoapods
管理。每个target
的normal
路径是不一样的。如果想得到的是pod
目录下的gcno
文件,我们会把本地的pod
仓库路径当做参数,然后根据podspec
文件修改normal
的路径。
def handlepoddir():
global OBJECT_FILE_DIR_normal
global SRCROOT
#default main repo
if len(sys.argv) != 2:
return
#filter coverage dir
if sys.argv[1] == SCRIPT_DIR.split('/')[-1]:
return
repodir = sys.argv[1]
SRCROOT = SCRIPT_DIR.replace(SCRIPT_DIR.split('/')[-1],repodir.strip())
os.environ['SRCROOT'] = SRCROOT
podspec = None
for podspecPath in os.popen('find %s -name \"*.podspec\" -maxdepth 1' %SRCROOT).xreadlines():
podspec = podspecPath.strip()
break
if podspec and os.path.exists(podspec):
podspecFile = open(podspec,'r')
snameRe = re.compile('s.name\s*=\s*[\"|\']([\w-]*)[\"|\']')
for line in podspecFile.xreadlines():
snameResult = snameRe.findall(line)
if snameResult:
break
sname = snameResult[0].strip()
OBJECT_FILE_DIR_normal = OBJROOT + '/Pods.build/%s/%s.build/Objects-normal'%(BUILT_PRODUCTS_DIR,sname)
if not os.path.exists(OBJECT_FILE_DIR_normal):
print 'Error:\nOBJECT_FILE_DIR_normal:%s invalid path'%OBJECT_FILE_DIR_normal
exit(1)
os.environ['OBJECT_FILE_DIR_normal'] = OBJECT_FILE_DIR_normal
gcda
文件存储在模拟器中。通过TARGET_DEVICE_ID
可以确认当前模拟器的路径。这个路径下每个APP对应的文件夹下面都存在一个plist文件记录了APP的bundleid,根据这个bundleid匹配APP。然后拼出gcda文件的路径。
def gcdadir():
GCDA_DIR = None
USER_ROOT = os.environ['HOME'].strip()
APPLICATIONS_DIR = '%s/Library/Developer/CoreSimulator/Devices/%s/data/Containers/Data/Application/' %(USER_ROOT,TARGET_DEVICE_ID)
if not os.path.exists(APPLICATIONS_DIR):
print 'Error:\nAPPLICATIONS_DIR:%s invaild file path'%APPLICATIONS_DIR
exit(1)
APPLICATION_ID_RE = re.compile('\w{8}-\w{4}-\w{4}-\w{4}-\w{12}')
for file in os.listdir(APPLICATIONS_DIR):
if not APPLICATION_ID_RE.findall(file):
continue
plistPath = APPLICATIONS_DIR + file.strip() + '/.com.apple.mobile_container_manager.metadata.plist'
if not os.path.exists(plistPath):
continue
plistFile = open(plistPath,'r')
plistContent = plistFile.read()
plistFile.close()
if string.find(plistContent,PRODUCT_BUNDLE_ID) != -1:
GCDA_DIR = APPLICATIONS_DIR + file + '/Documents/gcda_files'
break
if not GCDA_DIR:
print 'GCDA DIR invalid,please check xcode config'
exit(1)
if not os.path.exists(GCDA_DIR):
print 'GCDA_DIR:%s path invalid'%GCDA_DIR
exit(1)
os.environ['GCDA_DIR'] = GCDA_DIR
print("GCDA_DIR :"+GCDA_DIR)
确定了gcno和gcda目录路径之后。结合git分析得到的修改的文件,把这些文件对应的gcno和gcda文件拷贝到脚本目录下的source文件夹下。
sourcespath = SCRIPT_DIR + '/sources'
if os.path.isdir(sourcespath):
shutil.rmtree(sourcespath)
os.makedirs(sourcespath)
for filename in changedfiles:
gcdafile = GCDA_DIR+'/'+filename+'.gcda'
if os.path.exists(gcdafile):
shutil.copy(gcdafile,sourcespath)
else:
print 'Error:GCDA file not found for %s' %gcdafile
exit(1)
gcnofile = GCNO_DIR + '/'+filename + '.gcno'
if not os.path.exists(gcnofile):
gcnofile = gcnofile.replace(OBJECT_FILE_DIR_normal,OBJECT_FILE_DIR_main)
if not os.path.exists(gcnofile):
print 'Error:GCNO file not found for %s' %gcnofile
exit(1)
shutil.copy(gcnofile,sourcespath)
接下来使用了lcov
工具,这个工具能够让我们的代码覆盖率可视化,方便在覆盖率不达标的情况下去查看哪些文件的行没有执行到。lcov
命令会根据gcno
和gcda
生生一个中间文件.info
,.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>
生成.info
过程
os.system(lcov + '-c -b %s -d %s -o \"Coverage.info\"' %(SCRIPT_DIR,sourcespath))
if not os.path.exists(SCRIPT_DIR+'/Coverage.info'):
print 'Error:failed to generate Coverage.info'
exit(1)
if os.path.getsize(SCRIPT_DIR+'/Coverage.info') == 0:
print 'Error:Coveragte.info size is 0'
os.remove(SCRIPT_DIR+'/Coverage.info')
exit(1)
接下来结合拿到的git信息修改.info
实现增量,首先删除git没有记录修改的类。
for line in os.popen(lcov + ' -l Coverage.info').xreadlines():
result = headerFileRe.findall(line)
if result and not result[0].strip() in changedClasses:
filterClasses.add(result[0].strip())
if len(filterClasses) != 0:
os.system(lcov + '--remove Coverage.info *%s* -o Coverage.info' %'* *'.join(filterClasses))
删除git没有记录修改的行
for line in lines:
#match file name
if line.startswith('SF:'):
infoFilew.write('end_of_record\n')
classname = line.strip().split('/')[-1].strip()
changedlines = pushdiff.changedLinesForClass(classname)
if len(changedlines) == 0:
lcovclassinfo = None
else:
lcovclassinfo = lcovInfo.lcovclassinfo(classname)
infoFilew.write(line)
if not lcovclassinfo:
continue
#match lines
DAResult = DARe.findall(line)
if DAResult:
(startIndex,count) = DAResult[0]
if not int(startIndex) in changedlines:
continue
infoFilew.write(line)
if int(count) == 0:
lcovclassinfo.nohitlines.add(int(startIndex))
else:
lcovclassinfo.hitlines.add(int(startIndex))
continue
现在.info
文件只记录了git修改的类及对应行的覆盖率信息,同时LcovInfo
这个数据结构保存了相关信息,后面分析每次commit的覆盖率的时候会用到。通过·genhtml````命令生成可视化覆盖率信息。这个结果保存在脚本目录下的coverage路径下,可以打开
index.html```查看增量覆盖率情况。
if not os.path.getsize('Coverage.info') == 0:
os.system(genhtml + 'Coverage.info -o Coverage')
os.remove('Coverage.info')
index.html
示例,次级页面会有更详细信息:
最后一步通过git rebase
修改commit-msg,得到开篇的效果。
for i in reversed(range(0,len(pushdiff.commitdiffs))):
commitdiff = pushdiff.commitdiffs[i]
if not commitdiff:
os.system('git rebase --abort')
continue
coveragerate = commitdiff.coveragerate()
lines = os.popen('git log -1 --pretty=%B').readlines()
commitMsg = lines[0].strip()
commitMsgRe = re.compile('coverage: ([0-9\.-]*)')
result = commitMsgRe.findall(commitMsg)
if result:
if result[0].strip() == '%.2f'%coveragerate:
os.system('git rebase --continue')
continue
commitMsg = commitMsg.replace('coverage: %s'%result[0],'coverage: %.2f'%coveragerate)
else:
commitMsg = commitMsg + ' coverage: %.2f%%'%coveragerate
lines[0] = commitMsg+'\n'
stashName = 'commit-amend-stash'
os.system('git stash save \'%s\';git commit --amend -m \'%s \' --no-edit;' %(stashName,''.join(lines)))
if string.find(os.popen('cd %s;git stash list'%SRCROOT).readline(),stashName) != -1:
os.system('git stash pop')
os.system('git rebase --continue;')