iOS增量代码覆盖率工具(附源码)

4,674 阅读4分钟

这个工具是根据 《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的代码覆盖率信息:

avatar

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,我们只在模拟器收集信息,所以这里的archx86_64。目前我们APP整体架构采用模块化,每个模块对应一个target,通过cocoapods管理。每个targetnormal路径是不一样的。如果想得到的是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命令会根据gcnogcda生生一个中间文件.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示例,次级页面会有更详细信息:

avatar

最后一步通过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;')