MVVM な WPF アプリで GitHub Actions を使って CI を行う

# 概要
MVVM な WPF アプリで GitHub Actions を使って、ビルドとテストを自動化します。
MVVM の実現には、Livet を使用します。
# 環境
* IDE: Visual Studio 2022
* ターゲットフレームワーク: .NET 6.0

# プロジェクトテンプレート
* Livet project template(.NET 6) (MVVM 実装用)
* MSTest テスト プロジェクト

# WPF アプリ
今回の検証用に簡単な計算をしてくれるアプリを作成しました。
計算方法を選んで、Value1 と Value2 に値を入れて、Calculate ボタンを押すと、Result に結果を出してくれるアプリです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/a8d90344-cd96-b9c1-d775-fb577d73ff4f.png)

以下ソースコードとなります。全ソースコードは[こちら](https://github.com/sakurabayusuke/GitHubActionsSampleForWPF)
```C#:Sample/ViewModels/MainWindowViewModel.cs
using Livet;
using Livet.Commands;
using Sample.Enums;

namespace Sample.ViewModels
{
    public class MainWindowViewModel : ViewModel
    {
        private Method _method;

        public Method Method
        {
            get => _method;
            set
            {
                _method = value;
                RaisePropertyChanged();
            }
        }

        public string Value1 { get; set; }

        public string Value2 { get; set; }

        private string _result;

        public string Result
        {
            get => _result;
            set
            {
                _result = value;
                RaisePropertyChanged();
            }
        }

        public ViewModelCommand CalCommand { get; set; }

        public MainWindowViewModel()
        {
            CalCommand = new ViewModelCommand(Cal);
        }

        private void Cal()
        {
            var valid1 = int.TryParse(Value1, out var num1);
            var valid2 = int.TryParse(Value2, out var num2);
            if (!valid1 || !valid2)
            {
                Result = "Error";
                return;
            }

            switch (Method)
            {
                case Method.Addition:
                    Result = (num1 + num2).ToString();
                    break;
                case Method.Subtraction:
                    Result = (num1 - num2).ToString();
                    break;
                case Method.Multiplication:
                    Result = (num1 * num2).ToString();
                    break;
                case Method.Division:
                    Result = num2 == 0 ? "Error" : (num1 / num2).ToString();
                    break;
                default:
                    Result = "Error";
                    break;
            }
        }
    }
}

```

```C#:Sample/Enums/Enums.cs
namespace Sample.Enums
{
    public enum Method
    {
        Addition,
        Subtraction,
        Multiplication,
        Division,
    }
}

```

```C#:Sample/Converter/EnumToRadioConverter.cs
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Markup;

namespace Sample.Converter
{
    public class EnumToRadioConverter : MarkupExtension, IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value?.Equals(parameter);
        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => value?.Equals(true) == true ? parameter : Binding.DoNothing;
        public override object ProvideValue(IServiceProvider serviceProvider) => this;
    }
}

```

```xml:Sample/Views/MainWindow.xaml
<Window x:Class="Sample.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:cv="clr-namespace:Sample.Converter"
        xmlns:enums="clr-namespace:Sample.Enums"
        xmlns:vm="clr-namespace:Sample.ViewModels"
        Title="MainWindow"
        Width="525"
        Height="350">

    <Window.Resources>
        <ResourceDictionary>
            <Style x:Key="Radio"
                   TargetType="RadioButton">
                <Setter Property="Margin"
                        Value="0 0 24 0" />
                <Setter Property="VerticalContentAlignment"
                        Value="Center" />
                <Setter Property="VerticalAlignment"
                        Value="Center" />
                <Setter Property="FontSize"
                        Value="16" />
                <Setter Property="Padding"
                        Value="0" />
            </Style>
            <Style x:Key="TxtBox"
                   TargetType="TextBox">
                <Setter Property="InputMethod.IsInputMethodEnabled"
                        Value="False" />
                <Setter Property="HorizontalAlignment"
                        Value="Left" />
                <Setter Property="Width"
                        Value="100" />
                <Setter Property="FontSize"
                        Value="16" />
            </Style>
            <Style x:Key="TxtBlock"
                   TargetType="TextBlock">
                <Setter Property="FontSize"
                        Value="16" />
                <Setter Property="Margin"
                        Value="0 0 0 4" />
            </Style>
        </ResourceDictionary>
    </Window.Resources>

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <DockPanel Margin="16">
        <TextBlock DockPanel.Dock="Top"
                   FontSize="24"
                   Text="Method of calculation"
                   Margin="0 0 0 4" />
        <StackPanel DockPanel.Dock="Top"
                    Orientation="Horizontal"
                    Margin="0 0 0 16">
            <RadioButton Content="Addition"
                         Style="{StaticResource Radio}"
                         IsChecked="{Binding Method, Converter={cv:EnumToRadioConverter}, ConverterParameter={x:Static enums:Method.Addition}}" />
            <RadioButton Content="Subtraction"
                         Style="{StaticResource Radio}"
                         IsChecked="{Binding Method, Converter={cv:EnumToRadioConverter}, ConverterParameter={x:Static enums:Method.Subtraction}}" />
            <RadioButton Content="Multiplication"
                         Style="{StaticResource Radio}"
                         IsChecked="{Binding Method, Converter={cv:EnumToRadioConverter}, ConverterParameter={x:Static enums:Method.Multiplication}}" />
            <RadioButton Content="Division"
                         Style="{StaticResource Radio}"
                         IsChecked="{Binding Method, Converter={cv:EnumToRadioConverter}, ConverterParameter={x:Static enums:Method.Division}}" />
        </StackPanel>
        <TextBlock DockPanel.Dock="Top"
                   Text="Values"
                   FontSize="24"
                   Margin="0 0 0 4" />
        <DockPanel Dock="Top"
                   Margin="0 0 0 16">
            <StackPanel Margin="0 0 16 0">
                <TextBlock Text="Value1"
                           Style="{StaticResource TxtBlock}" />
                <TextBox Text="{Binding Value1}"
                         Style="{StaticResource TxtBox}" />
            </StackPanel>
            <StackPanel Margin="0 0 16 0">
                <TextBlock Text="Value2"
                           Style="{StaticResource TxtBlock}" />
                <TextBox Text="{Binding Value2}"
                         Style="{StaticResource TxtBox}" />
            </StackPanel>
        </DockPanel>

        <TextBlock DockPanel.Dock="Top"
                   Text="Result"
                   FontSize="24"
                   Margin="0 0 0 4" />
        <Button Content="Calculate"
                Height="24"
                Width="100"
                DockPanel.Dock="Top"
                HorizontalAlignment="Left"
                Background="LimeGreen"
                Foreground="White"
                FontWeight="Bold"
                FontSize="16"
                VerticalAlignment="Top"
                Margin="0 0 0 8"
                Command="{Binding CalCommand}"/>
        <StackPanel DockPanel.Dock="Top"
                    Orientation="Horizontal">
            <TextBlock Text="Value1"
                       Style="{StaticResource TxtBlock}"/>
            <TextBlock FontSize="16" Margin="8 0">
                <TextBlock.Style>
                    <Style TargetType="TextBlock">
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding Method}"
                                         Value="{x:Static enums:Method.Addition}">
                                <Setter Property="Text"
                                        Value="+" />
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Method}"
                                         Value="{x:Static enums:Method.Subtraction}">
                                <Setter Property="Text"
                                        Value="-" />
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Method}"
                                         Value="{x:Static enums:Method.Multiplication}">
                                <Setter Property="Text"
                                        Value="×" />
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Method}"
                                         Value="{x:Static enums:Method.Division}">
                                <Setter Property="Text"
                                        Value="÷" />
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </TextBlock.Style>
            </TextBlock>
            <TextBlock Text="Value2"
                       Style="{StaticResource TxtBlock}"
                       Margin="0 0 8 0"/>
            <TextBlock Text="="
                       Style="{StaticResource TxtBlock}"
                       Margin="0 0 8 0"/>
            <TextBlock Text="{Binding Result}"
                       FontSize="16"/>
        </StackPanel>
    </DockPanel>
</Window>
```


# テストコード
ユーザーの操作を想定してテストコードを書いています。
足し引き掛け割り算の 4 パターンとエラーになるパターン、全５つのテストケースを用意しました。

```C#:SampleTest/MainViewModelUnitTest.cs
using Sample.Enums;
using Sample.ViewModels;

namespace SampleTest
{
    [TestClass]
    public class MainViewModelUnitTest
    {
        /// <summary>
        /// Test Addition
        /// </summary>
        [TestMethod]
        public void TestAddition()
        {
            var vm = new MainWindowViewModel();

            vm.Method = Method.Addition;
            vm.Value1 = "12";
            vm.Value2 = "23";

            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "35");

            vm.Value1 = "-1";
            vm.Value2 = "1";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "0");
        }

        /// <summary>
        /// Test Subtraction
        /// </summary>
        [TestMethod]
        public void TestSubtraction()
        {
            var vm = new MainWindowViewModel();

            vm.Method = Method.Subtraction;
            vm.Value1 = "12";
            vm.Value2 = "23";

            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "-11");

            vm.Value1 = "-1";
            vm.Value2 = "1";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "-2");
        }

        /// <summary>
        /// Test Multiplication
        /// </summary>
        [TestMethod]
        public void TestMultiplication()
        {
            MainWindowViewModel vm = new MainWindowViewModel();
            var b = "sss";
            vm.Method = Method.Multiplication;
            vm.Value1 = "12";
            vm.Value2 = "23";

            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "276");

            vm.Value1 = "-1";
            vm.Value2 = "1";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "-1");
        }

        /// <summary>
        /// Test Multiplication
        /// </summary>
        [TestMethod]
        public void TestDivision()
        {
            var vm = new MainWindowViewModel();

            vm.Method = Method.Division;
            vm.Value1 = "50";
            vm.Value2 = "24";

            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "2");

            vm.Value1 = "-1";
            vm.Value2 = "1";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "-1");
        }

        /// <summary>
        /// Test ErrorPattern
        /// </summary>
        [TestMethod]
        public void TestErrorPattern()
        {
            var vm = new MainWindowViewModel();

            vm.Method = Method.Addition;

            // 両方未入力パターン
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "Error");

            // 数字が入力されていないパターン
            vm.Value1 = "aaaa";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "Error");

            vm.Value1 = string.Empty;
            vm.Value2 = "bbbb";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "Error");

            // 0 除算パターン
            vm.Method = Method.Division;
            vm.Value1 = "999";
            vm.Value2 = "0";
            vm.CalCommand.Execute();
            Assert.AreEqual(vm.Result, "Error");
        }
    }
}
```

# ワークフローファイルの追加
ワークフローファイルは、.yml で記述し、RepositoryRootFolder/.github/workflows/ に配置することで機能します。
GitHub からテンプレートが提供されているため、そちらを埋めていきます。
今回は、.NET Desktop というテンプレを使用しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/3f0bcb17-63c0-18b1-9ac6-657108a81dd3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/128d75f2-91b5-f435-b422-be262817b848.png)



