Bot Builder v4 でのテスト : QnA Maker ボットのユニットテスト

今回は QnA Maker サービスを使ったボットのユニットテストを見ていきます。QnA Maker の利用については [Bot Builder v4 でボット開発 : QnA Maker で簡単に QA ボットを作る](https://qiita.com/kenakamu/items/8e0195b671d629c479c2) を参照してください。

# ソリューションの準備
ボットのコードは [Bot Builder v4 でボット開発 : QnA Maker で簡単に QA ボットを作る](https://qiita.com/kenakamu/items/8e0195b671d629c479c2) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article15 のコードをベースに、article18 ブランチのコードをマージしてテストを開発します。

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

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

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

```shell
git checkout article18
git checkout test-article15
```

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

```shell
git checkout -b test-article16
```

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

```shell
git merge article18
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/ae1902dc-c007-1126-17f7-69ea7d6ea812.png)

5\. マージの競合があるため、以下コマンドで競合を確認。

```shell
git mergetool
```

6\. マージ対象のファイルである Startup.cs では両方の変更を選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/cc2902c7-60fe-3949-29fe-8712b265c24f.png)

7\. Resources フォルダと Dialogs フォルダを myfirstbot フォルダに移動後、ソリューションを Visual Studio で開いてビルド実行。ビルドが成功することを確認。

# QnA メーカーのユニットテスト

QnA メーカーも外部サービスのため、これまで同様サービスをモックして対応が可能です。Microsoft.Bot.Builder.AI.QnA 4.2.0 の時点では QnAMaker クラスはインターフェースを継承していません。

## 最新の NuGet とコードの確認

最新ではどうなっているかと、公開されいる NuGet パッケージでどうなっているかをそれぞれ確認して、モック化の方針を決めます。

1\. [GitHub master ブランチの QnAMaker.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.AI.QnA/QnAMaker.cs) を確認すると、QnAMaker は ITelemetryQnAMaker インターフェースを継承しており、[ITelemetryQnAMaker.cs のコード](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.AI.QnA/ITelemetryQnAMaker.cs) を見ると GetAnswersAsync メソッドがあるためモック化が容易に思える。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a31b05f5-6f01-56c7-bb55-ac1e6294b5c3.png)

2\. ソリューションを右クリックして「ソリューションの NuGet パッケージの管理」をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/1179d6da-e950-ae30-94a4-7dc588a9f761.png)

3\. 更新プログラムをクリックし、Microsoft.Bot 関連のパッケージを更新。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f43ecfa0-2760-fcc8-18ca-2d39e8fae058.png)

4\. QnAMaker の定義を見るとインターフェースを継承していない。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/fdd61b0d-fa39-f525-3226-41749de19b56.png)

以上の事より将来的にはインターフェースを使ったモック化が簡単に行えそうだが、現時点では無理であることが分かりました。残念。

## 現状の実装でモック化を検討

今回は NuGet パッケージを使ったままモック化することにします。
[バージョン 4.3 の QnAMaker.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/4.3/libraries/Microsoft.Bot.Builder.AI.QnA/QnAMaker.cs) を見ると、コンストラクタで HttpClient を渡せることが分かります。また HttpClient は引数に HttpMessageHandler の派生クラスを渡せるので、そこでモック化を行います。

1\. [GetAnswersAsync メソッド](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder.AI.QnA/QnAMaker.cs#L93) で ```var result = await QueryQnaServiceAsync((Activity)messageActivity, hydratedOptions).ConfigureAwait(false);``` メソッドを呼び出していることを確認。

2\. [QueryQnAServiceAsync メソッド](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder.AI.QnA/QnAMaker.cs#L193) で HttpClient の SendAsync を呼び出し、 ```var result = await FormatQnaResultAsync(response, options).ConfigureAwait(false);``` にて応答を整形していることを確認。

3\. [FormatQnaResultAsync メソッド](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder.AI.QnA/QnAMaker.cs#L245) を見ると InternalQueryResults 型の結果を期待していることが判明。

上記の情報を使ってモックしてみましょう。

## QnA メーカーのテストを追加

1\. ユニットテストソリューションに QnADialogUnitTest.cs を追加し、以下の using を追加。

```csharp
using CognitiveServices.Translator;
using CognitiveServices.Translator.Translate;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.AI.QnA;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Localization;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using myfirstbot.unittest.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
```
2\. QnA サービスのモック部分となる MockQnAMakerHandler クラスを名前空間配下に追加。

- HttpMessageHandler を継承して作成
- InternalQueryResult と InternalQueryResults を改めて定義
- 答えがある場合とない場合で結果を作成

```csharp
public class MockQnAMakerHandler : HttpMessageHandler
{
    private class InternalQueryResult : QueryResult
    {
        [JsonProperty(PropertyName = "qnaId")]
        public int QnaId { get; set; }
    }

    private class InternalQueryResults
    {
        /// <summary>
        /// Gets or sets the answers for a user query,
        /// sorted in decreasing order of ranking score.
        /// </summary>
        [JsonProperty("answers")]
        public InternalQueryResult[] Answers { get; set; }
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var question = JObject.Parse(await request.Content.ReadAsStringAsync())["question"].ToString();
        var queryResults = new List<InternalQueryResult>();
        if (question == "質問")
        {
            queryResults.Add(new InternalQueryResult()
            {
                Answer = "答え",
                Score = 100,
            });
        }
        else
        {
            queryResults.Add(new InternalQueryResult()
            {
                Answer = "答えなし",
                Score = 1,
            });
        }
        var results = new InternalQueryResults()
        {
            Answers = queryResults.ToArray()
        };
            
        var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(JsonConvert.SerializeObject(results), Encoding.UTF8, "application/json")
        };

        return await Task.FromResult(responseMessage);
    }
}
```

3\. テストのセットアップ部分を QnADialogUnitTest クラスに追加。

```csharp
private (TestFlow testFlow, BotAdapter adapter, DialogSet dialogs, StringLocalizer<QnADialog> localizer) ArrangeTest(string language)
{
    var accessors = AccessorsFactory.GetAccessors(language);

    // リソースを利用するため StringLocalizer を作成
    var localizer = StringLocalizerFactory.GetStringLocalizer<QnADialog>();
                     
    // 翻訳サービスのモック化
    var mockTranslateClient = new Mock<ITranslateClient>();
    mockTranslateClient.Setup(l => l.TranslateAsync(It.IsAny<RequestContent>(), It.IsAny<RequestParameter>()))
        .Returns((RequestContent requestContent, RequestParameter requestParameter) =>
        {
            var response = new List<ResponseBody>();
            switch (requestContent.Text)
            {
                case "Question":
                    response.Add(new ResponseBody() { Translations = new List<Translations>() { new Translations() { Text = "質問" } } });
                    break;
                case "答え":
                    response.Add(new ResponseBody() { Translations = new List<Translations>() { new Translations() { Text = "Answer" } } });
                    break;                        
                default:
                    response.Add(new ResponseBody() { Translations = new List<Translations>() { new Translations() { Text = "foo" } } });
                    break;
            }
            return Task.FromResult(response as IList<ResponseBody>);
        });

    // QnA サービスのモック化            
    var qnaEndpoint = new QnAMakerEndpoint()
    {
        KnowledgeBaseId = "dummyId",
        EndpointKey = "dummyKey",
        Host = "https://dummyhost.test/qna",
    };
    var qnaMaker = new QnAMaker(qnaEndpoint, httpClient:new HttpClient(new MockQnAMakerHandler()));
    // テスト対象のダイアログをインスタンス化
    var dialogs = new DialogSet(accessors.ConversationDialogState);
    dialogs.Add(new QnADialog(accessors, qnaMaker, mockTranslateClient.Object, localizer));

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

    // TestFlow の作成
    var testFlow = 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(QnADialog), null, cancellationToken);
        }
    });

    return (testFlow, adapter, dialogs, localizer);
}
```

4\. 答えがある場合とない場合のテストを追加。

```csharp
[TestMethod]
[DataRow("ja-JP")]
[DataRow("en-US")]
public async Task QnADialog_ShouldReturnAnswer(string language)
{
    // 言語を指定してテストを作成
    var arrange = ArrangeTest(language);
    Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
    Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

    // テストの追加と実行
    await arrange.testFlow
    .Send("foo")
    .AssertReply((activity) =>
    {
               
    })
    .Send(language == "ja-JP" ? "質問" : "Question")
    .AssertReply((activity) =>
    {
        Assert.AreEqual((activity as Activity).Text, language == "ja-JP" ? "答え" : "Answer");
    })
    .StartTestAsync();
}
[TestMethod]
[DataRow("ja-JP")]
[DataRow("en-US")]
public async Task QnADialog_ShouldReturnNoAnswer(string language)
{
    // 言語を指定してテストを作成
    var arrange = ArrangeTest(language);
    Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
    Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

    // テストの追加と実行
    await arrange.testFlow
    .Send("foo")
    .AssertReply((activity) =>
    {

    })
    .Send(language == "ja-JP" ? "Foo" : "Foo")
    .AssertReply((activity) =>
    {
        Assert.AreEqual((activity as Activity).Text,arrange.localizer["noanswer"]);
    })
    .StartTestAsync();
}
```

5\. ソリューションをビルドしてテストを実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/9b5a3eba-5f76-f8b2-e17a-91bcd0a6b390.png)

# その他のテストを追加

QnA ダイアログが増えたことで影響を受けるテストを更新します。

1\. MenuDialogUnitTest.cs のコードを以下のものと差し替え。

```csharp:MenuDialogUnitTest.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Localization;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using myfirstbot.unittest.Helpers;
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest
{
    [TestClass]
    public class MenuDialogUnitTest
    {
        private (TestFlow testFlow, BotAdapter adapter, DialogSet dialogs, StringLocalizer<MenuDialog> localizer) ArrangeTest(string language)
        {
            var accessors = AccessorsFactory.GetAccessors(language);

            // リソースを利用するため StringLocalizer を作成
            var localizer = StringLocalizerFactory.GetStringLocalizer<MenuDialog>();

            // IServiceProvider のモック
            var serviceProvider = new Mock<IServiceProvider>();

            // MenuDialog クラスで解決すべきサービスを登録
            serviceProvider.Setup(x => x.GetService(typeof(LoginDialog))).Returns(new LoginDialog(StringLocalizerFactory.GetStringLocalizer<LoginDialog>()));
            serviceProvider.Setup(x => x.GetService(typeof(WeatherDialog))).Returns(new WeatherDialog(accessors, StringLocalizerFactory.GetStringLocalizer<WeatherDialog>()));
            serviceProvider.Setup(x => x.GetService(typeof(ScheduleDialog))).Returns(new ScheduleDialog(serviceProvider.Object, StringLocalizerFactory.GetStringLocalizer<ScheduleDialog>()));
            serviceProvider.Setup(x => x.GetService(typeof(QnADialog))).Returns(new QnADialog(accessors,null, null, StringLocalizerFactory.GetStringLocalizer<QnADialog>()));

            // テスト対象のダイアログをインスタンス化
            var dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(new MenuDialog(serviceProvider.Object, localizer));

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

            // TestFlow の作成
            var testFlow = 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(MenuDialog), null, cancellationToken);
                }
            });

            return (testFlow, adapter, dialogs, localizer);
        }

        [TestMethod]
        [DataRow("ja-JP")]
        [DataRow("en-US")]
        public async Task MenuDialog_ShouldGoToWeatherDialog(string language)
        {
            // 言語を指定してテストを作成
            var arrange = ArrangeTest(language);
            Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

            // テストの追加と実行
            await arrange.testFlow
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["choicemenu"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkweather"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkschedule"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkqa"]) >= 0);
            })
            .Send(arrange.localizer["checkweather"])
            .AssertReply((activity) =>
            {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が WeatherDialog で その下が MenuDialog であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
                Assert.AreEqual(dialogInstances[0].Id, nameof(WeatherDialog));
            })
            .StartTestAsync();
        }

        [TestMethod]
        [DataRow("ja-JP")]
        [DataRow("en-US")]
        [ExpectedException(typeof(System.Exception), "OAuthPrompt.GetUserToken(): not supported by the current adapter")]

        public async Task MenuDialog_ShouldGoToScheduleDialog(string language)
        {
            // 言語を指定してテストを作成
            var arrange = ArrangeTest(language);
            Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

            await arrange.testFlow
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["choicemenu"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkweather"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkschedule"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkqa"]) >= 0);
            })
            // 予定を確認を送った時点で OAuthPrompt.GetUserToken(): not supported by the current adapter エラーが出る
            .Test(arrange.localizer["checkschedule"], "dummy")
            .StartTestAsync();
        }

        [TestMethod]
        [DataRow("ja-JP")]
        [DataRow("en-US")]
        public async Task MenuDialog_ShouldGoToQnADialog(string language)
        {
            // 言語を指定してテストを作成
            var arrange = ArrangeTest(language);
            Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

            // テストの追加と実行
            await arrange.testFlow
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["choicemenu"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkweather"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkschedule"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["checkqa"]) >= 0);
            })
            .Send(arrange.localizer["checkqa"])
            .AssertReply((activity) =>
            {
                // Activity とアダプターからコンテキストを作成
                var turnContext = new TurnContext(arrange.adapter, activity as Activity);
                // ダイアログコンテキストを取得
                var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
                // 現在のダイアログスタックの一番上が QnADialog であることを確認。
                var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
                Assert.AreEqual(dialogInstances[0].Id, nameof(QnADialog));
            })
            .StartTestAsync();
        }
    }
}
```

2\. MyBotUnitTest.cs の testFlow メソッド内、```// MyBot クラスで解決すべきサービスを登録``` に QnADialog をサービスに登録。

