🐷

GitHub Actions で .NETプロジェクトのコードカバレッジを計測する

2022/12/05に公開

GitHub Actions Advent Calendar 2022 5日目の記事です。

コードカバレッジ、すなわちコードについてどの程度テストされているかを、pull requestのたびに自動的に求めるGitHub Actionsを作ります。

サードパーティーアクションで簡単に実現する方法と、独力で実装する2通りの方法を紹介します。

pull requestにBotからのコメントがこのように出てくるのが完成イメージです。
pull requestにおけるコードカバレッジのBotコメントの例
pull requestにおけるコードカバレッジのBotコメントの例

環境

  • .NET 6
    • .NET Core系列ならバージョン問わず可能なはずです。 .NET Frameworkだとうまく動かないかもしれません。

例題プロジェクト

https://github.com/shimat/dotnet_coverage_sample

円の面積を求める簡単なクラスライブラリを例題とします。

AreaCalcurator.cs
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によるテストプロジェクトを隣に作ります。

AreaCalcuratorTests.cs
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 パッケージをインストールします。
https://www.nuget.org/packages/coverlet.collector

Visual StudioのxUnitテンプレート (コマンドラインでは dotnet new xunit) からテストプロジェクトを作成した場合は、coverlet.collector は最初からインストールされています。

コードカバレッジを算出・出力する方法

以下の手順通りです。
https://learn.microsoft.com/ja-jp/dotnet/core/testing/unit-testing-code-coverage?tabs=windows

必要なコマンドだけかいつまんで示すと、以下でコードカバレッジを算出して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をご覧ください。
https://github.com/marketplace/actions/code-coverage-summary

.github/workflows/dotnet.yml
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したとしても、都度コメントが増えていくのではなく既存のものに上書きされます。

なお、あらかじめリポジトリの設定を変更する必要があるかもしれません。以下参考にしてください。
https://github.com/marocchino/sticky-pull-request-comment/issues/893

再掲ですが以下のようなコメントが出ます。
pull requestにおけるコードカバレッジのBotコメントの例
pull requestにおけるコードカバレッジのBotコメントの例

2.独力で行う方法

何らかの理由でサードパーティアクションを使いたくないとか、細かくカスタマイズしたい場合等にお勧めです。なお irongut/CodeCoverageSummary はWindowsは対応しないようで、Windowsランナーの利用を考える場合もこちらになります。

HTML と MarkdownSummary の2形式で出力し、アーティファクトとして保存しています。コメントで簡易版を見てもよし、詳細に見たければアーティファクトをダウンロードして手元でHTMLをみてもよし、ということにしています。

最後の長大なステップがコメント実装で、以下を参考にしています。ここでも既存コメントがあれば上書きするようにしています。(毎度新規コメントでもよいなら、半分くらいに短くできます。)
https://github.com/hashicorp/setup-terraform#usage

.github/workflows/dotnet.yml
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では各クラスの問題点を見ることができます。ここでは半径が負の場合をテストできていないと言われています。
各クラスのコードカバレッジ結果(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