自作のWPFアプリを後から自動テスト・DI・CI/CD対応にしてみる。その3

# 概要

これはもともとあったMVVMなWPFアプリに自動テストとDependency-Injection(DI)とCI/CDを実装してみた記録です。変更点はWPFとはあまり関係ないので、C#であれば他のフレームワークにも参考になるかもしれません。

前回は自動テストとDIの追加までやりました。

https://qiita.com/soi/items/a19304220c71bdfbc171

今回はGitHubActionsを使ってCI/CDを追加してみます。
コードをpushするだけで、Github上でビルド・テスト実行・テストカバレッジのアップロード・Releaseページの下書き作成までが自動でできます。

WPFアプリの中身はファイルリネーマーです。アプリの詳細はこちらで。コード行数850行ぐらい、クラス数40個ぐらいのサンプルアプリに毛の生えたぐらいのコードサイズです。

https://github.com/soi013/FileRenamerDiff

TestフレームワークはxUnit、DIコンテナはMicrosoft.Extensions.DependencyInjection、CIはGitHubActionsを使用しました。

# GithubActionsの作成

### YAMLファイルの追加

まず、GitHubActionsのソースコードにあたる、YAMLファイルを作成します。
GithubのActionsタブにいくと、オススメのWorkflowが表示されているので、".NET Desktop"を選んで追加します。似たのに".NET"がありますが、こちらはデスクトップアプリケーション以外の.NET用です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/1c99836f-a59a-fa0e-87df-b05affe9a21f.png)

デスクトップアプリケーションに適したWorkflowが追加されるので、これを修正していきます。
そのままGithub上で編集してもよいですし、PullしてローカルでVSCode等を使用して編集してもよいです。

### 環境変数などの追加

アプリケーションプロジェクトやテストプロジェクトの名前などを指定します。
デフォルトで入っている`env:`の部分を修正します。

```yaml:dotnet-desktop.yml
env:
  App_Name: FileRenamerDiff
  Solution_Directory: src
  Solution_Path: src/FileRenamerDiff.sln
  App_Project_Path: src/FileRenamerDiff/FileRenamerDiff.csproj
  Test_Directory: UnitTests
  CodeCov_Result: "lcov.xml"
```

### Dump表示の追加

これは必須ではありませんが、GithubActionsをデバッグするときに便利なのでDump表示を`steps`の最初に入れておくことをオススメします。

```yaml:dotnet-desktop.yml
steps:
  # Dump for debug workflow
  - name: Dump Github Context
    env:
      GitHub_Context: ${{ toJson(github) }}
    run: echo "${GitHub_Context}"
```


### リストア→ビルド

次にアプリケーションをビルドしますが、その前にリストア（依存関係の解決）します。
ローカルでのビルドやテストでは自動でリストアが走っています。しかしGithubActions上では事前に明示的にリストアして、ビルドやテストではリストア処理をスキップすることでワークフローが効率化できます。


```yaml:dotnet-desktop.yml
# Restore before build and test
- name: Restore
  run: dotnet restore ${{ env.Solution_Path }}

- name: Build with dotnet
  run: dotnet build ${{ env.App_Project_Path }} --no-restore
  env:
    Configuration: ${{ matrix.configuration }}
```

### テスト実行→カバレッジ計算

そしていよいよテストの実行です。今回はテストの実行と共にテストカバレッジの計算までしています。
いくつかのファイルはカバレッジに含めたくないので、除外パターンを指定しています。基本的にView関係のファイル（XAML・コードビハインド・リソースファイル）は除いています。ただしConverterなどはテストできるので含めます。

```yaml:dotnet-desktop.yml
# Execute all unit tests in the solution
- name: Execute unit tests
  run: >
    dotnet test ${{ env.Solution_Path }}
    --verbosity normal --no-restore
    --collect:"XPlat Code Coverage"
    /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
    /p:ExcludeByFile="**.Designer.cs%2c**.xaml*%2c**.g.cs%2c**.xaml"
    -p:coverletOutput=${{ env.CodeCov_Result }}
  env:
    Configuration: ${{ matrix.configuration }}
```

そうして計算したカバレッジ結果をアップロードします。

```yaml:dotnet-desktop.yml
- name: Send coverage result to codecov
  uses: codecov/codecov-action@v2.0.3
  with:
    files: ${{ env.Solution_Directory }}/${{ env.Test_Directory }}/${{ env.CodeCov_Result }}
```

### Release時のみ実行Job

ここまではpushされたら必ず実行する内容でしたが、ここからはRelease時にのみ変更します。
まず、実行条件を分けるので、別のjobにします。
コミットに`v---`というタグがついていた場合は、リリースタグとみなして、jobを実行します。

```yaml:dotnet-desktop.yml
create-release:
  runs-on: windows-latest
  needs: [build]
  if: "contains( github.ref , 'v')"
  steps:
...
```

### Publishの実行

`dotnet publish`で自己完結型、単一ファイルの指定をして、アプリケーションの発行を行います。x86とx64で2回行います。
発行完了後、ファイルをアップロードしておきます。

