SonarQubeとVSTSをつなげてカバレッジとか見れるようにする

仕事でSonarQubeとVSTSを連携させた話を書く。

Qiitaなどで、SonarQubeの設定記事はよくあるし、それとJenkinsやその他のCIツールとを連携させている記事は多く見かけるがVSTSと連携させている記事はあまり見かけず、少しつまった部分があったので書いておく。

SonarQubeとは何か

この説明はよくいろいろな記事があるので割愛するが、静的解析ツールの一つ。
SonarQubeを選定した理由としては、静的解析ツールはたくさん色々なものがあるが、オープンソースC#,Typescript,Javascriptなどに対応していて、参考となるドキュメントなどが多いものを調べた結果、検索でたくさんヒットしたり、記事が多かったりしたものをなんとなく選定した。
公式のドキュメントページはこの辺
Documentation - SonarQube Documentation - Doc SonarQube

VSTSとは何か

Visual Studio Team Service
タスク管理からGit、CI/CDまでさまざまなものを幅広く提供しているサービス。これだけを使っておけばASP.NETでの開発は不自由なく進められる。
VSTSを利用している理由は、開発チーム全員にMSのサブスクリプションが割り当てられていて、VSTSを利用できる体制が整っているため、C#で開発するのにVSTSをあえて使わない理由がないので、使っている。

連携設定の方法

VSTS連携設定の方法は比較的簡単で、VSTSからテストなどを実行するための Build Pipeline ( 2018年の途中までは Build Definition という名前だった)の中で、SonarQubeへの連携開始のTaskとSonarQubeへの連携終了のTaskを配置するだけ。

詳しくはここに書いてあって、最初にPrepare Analysis Configuration taskを設定して、ビルド、テストを実行し、Run Code Analysis taskを実行する。もし、Quality Gateを利用したい場合には、Publish Quality Gate Result taskも設定する。

簡単だったのだが・・・

上記までは全然つまらずにSonarQubeのドキュメント通りに進めれば問題なく進められる。
多少つまるとすれば、分析対象のプロジェクトが大きい場合、SonarQube側でOut of Memoryエラーが発生することくらい。
Out of MemoryについてはSonarQubeのドキュメントにも書いてあり環境変数を設定する。
name : SONAR_SCANNER_OPTS
value : -Xmx3072m

ここまでで、大体の作業は終わって、今回あえて記事を書こうと思ったのは、VSTS上のTest Taskが分割されていた場合には、単純に上記の設定ではコードカバレッジの連携がうまくいかないことが分かったためである。