テンプレから CD 用の job や変数を排除し、テストとビルドだけ行うようにしたのが以下となります。
書いてある主な内容は、develop ブランチへのプルリク時に、テストを実行し、その後 Debug ビルドと Release ビルドを実行せよという内容になります。

```yaml:.github.workflows/dotnet-desktop.yml
name: .NET Core Desktop

on:
  pull_request:
    branches: [ "develop" ]

jobs:

  build:

    strategy:
      matrix:
        configuration: [Debug, Release]

    runs-on: windows-latest  # For a list of available runner types, refer to
                             # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on

    env:
      Solution_Name: GitHubActionsSample.sln
      Test_Project_Path: GitHubActionsSample\SampleTest\SampleTest.csproj

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

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

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

    # Execute all unit tests in the solution
    - name: Execute unit tests
      run: dotnet test $env:Test_Project_Path

    # Restore the application to populate the obj folder with RuntimeIdentifiers
    - name: Restore the application
      run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
      env:
        Configuration: ${{ matrix.configuration }}

```

# .github/workflows/dotnet-desktop.yml が push できない...
windows10 + sourcetree でソースの push をしていたのですが、「refusing to allow an OAuth App create or update workflow」というエラーが出て push できませんでした。
どうも workflow に関する権限がないことが原因のようでした。アクセストークンを発行するとともに[こちら](https://oc-technote.com/github/sourcetree-cannot-access-github-using-accesstoken/)を参考にすることで解決できました。感謝します。

# 実行
今回は、develop へのプルリクがトリガーなので、適当なブランチを作り、プルリクも作ります。プルリクを作った時点で、先程のワークフローの job が実行されます。

# GitHub Actions 上で ビルドが失敗する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/0063aa23-5e82-eeaa-a3b4-11759f8a1aa5.png)

job のステップの一つとして、`msbuild $env:Solution_Name /t:Restore /p:Configuration-$env:Configuration`というコマンドでビルドを行なっていたのですが、なぜか`Project file does not exist.`と表示され、失敗してしまいます。試しに VS2022 上で、同じ意味のコマンドを実行したのですが、こちらは成功しました。


.sln の位置を見失っているのかなと思い、`Solution_Name` の代わりに `Solution_Path` という環境変数を作り、そちらに `GitHubActionsSample\GitHubActionsSample.sln` という値を入れ、実行してみたところ、成功しました。

```diff_yaml
    env:
      Solution_Name: GitHubActionsSample.sln
+     Solution_Path: GitHubActionsSample\GitHubActionsSample.sln

# <中略>

    # Restore the application to populate the obj folder with RuntimeIdentifiers
    - name: Restore the application
-     run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
+     run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration
      env:
```

# 結果
成功するとプルリクの Conversation タブに以下のように表示されます。失敗時や実行中の場合もそれぞれ、この画面にステータスが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/e6d5c2fb-608e-d5e7-40e4-5d34b399acfd.png)


Checks タブから詳細を見ることもできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/34beaa58-0268-9843-3e19-7a4b7cec8630.png)

File changed タブにコードレビューがされてました。わざと不要な変数や、非効率な宣言をしてみたのですが、不要な変数に関しては、Warning で指摘されてますね。どうやら、`dotnet test` コマンドがそのような機能を持っているようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3117450/91f85900-e9b4-9738-17ad-cbb68865fb4d.png)


# 終わりに
* 使用してみた感じ、実行時間が結構長いですね...。 DebugBuild の方で 6分弱、ReleaseBuild の方で 3 分かかっています。Golang でもやったことがあるのですが、そちらはビルド、テスト、linter によるコードレビューを含めても 1 分かかってなかったと思います。チーム開発の場合、このペースだとすぐに無料枠を使い切ってしまうでしょうから注意が必要です。

* 今回は、WPF における GitHub Actions の CI 部分の導入をしてみました。linter の導入や CD の方法については、まだ不明な点が多いので今後さらに調査していきたいです。