GitHub Actions で .NETプロジェクトのコードカバレッジを計測する
GitHub Actions Advent Calendar 2022 5日目の記事です。
コードカバレッジ、すなわちコードについてどの程度テストされているかを、pull requestのたびに自動的に求めるGitHub Actionsを作ります。
サードパーティーアクションで簡単に実現する方法と、独力で実装する2通りの方法を紹介します。
pull requestにBotからのコメントがこのように出てくるのが完成イメージです。
pull requestにおけるコードカバレッジのBotコメントの例
環境
- .NET 6
- .NET Core系列ならバージョン問わず可能なはずです。 .NET Frameworkだとうまく動かないかもしれません。
例題プロジェクト
円の面積を求める簡単なクラスライブラリを例題とします。
namespace CoverageSample;
public static class AreaCalculator
{
public static double Circle(double r)
{
if (r < 0)
throw new ArgumentOutOfRangeException(nameof(r));
return r * r * Math.PI;
}
}
これをテストするためのxUnit.netによるテストプロジェクトを隣に作ります。
namespace CoverageSample.Tests;
public class AreaCalculatorTests
{
[Theory]
[InlineData(1, Math.PI)]
[InlineData(5, 78.5398163397)]
public void Test(double r, double expected)
{
var area = AreaCalculator.Circle(r);
Assert.Equal(expected, area, 6);
}
}
テストプロジェクトには coverlet.collector パッケージをインストールします。
Visual StudioのxUnitテンプレート (コマンドラインでは dotnet new xunit
) からテストプロジェクトを作成した場合は、coverlet.collector は最初からインストールされています。
コードカバレッジを算出・出力する方法
以下の手順通りです。
必要なコマンドだけかいつまんで示すと、以下でコードカバレッジを算出してXML形式で出力します。
$ dotnet test --collect:"XPlat Code Coverage"
出力XMLファイルは、既定ではテストプロジェクト以下の TestResults/{GUID}/coverage.cobertura.xml
というパスに置かれます。{GUID}以下のパスはランダムであり固定不能です。この点はCIワークフローを組む際に多少厄介になります。
出力されたXMLを人間に見やすい形式に変換できます。
$ dotnet tool install -g dotnet-reportgenerator-globaltool
$ reportgenerator \
-reports:"./CoverageSample.Tests/TestResults/{GUID}/coverage.cobertura.xml" \
-targetdir:"coverage_report" \
-reporttypes:Html
出力形式(-reporttypes
)はHTML以外にも多くの形式に対応しています。https://github.com/danielpalme/ReportGenerator#usage--command-line-parameters
Report types一覧(引用)
Report types: The output formats and scope (separated by semicolon).
Values: Badges, Clover, Cobertura, CsvSummary,
MarkdownSummary, MarkdownDeltaSummary
Html, Html_Light, Html_Dark,
HtmlChart, HtmlInline, HtmlSummary,
HtmlInline_AzurePipelines, HtmlInline_AzurePipelines_Light, HtmlInline_AzurePipelines_Dark,
JsonSummary, Latex, LatexSummary, lcov, MHtml, PngChart, SonarQube, TeamCitySummary,
TextSummary, TextDeltaSummary
Xml, XmlSummary
GitHub Actionsワークフローを作成
上記コードカバレッジのためのコマンドを、GitHub Actions の .NET
テンプレートに肉付けしながら作成します。
1.サードパーティアクションを使う方法
以下を使用し、簡単かつ見栄えよく作れます。詳細はREADMEをご覧ください。
name: .NET
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
id: test
run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Find coverage output path
run: |
cp $(find . -name "coverage.cobertura.xml") .
- name: Code Coverage Report
uses: irongut/CodeCoverageSummary@v1.3.0
with:
filename: ./coverage.cobertura.xml
badge: true
fail_below_min: true
format: markdown
hide_branch_rate: false
hide_complexity: false
indicators: true
output: both
thresholds: '60 80'
- name: Add Coverage PR Comment
uses: marocchino/sticky-pull-request-comment@v2
if: github.event_name == 'pull_request'
with:
recreate: true
path: code-coverage-results.md
末尾でコメントのため使用している marocchino/sticky-pull-request-comment も有用です。recreate: true
のオプションにより、pull request作成後に何度かpushしたとしても、都度コメントが増えていくのではなく既存のものに上書きされます。
なお、あらかじめリポジトリの設定を変更する必要があるかもしれません。以下参考にしてください。
再掲ですが以下のようなコメントが出ます。
pull requestにおけるコードカバレッジのBotコメントの例
2.独力で行う方法
何らかの理由でサードパーティアクションを使いたくないとか、細かくカスタマイズしたい場合等にお勧めです。なお irongut/CodeCoverageSummary
はWindowsは対応しないようで、Windowsランナーの利用を考える場合もこちらになります。
HTML と MarkdownSummary の2形式で出力し、アーティファクトとして保存しています。コメントで簡易版を見てもよし、詳細に見たければアーティファクトをダウンロードして手元でHTMLをみてもよし、ということにしています。
最後の長大なステップがコメント実装で、以下を参考にしています。ここでも既存コメントがあれば上書きするようにしています。(毎度新規コメントでもよいなら、半分くらいに短くできます。)
name: .NET
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
id: test
run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Find coverage output path
run: |
cp $(find . -name "coverage.cobertura.xml") .
- name: Run ReportGenerator
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"coverage.cobertura.xml" -targetdir:"report" -reporttypes:"Html;MarkdownSummary"
echo "SUMMARY<<EOF" >> $GITHUB_ENV
echo "$(cat report/Summary.md)" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- uses: actions/upload-artifact@v3
with:
name: coverage-report
path: report/
- name: Comment
uses: actions/github-script@v6
if: ${{ github.event_name == 'pull_request' }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// 1. Retrieve existing bot comments for the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
})
const botComment = comments.find(comment => {
return comment.user.type === 'Bot' && comment.body.includes('dotnet test 🤖')
})
// 2. Prepare format of the comment
const output = `#### dotnet test 🤖\`${{ steps.test.outcome }}\`
<details><summary>Show coverage summary</summary>
${{ env.SUMMARY }}
</details>`;
// 3. If we have a comment, update it, otherwise create a new one
if (botComment) {
github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: output
})
} else {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
}
以下完成イメージです。MarkdownSummary形式そのままに出ますが、pull requestへのコメントとしてはやや冗長かもしれませんね。
独力でコメントした場合(折りたたまれた状態)
独力でコメントした場合(折りたたみを展開)
$GITHUB_STEP_SUMMARY
への出力によりジョブサマリーに表示させることが最近可能になりました(参考)。こちらのほうが向いているかもしれません。
echo "$(cat report/Summary.md)" >> $GITHUB_STEP_SUMMARY
以下はアーティファクトで保存したHTMLです。index.html
を開いてください。
コードカバレッジ結果(HTML)
HTMLでは各クラスの問題点を見ることができます。ここでは半径が負の場合をテストできていないと言われています。
各クラスのコードカバレッジ結果
HTML形式の出力については、GitHub PagesにアップロードするようにすればすべてWeb上で完結させることが可能かもしれませんね。
pwshの場合のワークフロー例
Windowsのself-hosted runnerを使う等の事情で、bashではなくpwshで定義したい場合のワークフローは以下のようになります。
name: ".NET"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: windows-2022
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
id: test
run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Find coverage output path
run: |
# find相当
$path = $(Get-ChildItem -Recurse -Name -Filter "coverage.cobertura.xml")
cp $path .
- name: Run ReportGenerator
run: |
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"coverage.cobertura.xml" -targetdir:"report" -reporttypes:"Html;MarkdownSummary"
# SUMMARY環境変数に report/Summary.md の内容を改行込みで詰める
$content = Get-Content -Path "report/Summary.md" -Raw
$content = "SUMMARY<<EOF`n$content`nEOF"
Add-Content -Path $env:GITHUB_ENV -Value $content
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: report/
- name: Comment
uses: actions/github-script@v7
if: ${{ github.event_name == 'pull_request' }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// 1. Retrieve existing bot comments for the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
})
const botComment = comments.find(comment => {
return comment.user.type === 'Bot' && comment.body.includes('dotnet test 🤖')
})
// 2. Prepare format of the comment
const output = `#### dotnet test 🤖\`${{ steps.test.outcome }}\`
<details><summary>Show coverage summary</summary>
${{ env.SUMMARY }}
</details>`;
// 3. If we have a comment, update it, otherwise create a new one
if (botComment) {
github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: output
})
} else {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
}
Discussion