ReSharper Test Runner で AWS Lambda ASP.NET Core Web API のテストプロセスが解放されない話

# Summary

- API のバックエンドに Lamdba を使うととっても便利！
- C#(.NET Core 3.1) に対応しているので公式が配布しているテンプレートを改造して API つくったろ！
- API つくるなら Unit Test は重要だよね！
- テンプレートの Unit Test を参考にテストを書いて実行すると Runner のプロセスが終了しない！なんで！😫
- ↓ (いろいろ調べる)
- 解放されていないリソースがあると Test Runner に使っている [ReSharper Test Runner](https://www.jetbrains.com/help/resharper/Reference__Options__Tools__Unit_Testing__Test_Runner.html) のプロセスが解放されないらしい
- そのため内部で使用している ASP.NET Core の Host を Dispose することで解決できた！🎉

要約すると解放をしなければならないものはきちんと後処理しましょうという話。

# 詳しい内容について

具体的にどういう状況かについて説明していきます。
まず環境から。

## 環境

```powershell
> dotnet --version
3.1.408
```

現在(2021-04-24) AWS Lambda では .NET Core 3.1 がサポートされているので .NET 5 ではなくこちらを使っていきます。

IDE には JetBrains の [Rider](https://www.jetbrains.com/ja-jp/rider/) を使用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173077/ec47ce76-5060-67ff-afdb-71fe3eb8fa22.png)


安くて割と軽いのでお勧めです。

今回の話は Visual Studio で ReSharper の Test Runner を使用しても同じ現象が現れます。 

## 問題が発生するコード

次に問題が発生するコードを作ります。
テンプレートをインストールしてソリューションファイルまで以下のコマンドで作成します。

```powershell
> dotnet new -i "Amazon.Lambda.Templates::*"
> dotnet new serverless.AspNetCoreWebAPI -n SampleProject
> dotnet new sln -n SampleProject -o SampleProject
> dotnet sln .\SampleProject\SampleProject.sln add .\SampleProject\src\SampleProject\SampleProject.csproj
> dotnet sln .\SampleProject\SampleProject.sln add .\SampleProject\test\SampleProject.Tests\SampleProject.Tests.csproj
```

このコマンドを実行すると以下のようなフォルダとファイルが作成されます。
これで問題が発生するコードの準備は完了です！

```powershell
> tree /f
フォルダー パスの一覧
ボリューム シリアル番号は です
C:.
│  global.json
│
└─SampleProject
    │  SampleProject.sln
    │
    ├─src
    │  └─SampleProject
    │      │  appsettings.Development.json
    │      │  appsettings.json
    │      │  aws-lambda-tools-defaults.json
    │      │  LambdaEntryPoint.cs
    │      │  LocalEntryPoint.cs
    │      │  Readme.md
    │      │  SampleProject.csproj
    │      │  serverless.template
    │      │  Startup.cs
    │      │
    │      └─Controllers
    │              ValuesController.cs
    │
    └─test
        └─SampleProject.Tests
            │  appsettings.json
            │  SampleProject.Tests.csproj
            │  ValuesControllerTests.cs
            │
            └─SampleRequests
                    ValuesController-Get.json

```

※ global.json は dotnet のバージョンを指定するためにあらかじめ作成しています。

## 取り合えず Command Line でテストを実行

まずはコマンドラインでテストを実行してみます。
`SampleProject` ディレクトリに移動して `dotnet test` を実行します。

```powershell
> cd .\SampleProject\
SampleProject> dotnet test
Microsoft (R) Test Execution Command Line Tool Version 16.7.1
Copyright (c) Microsoft Corporation.  All rights reserved.

テスト実行を開始しています。お待ちください...

合計 1 個のテスト ファイルが指定されたパターンと一致しました。

テストの実行に成功しました。
テストの合計数: 1
     成功: 1
合計時間: 2.3279 秒
```

無事に実行できました！

## 問題となる ReSharper Test Runner での実行

Rider で `SampleProject.sln` を開きます。
サンプルとなるテストが記述されている `ValuesControllerTests.cs` を開くとテストケースが認識され、メソッドの左にテストの実行ボタンが現れます。
ここからテストを実行してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173077/f7ae8728-10b4-292d-e003-24aface1561d.png)

左下のところにテストの実行結果が現れました！
テストは問題なく通過しているよう...
でも Test Runner が終了せず停止ボタンが表示されたままになってしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173077/153f387c-2502-8a50-c0de-19d44f492b5a.png)