```yaml:dotnet-desktop.yml
- name: dotnet publish x86
  run: dotnet publish ${{ env.App_Project_Path }} -c Release -r win-x86 --self-contained true -p:PublishTrimmed=false -p:PublishSingleFile=true -p:PublishReadyToRun=true -o outputs\${{ env.app_x86_name }}
- name: dotnet publish x64
  run: dotnet publish ${{ env.App_Project_Path }} -c Release -r win-x64 --self-contained true -p:PublishTrimmed=false -p:PublishSingleFile=true -p:PublishReadyToRun=true -o outputs\${{ env.app_x64_name }}

- name: Archive publish files
  uses: actions/upload-artifact@v1
  with:
    name: FileRenamerDiff_apps
    path: outputs
```

### Releaseノートの下書き
Releaseノートの下書きを作ります。毎回デザイン修正とバグフィックスはあるので、最初から書いておきます。

```yaml:dotnet-desktop.yml
    - name: Create release
      id: create_release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
      with:
        tag_name: v${{ env.version }}
        release_name: Ver ${{ env.version }}
        body: |
          - Change design
          - Bug fix
        draft: true
        prerelease: false
```

### ReleaseノートへのAssetsの追加

さきほど作成したPublishファイルをZipに圧縮してリリースノートのAssetsへ追加します。

```yaml:dotnet-desktop.yml
    - name: Archive packages
      shell: pwsh
      run: |
        Compress-Archive -Path outputs\${{ env.app_x86_name }} -DestinationPath ${{ env.app_x86_name }}.zip
        Compress-Archive -Path outputs\${{ env.app_x64_name }} -DestinationPath ${{ env.app_x64_name }}.zip

    - name: Upload Release Asset
      uses: csexton/release-asset-action@v2
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        release-url: ${{ steps.create_release.outputs.upload_url }}
        files: |
          ${{ env.app_x86_name }}.zip
          ${{ env.app_x64_name }}.zip
```

### 全体

<details><summary>YAMLファイル全体は以下です。</summary><div>

```yaml:dotnet-desktop.yml
name: .NET Build and Test
on:
  push:
env:
  App_Name: FileRenamerDiff
  Solution_Directory: src
  Solution_Path: src/FileRenamerDiff.sln
  App_Project_Path: src/FileRenamerDiff/FileRenamerDiff.csproj
  Test_Directory: UnitTests
  CodeCov_Result: "lcov.xml"

jobs:
  build:
    strategy:
        matrix:
          configuration: [Debug, Release]
    runs-on:
      windows-latest
    
    steps:
      # Dump for debug workflow
      - name: Dump Github Context
        env:
          GitHub_Context: ${{ toJson(github) }}
        run: echo "${GitHub_Context}"

      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      # Install the .NET Core workload
      - name: Install .NET Core
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: 5.0.x

      # Add  MsBuild to the PATH: https://github.com/microsoft/setup-msbuild
      - name: Setup MSBuild.exe
        uses: microsoft/setup-msbuild@v1.0.2

      # Restore before build and test
      - name: Restore
        run: dotnet restore ${{ env.Solution_Path }}

      - name: Build with dotnet
        run: dotnet build ${{ env.App_Project_Path }} --no-restore
        env:
          Configuration: ${{ matrix.configuration }}

      # Execute all unit tests in the solution
      - name: Execute unit tests
        run: >
          dotnet test ${{ env.Solution_Path }}
          --verbosity normal --no-restore
          --collect:"XPlat Code Coverage"
          /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
          /p:ExcludeByFile="**.Designer.cs%2c**.xaml*%2c**.g.cs%2c**.xaml"
          -p:coverletOutput=${{ env.CodeCov_Result }}
        env:
          Configuration: ${{ matrix.configuration }}

      - name: Send coverage result to codecov
        uses: codecov/codecov-action@v2.0.3
        with:
          files: ${{ env.Solution_Directory }}/${{ env.Test_Directory }}/${{ env.CodeCov_Result }}

  create-release:
    runs-on: windows-latest
    needs: [build]
    if: "contains( github.ref , 'v')"

    steps:
      - name: echos
        shell: bash
        run: |
          echo $RELEASE_VERSION
          echo version=${GITHUB_REF/refs\/tags\/v/}
          echo "version=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV
          echo "app_x86_name=${{ env.App_Name }}_app_win-x86_ver${{ env.version }}" >> $GITHUB_ENV
          echo "app_x64_name=${{ env.App_Name }}_app_win-x64_ver${{ env.version }}" >> $GITHUB_ENV
          pwd

      - name: confirm env value
        shell: bash
        run: |
          echo "env.version=${{ env.version }}"
          echo "app_x86_name=${{ env.app_x86_name }}"
          echo "app_x64_name=${{ env.app_x64_name }}"

      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      - name: dotnet publish x86
        run: dotnet publish ${{ env.App_Project_Path }} -c Release -r win-x86 --self-contained true -p:PublishTrimmed=false -p:PublishSingleFile=true -p:PublishReadyToRun=true -o outputs\${{ env.app_x86_name }}
      - name: dotnet publish x64
        run: dotnet publish ${{ env.App_Project_Path }} -c Release -r win-x64 --self-contained true -p:PublishTrimmed=false -p:PublishSingleFile=true -p:PublishReadyToRun=true -o outputs\${{ env.app_x64_name }}

      - name: Archive publish files
        uses: actions/upload-artifact@v1
        with:
          name: FileRenamerDiff_apps
          path: outputs

      - name: Create release
        id: create_release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
        with:
          tag_name: v${{ env.version }}
          release_name: Ver ${{ env.version }}
          body: |
            - Change design
            - Bug fix
          draft: true
          prerelease: false

      - name: Archive packages
        shell: pwsh
        run: |
          Compress-Archive -Path outputs\${{ env.app_x86_name }} -DestinationPath ${{ env.app_x86_name }}.zip
          Compress-Archive -Path outputs\${{ env.app_x64_name }} -DestinationPath ${{ env.app_x64_name }}.zip

      - name: Upload Release Asset
        uses: csexton/release-asset-action@v2
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          release-url: ${{ steps.create_release.outputs.upload_url }}
          files: |
            ${{ env.app_x86_name }}.zip
            ${{ env.app_x64_name }}.zip

```

