这个工具是根据 《iOS 覆盖率检测原理与增量代码测试覆盖率工具实现》的一次实践(侵删),本篇文章更注重实现细节,原理部分能够参考原文。html
最终的效果是经过修改push脚本:python
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的代码覆盖率信息: ios
github连接:xuezhulian/Coveragegit
下面从增量和覆盖率介绍这个工具的实现。github
增量的结果根据git获得。xcode
git status
获得当前有几个commit须要提交。bash
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。markdown
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" 复制代码
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
来实现增量的结果展现。
这是咱们分析覆盖率用到的关键字段。
<absolute path to the source file>
<line number of function start>,<function name>
<execution count>,<function name>
<number of functions found>
<number of function hit>
<line number>,<execution count>[,<checksum>]
<number of lines with a non-zero execution count>
<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;') 复制代码