- MenuDialog で QnADialog を利用するため、MenuDialog より前に登録
 
```csharp
// MyBot クラスで解決すべきサービスを登録
serviceProvider.Setup(x => x.GetService(typeof(LoginDialog))).Returns(new LoginDialog(StringLocalizerFactory.GetStringLocalizer<LoginDialog>()));
serviceProvider.Setup(x => x.GetService(typeof(WeatherDialog))).Returns(new WeatherDialog(accessors, StringLocalizerFactory.GetStringLocalizer<WeatherDialog>()));
serviceProvider.Setup(x => x.GetService(typeof(ProfileDialog))).Returns(new ProfileDialog(accessors, StringLocalizerFactory.GetStringLocalizer<ProfileDialog>()));
serviceProvider.Setup(x => x.GetService(typeof(SelectLanguageDialog))).Returns(new SelectLanguageDialog(accessors));
serviceProvider.Setup(x => x.GetService(typeof(WelcomeDialog))).Returns
    (new WelcomeDialog(accessors, null, serviceProvider.Object));
serviceProvider.Setup(x => x.GetService(typeof(ScheduleDialog))).Returns(new ScheduleDialog(serviceProvider.Object, StringLocalizerFactory.GetStringLocalizer<ScheduleDialog>()));
serviceProvider.Setup(x => x.GetService(typeof(QnADialog))).Returns(new QnADialog(accessors, null, null, StringLocalizerFactory.GetStringLocalizer<QnADialog>()));
serviceProvider.Setup(x => x.GetService(typeof(MenuDialog))).Returns(new MenuDialog(serviceProvider.Object, StringLocalizerFactory.GetStringLocalizer<MenuDialog>()));
```

3\. ソリューションをコンパイル後、全てのテストを実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/0b6541d3-2e12-2db4-33f5-f6321b0606b0.png)

# まとめ

今回のモック化は少し遠回りになりましたが、今後インターフェース化されたクラスが出てきた場合はより容易にモック化できると思います。サービスはかならずインターフェース継承して欲しいですね。

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

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