Bot Builder v4 でのテスト :  他サービスに対する認証のテスト

今回は他サービスに対する認証のユニットテストを見ていきます。 他サービスに対する認証については [Bot Builder v4 でボット開発 : 他サービスに対する認証](https://qiita.com/kenakamu/items/2c33a3c17103e8939360) を参照してください。

# ソリューションの準備
ボットのコードは [Bot Builder v4 でボット開発 : 他サービスに対する認証](https://qiita.com/kenakamu/items/2c33a3c17103e8939360) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article7 のコードをベースに、article9 ブランチのコードをマージしてテストを開発します。

1\. 任意のフォルダでレポジトリをクローン。

```shell
git clone https://github.com/kenakamu/botbuilderv4completeguide/
cd botbuilderv4completeguide
```

2\. 以下のコマンドで article9 をチェックアウトした後、test-article7 をチェックアウトしてどちらもローカルにコピー。

```shell
git checkout article9
git checkout test-article7
```

3\. 以下コマンドで test-article8 ブランチを作成。

```shell
git checkout -b test-article8
```

4\. article9 のブランチをマージ。

```shell
git merge article9
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e328b81f-15ad-eb0a-4fc9-5c97a38f71e6.png)

5\. 競合なくマージできるので、myfirstbot.sln を Visual Studio で開いてソリューションをビルド。

6\. 既存のテストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7015f82f-59f0-80e0-a5f2-38051f2dd723.png)

# OAuthPrompt を含むダイアログのテスト

OAuthPrompt のテストは以下の競合があるため困難です。

- [GitHub 上の OAuthPrompt.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.Dialogs/Prompts/OAuthPrompt.cs#L188) から分かるように、アダプターとして BotFrameworkAdapater を期待している
- [GitHub 上の TestFlow.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder/Adapters/TestFlow.cs#L63) から分かるように TestAdapter を期待してる

つまり TestFlow でテストする場合、TestAdapter が必須であり、TestAdapter は BotFrameworkAdapater の型を持っていないため OAuthPrompt を呼び出した時点でエラーで失敗します。実際にテスト結果見ると以下の様にエラーが出ています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/ec614ef8-f980-a758-9a52-4d5ea763da67.png)

また以下の課題もあります。

- OAuthPrompt のモック化はインターフェースや virtual メソッドがないのでできない
- Fake は dotnet core 2.2 では現時点でサポートされない

## メニューダイアログのユニットテスト

このような状況で対策はいくつかありますが、まずメニューダイアログの MenuDialog_ShouldGoToScheduleDialog テストは、「予定を確認」メニューを選択した際に予定ダイアログに遷移するかをテストすることが目的のため、「OAuthPrompt.GetUserToken(): not supported by the current adapter」が出れば良いとしてみます。

尚、この方法では他のダイアログも予定ダイアログと同じように OAuthPrompt をはじめに実行する場合、メニューダイアログの遷移が間違っていても検知できないという課題があります。

1\. MenuDialogUnitTest.cs の MenuDialog_ShouldGoToScheduleDialog メソッドを以下のコードに差し替え。

- ExpectedException 属性を使って期待されるエラーと内容を検証

```csharp
[TestMethod]
[ExpectedException(typeof(AggregateException), "OAuthPrompt.GetUserToken(): not supported by the current adapter")]

public async Task MenuDialog_ShouldGoToScheduleDialog()
{
    var arrange = ArrangeTest();
    await arrange.testFlow
    .Test("foo", "今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
    // 予定を確認を送った時点で OAuthPrompt.GetUserToken(): not supported by the current adapter エラーが出る
    .Test("予定を確認","dummy")
    .StartTestAsync();            
}
```

2\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/72e1bffb-1e93-d7d9-bc12-b6f5e7ea37e4.png)

3\. テストがカバーされている範囲も確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/2da08e12-7c5e-9bb7-7469-55809eff2313.png)

# 予定ダイアログのテスト

次に予定ダイアログですが、ここは全てのテストを通すため、OAuthPrompt をモック化する必要があります。しかし既に述べた理由から、Moq や Fake で対応することはできず、また元のコードを変えたくもないため、今回は以下のような実装で対応します。

- OAuthPrompt と同じような挙動をする TestOAuthPrompt を用意
- ScheduleDialog の DialogSet で、OAuthPrompt を TestOAuthPrompt に差し替え

ここで ScheduleDialog が継承している [ComponentDialog のソース](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.Dialogs/ComponentDialog.cs)を見ると、以下のことが分かります。

- AddDialog や FindDialog はあるが、RemoveDialog や ReplaceDialog はない
- FindDialog で返される dialog に対してあたしい Dialog をアサインしても元のインスタンスが変わることはない

上記の問題を解消するために、ComponentDialog に対して ReplaceDialog メソッドを追加する方法をとります。この際、_dialogs プロパティがパブリックではないため、リフレクションを使った実装を試してみます。

1\. ユニットテストプロジェクトに Helpers フォルダを追加。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/d9e05575-8659-aa36-ba8a-069327581613.png)

2\. Helpers フォルダに TestOAuthPrompt.cs ファイルを追加してコードを以下と差し替え。

- [GitHub の OAuthPrompt](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.Dialogs/Prompts/OAuthPrompt.cs) を参考
- 必要最小限のコードに書き換え

```csharp:TestOAuthPrompt.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest.Helpers
{
    public class TestOAuthPrompt : Dialog
    {
        private const string token = "dummyToken";
        // regex to check if code supplied is a 6 digit numerical code (hence, a magic code).
        private readonly Regex _magicCodeRegex = new Regex(@"(\d{6})");

        private OAuthPromptSettings _settings;
        private PromptValidator<TokenResponse> _validator;

        public TestOAuthPrompt(string dialogId, OAuthPromptSettings settings, PromptValidator<TokenResponse> validator = null)
            : base(dialogId)
        {
            _settings = settings ?? throw new ArgumentNullException(nameof(settings));
            _validator = validator;
        }

        public override async Task<DialogTurnResult> BeginDialogAsync(DialogContext dc, object options = null, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (dc == null)
            {
                throw new ArgumentNullException(nameof(dc));
            }

            PromptOptions opt = null;
            if (options != null)
            {
                if (options is PromptOptions)
                {
                    // Ensure prompts have input hint set
                    opt = options as PromptOptions;
                    if (opt.Prompt != null && string.IsNullOrEmpty(opt.Prompt.InputHint))
                    {
                        opt.Prompt.InputHint = InputHints.ExpectingInput;
                    }

                    if (opt.RetryPrompt != null && string.IsNullOrEmpty(opt.RetryPrompt.InputHint))
                    {
                        opt.RetryPrompt.InputHint = InputHints.ExpectingInput;
                    }
                }
                else
                {
                    throw new ArgumentException(nameof(options));
                }
            }

            // Attempt to get the users token
            var output = await GetUserTokenAsync(dc.Context, cancellationToken).ConfigureAwait(false);
            if (output != null)
            {
                // Return token
                return await dc.EndDialogAsync(output, cancellationToken).ConfigureAwait(false);
            }
            else
            {
                // Prompt user to login
                await SendOAuthCardAsync(dc.Context, opt?.Prompt, cancellationToken).ConfigureAwait(false);
                return Dialog.EndOfTurn;
            }
        }

        public override async Task<DialogTurnResult> ContinueDialogAsync(DialogContext dc, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (dc == null)
            {
                throw new ArgumentNullException(nameof(dc));
            }

            // Recognize token
            var recognized = await RecognizeTokenAsync(dc.Context, cancellationToken).ConfigureAwait(false);
            return await dc.EndDialogAsync(recognized.Value, cancellationToken).ConfigureAwait(false);
        }

        /// <summary>
        /// Get a token for a user signed in.
        /// </summary>
        /// <param name="turnContext">Context for the current turn of the conversation with the user.</param>
        /// <param name="cancellationToken">The cancellation token.</param>
        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
        public async Task<TokenResponse> GetUserTokenAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            return new TokenResponse() { Token = token };
        }

        /// <summary>
        /// Sign Out the User.
        /// </summary>
        /// <param name="turnContext">Context for the current turn of the conversation with the user.</param>
        /// <param name="cancellationToken">The cancellation token.</param>
        /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
        public async Task SignOutUserAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
        }

        private async Task SendOAuthCardAsync(ITurnContext turnContext, IMessageActivity prompt, CancellationToken cancellationToken = default(CancellationToken))
        {
            BotAssert.ContextNotNull(turnContext);

            if (!(turnContext.Adapter is TestAdapter adapter))
            {
                throw new InvalidOperationException("OAuthPrompt.Prompt(): not supported by the current adapter");
            }

            // Ensure prompt initialized
            if (prompt == null)
            {
                prompt = Activity.CreateMessageActivity();
            }

            if (prompt.Attachments == null)
            {
                prompt.Attachments = new List<Attachment>();
            }

            if (!prompt.Attachments.Any(a => a.Content is OAuthCard))
            {
                prompt.Attachments.Add(new Attachment
                {
                    ContentType = OAuthCard.ContentType,
                    Content = new OAuthCard
                    {
                        Text = _settings.Text,
                        ConnectionName = _settings.ConnectionName,
                        Buttons = new[]
                        {
                            new CardAction
                            {
                                Title = _settings.Title,
                                Text = _settings.Text,
                                Type = ActionTypes.Signin,
                            },
                        },
                    },
                });
            }

            // Set input hint
            if (string.IsNullOrEmpty(prompt.InputHint))
            {
                prompt.InputHint = InputHints.ExpectingInput;
            }

            await turnContext.SendActivityAsync(prompt, cancellationToken).ConfigureAwait(false);
        }

        private async Task<PromptRecognizerResult<TokenResponse>> RecognizeTokenAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            var result = new PromptRecognizerResult<TokenResponse>();
            if (IsTokenResponseEvent(turnContext))
            {
                var tokenResponseObject = turnContext.Activity.Value as JObject;
                var token = tokenResponseObject?.ToObject<TokenResponse>();
                result.Succeeded = true;
                result.Value = token;
            }

            return result;
        }

        private bool IsTokenResponseEvent(ITurnContext turnContext)
        {
            var activity = turnContext.Activity;
            return activity.Type == ActivityTypes.Event && activity.Name == "tokens/response";
        }

        private bool ChannelSupportsOAuthCard(string channelId)
        {            
            return true;
        }
    }
}
```

3\. 同じく Helpers フォルダに DialogComponentExtensions.cs を追加して以下のコードと差し替え。

- System.Reflection を使ってインスタンスのプライベートプロパティを取得
- 渡されたダイアログを同じ名前のダイアログを差し替え

```csharp:DialogComponentExtensions.cs
using Microsoft.Bot.Builder.Dialogs;
using System.Collections.Generic;
using System.Reflection;

