iOS增量代碼覆蓋率工具(附源碼)

這個工具是根據 《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

avatar

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" 複製代碼
  • 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;')
複製代碼
相關文章
相關標籤/搜索