それからしばらく放置すると以下のようなメッセージが表示されます。

```text
The process ReSharperTestRunner64:13532 has finished running tests assigned to it, but is still running.
Possible reasons are incorrect asynchronous code or lengthy test resource disposal.
If test cleanup is expected to be slow, please extend the wait timeout in Unit Testing options page.
```

Google 翻訳にかけると以下のようになります。

```text
プロセスReSharperTestRunner64：13532は、割り当てられたテストの実行を終了しましたが、まだ実行中です。
考えられる理由は、非同期コードが正しくないか、テストリソースの廃棄に時間がかかることです。
テストのクリーンアップが遅いと予想される場合は、ユニットテストオプションページで待機タイムアウトを延長してください。
```

ようするにテストは全て終了したけどリソースの解放ができていないからテストプロセスを終了できないよ！ということみたいです。

とりあえず `Kill` ボタンを押すとプロセスが終了するのでまたテストを実行できるようになります。
ですがテストを連続して実行するのがやりずらいし、気持ちが落ち着かないので原因を調査します。

## 原因の調査

Google で警告文章や ReSharper Test Runner hung などのキーワードで検索すると以下のような記事が見つかりました。

[ReSharper test runner still going after tests complete – The Blog of Colin Mackay](https://colinmackay.scot/2020/10/10/resharper-test-runner-still-going-after-tests-complete/)

この記事では `WebApplicationFactory<Startup>` で作成される `factory` の解放が問題だったようです。
ということで同じように解放していないリソースがないかコードを確認してみます。

テンプレートで作成されるソースは以下のようなものになります。
読みにくいのでコメントは削除しています。

こちらが Lambda のエントリーポイント。

```csharp:LambdaEntryPoint.cs
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace SampleProject
{
    public class LambdaEntryPoint : Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
    {
        protected override void Init(IWebHostBuilder builder)
        {
            builder
                .UseStartup<Startup>();
        }

        protected override void Init(IHostBuilder builder)
        {
        }
    }
}

```

そのエントリーポイントを使ったテストが以下。

```csharp:ValuesControllerTests.cs
using System.IO;
using System.Threading.Tasks;
using Xunit;
using Amazon.Lambda.TestUtilities;
using Amazon.Lambda.APIGatewayEvents;
using Newtonsoft.Json;

namespace SampleProject.Tests
{
    public class ValuesControllerTests
    {
        [Fact]
        public async Task TestGet()
        {
            var lambdaFunction = new LambdaEntryPoint();

            var requestStr = File.ReadAllText("./SampleRequests/ValuesController-Get.json");
            var request = JsonConvert.DeserializeObject<APIGatewayProxyRequest>(requestStr);
            var context = new TestLambdaContext();
            var response = await lambdaFunction.FunctionHandlerAsync(request, context);

            Assert.Equal(200, response.StatusCode);
            Assert.Equal("[\"value1\",\"value2\"]", response.Body);
            Assert.True(response.MultiValueHeaders.ContainsKey("Content-Type"));
            Assert.Equal("application/json; charset=utf-8", response.MultiValueHeaders["Content-Type"][0]);
        }
    }
}

```

`APIGatewayProxyFunction` を継承して `LambdaEntryPoint` を作成しています。
テストコード的には `LambdaEntryPoint` をインスタンス化してそのメソッドを呼んでいるだけのシンプルなものになっています。
そうなると怪しいのは `APIGatewayProxyFunction` の中になります。
そこで GitHub でソースを確認してみると中では ASP.NET Core の Host を起動していました。

https://github.com/aws/aws-lambda-dotnet/blob/c2be053a7a7791dda536feaea5330cd3391c4a8f/Libraries/src/Amazon.Lambda.AspNetCoreServer/AbstractAspNetCoreFunction.cs#L321

しかしこの起動した Host を解放する処理がどこにもない...
通常のマネージドなリソースであれば Dispose を忘れても GC が解放してくれるけれど、多分 ASP.NET Core の Host はアンマネージドなリソースを持っていて、明示的に呼び出さなければ解放されないリソースがあるのだと思います。
この辺りは詳しく調べてないのでちょっとよくわからないです。知っているかたがいればぜひ教えてください。🙏

## コードの修正とテストの再実行

おそらく原因はこの Host が解放されていないからではないかと推測できたので以下のようなコードにかきかえます。

```csharp:LambdaEntryPoint.cs
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;

namespace SampleProject
{
    public class LambdaEntryPoint : Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction, IDisposable
    {
        private IHost _webHost;
        protected override void Init(IWebHostBuilder builder)
        {
            builder
                .UseStartup<Startup>();
        }

        protected override void Init(IHostBuilder builder)
        {
        }

        // Host が作成された際に PostCreateHost が呼び出されるので field に Host を保存しておく
        protected override void PostCreateHost(IHost webHost)
        {
            _webHost = webHost;
            base.PostCreateHost(webHost);
        }

        // LambdaEntryPoint が不要になったタイミングで解放するために Dispose を実装する
        public void Dispose()
        {
            _webHost?.Dispose();
            _webHost = null;
        }
    }
}

```

```csharp:ValuesControllerTests.cs
using System.IO;
using System.Threading.Tasks;
using Xunit;
using Amazon.Lambda.TestUtilities;
using Amazon.Lambda.APIGatewayEvents;
using Newtonsoft.Json;

namespace SampleProject.Tests
{
    public class ValuesControllerTests
    {
        [Fact]
        public async Task TestGet()
        {
            // テストメソッドのスコープを外れたタイミングで Dispose して Host を解放する
            using var lambdaFunction = new LambdaEntryPoint();

            var requestStr = File.ReadAllText("./SampleRequests/ValuesController-Get.json");
            var request = JsonConvert.DeserializeObject<APIGatewayProxyRequest>(requestStr);
            var context = new TestLambdaContext();
            var response = await lambdaFunction.FunctionHandlerAsync(request, context);

            Assert.Equal(200, response.StatusCode);
            Assert.Equal("[\"value1\",\"value2\"]", response.Body);
            Assert.True(response.MultiValueHeaders.ContainsKey("Content-Type"));
            Assert.Equal("application/json; charset=utf-8", response.MultiValueHeaders["Content-Type"][0]);
        }
    }
}

```

やっていることは以下です。
Host が作成されたあと `APIGatewayProxyFunction` が継承している `AbstractAspNetCoreFunction` では `PostCreateHost` が呼ばれ、継承したクラスから Host を触ることができるようになります。
そこでこのメソッドを override し、いったん field に Host を保存します。
そして `LambdaEntryPoint` に `IDisposable` を継承させ `Dispose` の中で Host の解放を行います。
テストのほうでは new のところに using を付け、スコープを抜けたタイミングで `LambdaEntryPoint` を解放するようにしています。

そして再度テストを実行！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173077/1b075e74-e170-75ff-8547-6702f2ccfe9a.png)