</div></details>

## 実行結果

### GithubActions進行状況
YAMLファイルを追加すると、GithubActionsが実行されます。
GitHubのActionsタブにいくと、進行状況が見れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/32a8dbbb-eb0d-9c03-11f6-017a842191ec.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/3f9e4167-d68b-f78b-5371-af0d8cd20834.png)

`build`jobは`configuration`をDebugとReleaseの2つ指定したので、並列で2つのjobが走ります。それらが終わって、コミットタグに`v~`が指定されていたら、`create-release`jobが走ります。

完了すると、緑色の表示になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/7ccf5cc3-7991-6b15-a1ee-a0eabbc4aa8d.png)

### Releaseノート

Releaseノートにいくと、下書きができていますので、リリース内容を確認してPublishします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/e0253dcd-7516-a7d3-ff5f-bb6173a196c3.png)

### テストカバレッジ表示

テストカバレッジの結果をアップロードしているので、カバレッジが経時的に変化しているかも見ることができます。

https://app.codecov.io/gh/soi013/FileRenamerDiff

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/fff1030c-d230-117f-9878-01274138fdec.png)

こうやってVisualizeされると退屈なテストを書くモチベーションも上がりますね！

## デバッグのコツ

うまくワークフローが動くようになるとGithubActionsはとても便利ですが、最初はつまずくこともあります。
ここでは私が行ったいくつかの工夫を紹介します。

### GithubActions練習用レポジトリの作成

GithubActionsの練習をアプリケーションのレポジトリでやってしまうとコミットツリーにノイズが増えすぎてしまいます。
またアプリケーションの規模大きくなると、ワークフローの実行時間も増えてきてしまいます。
最終的には本番レポジトリでも試行錯誤が必要ですが、GithubActions自体の練習は専用のレポジトリを作成したほうがよいでしょう。

### Dumpを確認する

GithubActionsがうまく動かない原因は、だいたいレポジトリ名やファイルパスなどが間違っていることです。
Run結果の一番最初の`Dump Github Context`の中を確認して他の変数指定とあっているかを確認します。

### ログを見やすく

GithubActionsのログは"個別のJobのRun結果＞⚙アイコン＞View raw logs"から表示できます。
ただしそのままではハイライトも無く見づらいのでダウンロードして`.log`ファイルとしてVSCodeで見ると色がついて見やすくなります。

### Re-run

ローカルでは成功するテストがGithubActions上では失敗することがあります。この場合、ワークフローを再実行することで成功することもあります。
"個別のJobのRun結果＞Re-run all jobs"で再実行します。
ただ、そもそも環境によって結果が変わるテストは筋が悪いので、テストコードを修正したほうがよいでしょう。

# 注意点

プライベートレポジトリの場合はいくつかのstepで認証が必要になるかもしれません。そのあたりはGithubActionsのドキュメントか使用するActionのレポジトリを確認してください。

# 後書き

GitHubActionsは動作を確認するためにCommitが必要になるので、試行錯誤を繰り返すと自分のGithubマイページの芝がよく生えます。
なれると自動でGithub側が色々やってくれてスゴイ便利です。

# 参考

https://www.youtube.com/watch?v=VIlDni8-iWM

https://docs.github.com/ja/actions/guides/building-and-testing-net

# 環境
VisualStudio 2019
C# 9
.NET 5
Microsoft.NET.Test.Sdk 16.9.4
xunit 2.4.1
xunit.runner.visualstudio 2.4.3
coverlet.collector 3.0.2
System.IO.Abstractions 13.2.38
System.IO.Abstractions.TestingHelpers 13.2.38