関わっていたプロジェクトでは、順番に依存したテストをかなりの数で書いており、それらが多すぎるとVSTSのエージェント側でもOut Of Memoryとなっていたので、Test Task(C#のテストなので Visual Studio Testという名前のタスク)を分割し計4つのTest Taskを順番に実行していた。
これらをTest1, Test2, Test3, Test4という名前だとする。
VSTSのエージェント側ではTest毎にビルド用フォルダ内に、trxファイルとcoverageファイルが生成される。(Visual Studio Testタスクの中で、Code coverage enabledのチェックをONにしている場合、coverageファイルが生成される)
trxファイルとcoverageファイルはTest Task毎に似たような名前で作成されて、VSTSにアップロード後、次のTest Taskでファイルは上書きされ、差し替えられるようになっている。
そして、最後にRun Code Analysis taskによって、SonarQubeサーバにtrxファイルとcoverageファイルがアップロードされるのだが、Run Code Analysis taskが実行されるタイミングでは、最後のTest4のタスクについてのtrxファイルとcoverageファイルしか残っていないため、すべてのテストについてのテスト結果やcoverageファイルをSonarQubeに連携できないという状態だった。

テスト毎に上書きされるのだから、上書きされないようにファイルを別フォルダに退避させて、Run Code Analysis taskが実行される直前で元の場所に名前を変えるなどして戻して、trxファイル、coverageファイルを4つずつSonarqube側に連携してもらえばよいかと考えたが、どうもRun Code Analysis taskは1回のBuild Pipelineの実行において、trxファイル、coverageファイルともに、1ファイルずつしか扱えないようで、この方法は断念した。

したがって、Sonarqubeに連携するにはどうにかしてtrxファイルとcoverageファイルをマージする必要がある。

trxファイルのマージ

trxファイルのマージはいろいろとググった結果、TRX-Mergerというものがあり、これを使ってマージすることで問題なくなった。

coverageファイルのマージ

coverageファイルのマージもググったのだが、こちらはVisual Studioで用意されているexeを利用すればいいことが分かった。
変換する方法が書いてあるだけだったが、exeから複数のcoverageのファイルパスを並べて実行してしまえば問題なかった。
コマンドは下記のような形
"C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe" analyze /output:Summary.coveragexml 0.coverage 1.coverage 2.coverage ...


というわけで、書いた大作のpowershellが↓です。
ところどころにGet-Locationと書いてありますが、これはデバッグ用に書いたもので消してないだけなのでうまく無視していください。

前提としてフォルダ構成はこんな感じになっていると思って読んでください。
$(Build.Repository.LocalPath)\TestResults
 ┣previous
 ┃ ┣("HogeAgent-ユニーク名"形式のフォルダ)\In\("ユニーク名"のフォルダ)
 ┃ ┃ ┗.coverageファイル
 ┃ ┣("HogeAgent-ユニーク名"形式のフォルダ)\In\("ユニーク名"のフォルダ)
 ┃ ┃ ┗.coverageファイル
 ┃ ┣("HogeAgent-ユニーク名"形式のフォルダ)\In\("ユニーク名"のフォルダ)
 ┃ ┃ ┗.coverageファイル
 ┃ ┣.trxファイル
 ┃ ┣.trxファイル
 ┃ ┗.trxファイル
 ┃ (↑Test Task毎に生成されているので3つの異なるtrxファイルとcoverageファイルが退避されている)
 ┃ (↓最後のTest Taskによって作成されたtrxファイルとcoverageファイル)
 ┣("HogeAgent-ユニーク名"形式のフォルダ)\In\("ユニーク名"のフォルダ)
 ┃ ┗.coverageファイル
 ┗.trxファイル


.coverageファイルをマージして、一つのcoverage.xmlファイルに変換する
CodeCoverage.exeのパスはVisual Studio 2017用のパスを利用していますが、適宜別のバージョンに読み替えればなんとかなるかと思っています。
※この部分はVSTSPowershell Task (Version 2.*)を利用しています。

cd $(Build.Repository.LocalPath)
# collect coverage file paths
cd TestResults\previous
$targetFilePaths = @()
Get-ChildItem | ForEach-Object -Process {
  # folder name is hard code on each environment ←ここはVSTSのagentの実行者の設定などによって、テスト結果のtrxファイルやcoverageファイルが出力されるフォルダ名が変わるので、こうしています。
  if(($_.Name.StartsWith("HogeAgent") -or $_.Name.StartsWith("HogeAgent2")) -And $_.PSIsContainer){
    $InfolderName = $_.Name + "\In"
    
    cd $InfolderName
# Get-Location
    $coverageFolderName = Get-ChildItem -Name | Select-Object -First 1
    
    cd $coverageFolderName
# Get-Location
    $coverageFileName = Get-ChildItem -Name | Select-Object -First 1
#    $coverageXmlFileName = $coverageFileName + "xml"
    
Write-Output $coverageFileName
#Write-Output $coverageXmlFileName

    # convert to coveragexml
#    & "C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe" analyze /output:"$coverageXmlFileName" "$coverageFileName"
    
    $targetFilePath = $InfolderName + "\" + $coverageFolderName + "\" + $coverageFileName
    $targetFilePaths = $targetFilePaths + ,$targetFilePath
    cd ..\..\..
Get-Location
  }
}

# set target merge file
# move to TestResults folder
cd ..
Get-Location
$targetCoverageFilePath = ""
Get-ChildItem | ForEach-Object -Process {
  # folder name is hard code on each environment
  if(($_.Name.StartsWith("HogeAgent") -or $_.Name.StartsWith("HogeAgent")) -And $_.PSIsContainer){
    $InfolderName = $_.Name + "\In"
    
    cd $InfolderName
    $coverageFolderName = Get-ChildItem -Name | Select-Object -First 1
    
    cd $coverageFolderName
    $coverageFileName = Get-ChildItem -Name | Select-Object -First 1

    $targetCoverageFilePath = $InfolderName + "\" + $coverageFolderName + "\" + $coverageFileName
    cd ..\..\..
  }
}

$lastCoverage = [string]$targetFilePaths.Length + ".coverage"
Copy-Item $targetCoverageFilePath -Destination $lastCoverage -Force
For ($i=0; $i -lt $targetFilePaths.Length; $i++) {
  $srcpath = "previous\" + $targetFilePaths[$i]
  $dstpath = [string]$i + ".coverage"
  Copy-Item $srcpath -Destination $dstpath -Force
}

$all_args = @("analyze", "/output:Summary.coveragexml", "0.coverage")
$coverageFiles = ,"0.coverage"
For ($i=1; $i -le $targetFilePaths.Length; $i++) {
  $cov = [string]$i + ".coverage"
  $all_args = $all_args + ,$cov
}
Write-Output $all_args

$cmd = "C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe"
& $cmd $all_args

Write-Output $paths


.trxファイルをマージして、一つのtrxファイルに変換する、前にtrxファイルのパスを集めたものをVSTSで利用する変数に格納する
※この部分はVSTSPowershell Task (Version 2.*)を利用しています。

cd $(Build.Repository.LocalPath)
# collect trx file paths
cd TestResults\previous
$targetFilePaths = ""
Get-ChildItem | ForEach-Object -Process {
  if($_.Name.EndsWith(".trx")){
    if($targetFilePaths -eq ""){
      $targetFilePaths = "previous\" + $_.Name
    }
    else{
      $targetFilePaths = $targetFilePaths + "," + "previous\" + $_.Name
    }
  }
}

# set target merge file
# move to TestResults folder
cd ..
Get-Location
Get-ChildItem | ForEach-Object -Process {
  if($_.Name.EndsWith(".trx")){
    $targetFilePaths = $targetFilePaths + "," + $_.Name
  }
}
Write-Output $targetFilePaths

Write-Output ("##vso[task.setvariable variable=TargetFilePaths;]$targetFilePaths")

Write-Output $targetFilePaths
cd ..

.trxファイルをマージして、一つのtrxファイルに変換する
TRX_Merger.exeはビルド後のものを利用しているので、パスがビルドされたものっぽいところになっています。
※この部分だけはVSTSPowershell Taskではなく、Command Line Taskで記述しています。理由はないですが、最初にこのTaskを書こうと思った時に、powershell から exe を叩く際の引数を複数指定する際の呼び方をよく分かっていなかったので、Command Line Taskを使っただけです。

cd $(Build.Repository.LocalPath)\TestResults

$(Build.Repository.LocalPath)\sources\Tools\trx-merger\TRX_Merger\bin\Debug\TRX_Merger.exe /trx:%TargetFilePaths% /output:Summary.trx

del HogeAgent*.trx
del HogeAgent2*.trx

上記によって、Summary.trxとSummary.codecoveragexmlが$(Build.Repository.LocalPath)\TestResultsに出力されているので、このファイルがSonarQubeに連携されるようにVSTSのBuild Pipelineでは、Prepare Analysis Configuration taskにおいて、Advanced > Additional Propertiesのところで、下記の2行を追記する。

sonar.cs.vscoveragexml.reportsPaths=**/Summary.coveragexml
sonar.cs.vstest.reportsPaths=**/Summary.trx

以上で、SonarQubeまでのコードカバレッジの連携がうまくいくようになった。

すごく長いものを書いてしまい、これは2回に分けて書くくらいでもよかったのかもしれないと感じた・・・。