無事にテストが終了し、今度はテストプロセスもきちんと解放されました！🎉🎊

# まとめ

この問題を踏むのはテストの中で ASP.NET Core の Host などリソースの解放をしなければならないものを解放せずに使い、かつテストランナーとして ReSharper Test Runner を使用している方だと思うので多分困るのはごく一部の人だと思います。
実際 C# での開発をするのであれば Visual Studio が第一候補になるでしょうし、最近なら VSCode でも十分強力な支援を得ながら開発をすることができ、この 2 つで開発を行う場合はテンプレートそのままのコードても特にテストプロセスが終了しない問題も発生しません。
でもきっとどこかでは似たような問題にぶつかって困ってしまっているかたもいるかもしれないので自分の備忘録もかねて記事にしておきます。

とりあえず教訓として **リソースの解放はきちんとする** と覚えておこうと思います。

最後に... ReSharper も Rider も便利なのでぜひ使ってみてください！

# 今回のサンプルコード

- https://github.com/uttne/dotnet-serverless-lambda-test-with-resharper-test-runner

# 参考

- https://github.com/aws/aws-lambda-dotnet
- [ReSharper test runner still going after tests complete – The Blog of Colin Mackay](https://colinmackay.scot/2020/10/10/resharper-test-runner-still-going-after-tests-complete/)