namespace myfirstbot.unittest.Helpers
{
    public static class DialogComponentExtensions
    {
        public static void ReplaceDialog(this ComponentDialog componentDialog, Dialog dialog)
        {
            var field = typeof(ComponentDialog).GetField("_dialogs", BindingFlags.Instance | BindingFlags.NonPublic);
            var dialogSet = field.GetValue(componentDialog) as DialogSet;
            field = typeof(DialogSet).GetField("_dialogs", BindingFlags.Instance | BindingFlags.NonPublic);
            var dialogs = field.GetValue(dialogSet) as Dictionary<string, Dialog>;
            dialogs[dialog.Id] = dialog;
        }
    }
}
```
4\. ユニットテストプロジェクトに ScheduleDialogUnitTest.cs を追加し、以下のコードを貼り付け。

- 追加した拡張メソッドと TestOAuthPrompt を利用してテストを作成

```csharp: ScheduleDialogUnitTest.cs 
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using myfirstbot.unittest.Helpers;
using System.Threading.Tasks;

namespace myfirstbot.unittest
{
    [TestClass]
    public class ScheduleDialogUnitTest
    {       

        private TestFlow ArrangeTestFlow()
        {
            // ストレージとしてインメモリを利用
            IStorage dataStore = new MemoryStorage();
            // それぞれのステートを作成
            var conversationState = new ConversationState(dataStore);
            var userState = new UserState(dataStore);
            var accessors = new MyStateAccessors(userState, conversationState)
            {
                // DialogState を ConversationState のプロパティとして設定
                ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
                // UserProfile を作成
                UserProfile = userState.CreateProperty<UserProfile>("UserProfile")
            };
            
            // テスト対象のダイアログをインスタンス化
            var scheduleDialog = new ScheduleDialog();
            // テスト用のプロンプトに差し替え
            scheduleDialog.ReplaceDialog(new TestOAuthPrompt("login", new OAuthPromptSettings()));
            var dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(scheduleDialog);

            // アダプターを作成し必要なミドルウェアを追加
            var adapter = new TestAdapter()
                .Use(new SetLocaleMiddleware(Culture.Japanese))
                .Use(new AutoSaveStateMiddleware(userState, conversationState));

            // TestFlow の作成
            return new TestFlow(adapter, async (turnContext, cancellationToken) =>
            {
                // ダイアログに必要なコードだけ追加
                var dialogContext = await dialogs.CreateContextAsync(turnContext, cancellationToken);

                var results = await dialogContext.ContinueDialogAsync(cancellationToken);
                if (results.Status == DialogTurnStatus.Empty)
                {
                    await dialogContext.BeginDialogAsync(nameof(ScheduleDialog), null, cancellationToken);
                }
            });
        }

        [TestMethod]
        public async Task ScheduleDialog_ShouldReturnToken()
        {
            await ArrangeTestFlow()
            .Test("foo", "Token: dummyToken")
            .StartTestAsync();
        }
    }
}
```
5\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8ad48378-355a-f8f2-fb84-b88693a4b6f3.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/39352083-716a-ab88-c896-4f2b5fc60a50.png)

# まとめ

今回は BotBuilder の認証サービスをテストする方法を見ていきました。テストに最適化されたものではなかったため多少強引な手法を用いましたが、目的は果たしたので良しとします。

尚、OAuthPrompt 自体が正しく動作するかはユニットのスコープではないため、ファンクションテスト時などに確認します。

[次の記事へ](https://qiita.com/kenakamu/items/f1e3185648367eae75ce)
[目次に戻る](https://qiita.com/kenakamu/items/6dc043cfc1f199032883)

[この記事のサンプルコード](https://github.com/kenakamu/botbuilderv4completeguide/tree/test-article8)