Bot Builder v4 でのテスト : LUIS を使った自然言語処理のテスト

今回は LUIS を使った自然言語処理のユニットテストを見ていきます。 LUIS を使った自然言語処理については [Bot Builder v4 でボット開発 : LUIS を使った自然言語処理](https://qiita.com/kenakamu/items/f580ab6a8a0f10e94edb) を参照してください。

# ソリューションの準備

ボットのコードは [Bot Builder v4 でボット開発 : LUIS を使った自然言語処理](https://qiita.com/kenakamu/items/f580ab6a8a0f10e94edb) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article6 のコードをベースに、article8 ブランチのコードをマージしてテストを開発します。

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

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

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

```shell
git checkout article8
git checkout test-article6
```

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

```shell
git checkout -b test-article7
```

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

```shell
git merge article8
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/89d436ce-5f80-0363-c015-21376d369d96.png)

5\. 競合なくマージできるので、myfirstbot.sln を Visual Studio で開いてソリューションをビルド。MyBot クラスのコンストラクタで IRecognizer が必要なためエラーなることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/bf8974db-a9b6-0bed-79d5-7d8ec91b58ed.png)

# 自然言語処理 LUIS のユニットテスト

LUIS もステート管理サービスのように Moq ライブラリを使ってモック化していきます。ユーザーからの入力の値によって返す結果を変更します。また MyBot で LUIS を使った分岐があるため、それぞれのテストを追加、改修します。

1\. MyBotUnitTest.cs の ArrangeTest メソッドを以下のコードで差し替え。

- ステート管理に使うストレージの変数を mockStorage に変更
- IRecognizer をモック化
- 受け取ったインプットによって返す結果を変更
- Intent だけでなく、必要に応じて Entity も返す

```csharp
private (TestFlow testFlow, BotAdapter adapter, DialogSet dialogs) ArrangeTest(bool returnUserProfile)
{
    // アダプターを作成
    var adapter = new TestAdapter();
    adapter.Use(new SetLocaleMiddleware(Culture.Japanese));
    // ストレージとしてモックのストレージを利用
    var mockStorage = new Mock<IStorage>();
    // User1用に返すデータを作成
    // UserState のキーは <channelId>/users/<userId>
    var dictionary = new Dictionary<string, object>();
    if (returnUserProfile)
    {
        dictionary.Add("test/users/user1", new Dictionary<string, object>()
        {
            { "UserProfile", new UserProfile() { Name = name, Age = 0 } }
        });
    }
    // ストレージへの読み書きを設定
    mockStorage.Setup(ms => ms.WriteAsync(It.IsAny<Dictionary<string, object>>(), It.IsAny<CancellationToken>()))
        .Returns((Dictionary<string, object> dic, CancellationToken token) =>
        {
            foreach (var dicItem in dic)
            {
                if (dicItem.Key != "test/users/user1")
                {
                    if (dictionary.ContainsKey(dicItem.Key))
                    {
                        dictionary[dicItem.Key] = dicItem.Value;
                    }
                    else
                    {
                        dictionary.Add(dicItem.Key, dicItem.Value);
                    }
                }
            }

            return Task.CompletedTask;
        });
    mockStorage.Setup(ms => ms.ReadAsync(It.IsAny<string[]>(), It.IsAny<CancellationToken>()))
        .Returns(() =>
        {
            return Task.FromResult(result: (IDictionary<string, object>)dictionary);
        });

    // それぞれのステートを作成
    var conversationState = new ConversationState(mockStorage.Object);
    var userState = new UserState(mockStorage.Object);
    var accessors = new MyStateAccessors(userState, conversationState)
    {
        // DialogState を ConversationState のプロパティとして設定
        ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
        // UserProfile を作成
        UserProfile = userState.CreateProperty<UserProfile>("UserProfile")
    };

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

    // IRecognizer のモック化
    var mockRecognizer = new Mock<IRecognizer>();
    mockRecognizer.Setup(l => l.RecognizeAsync(It.IsAny<TurnContext>(), It.IsAny<CancellationToken>()))
        .Returns((TurnContext turnContext, CancellationToken cancellationToken) => 
        {
            // RecognizerResult の作成
            var recognizerResult = new RecognizerResult()
            {
                    Intents = new Dictionary<string, IntentScore>(),
                    Entities = new JObject()
            };

            switch(turnContext.Activity.Text)
            {
                case "キャンセル":
                    recognizerResult.Intents.Add("Cancel", new IntentScore() { Score = 1 });
                    break;
                case "天気を確認":
                    recognizerResult.Intents.Add("Weather", new IntentScore() { Score = 1 });                            
                    break;
                case "今日の天気を確認":
                    recognizerResult.Intents.Add("Weather", new IntentScore() { Score = 1 });
                    recognizerResult.Entities.Add("day", JArray.Parse("[['今日']]"));
                    break;
                case "ヘルプ":
                    recognizerResult.Intents.Add("Help", new IntentScore() { Score = 1 });
                    break;
                case "プロファイルの変更":
                    recognizerResult.Intents.Add("Profile", new IntentScore() { Score = 1 });
                    break;
                default:
                    recognizerResult.Intents.Add("None", new IntentScore() { Score = 1 });
                    break;
            }
            return Task.FromResult(recognizerResult);
        });
    // テスト対象のクラスをインスタンス化
    var bot = new MyBot(accessors, mockRecognizer.Object);

    // TestFlow の作成
    var testFlow = new TestFlow(adapter, bot.OnTurnAsync);
    return (testFlow, adapter, dialogs);
}
```

2\. MyBot_GlobalCommand_ShouldCancelAllDialog メソッドを以下の様に変更。

- 一旦 WeatherDialog を呼び出してからキャンセル
- ボットから戻る文字列ではなく、AssertReply でスタックを確認して検証

```csharp
[TestMethod]
public async Task MyBot_GlobalCommand_ShouldCancelAllDialog()
{
    var arrange = ArrangeTest(true);

    // テストの追加と実行
    await arrange.testFlow
        .Test("foo", $"ようこそ '{name}' さん！")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が MenuDialog の choice であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "choice");
        })
        .Send("天気を確認")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が WeatherDialog の choice であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(WeatherDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "choice");
        })
        .Test("キャンセル", "キャンセルします")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が MenuDialog の choice であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "choice");
        })
        .StartTestAsync();
}
```

3\. MyBot_GlobalCommand_ShouldGoToProfileDialog メソッドを以下の様に変更。

- 一旦 WeatherDialog を呼び出してからプロファイルダイアログを呼び出し
- ボットから戻る文字列ではなく、AssertReply でスタックを確認して検証

```csharp
[TestMethod]
public async Task MyBot_GlobalCommand_ShouldGoToProfileDialog()
{
    var arrange = ArrangeTest(true);

    // テストの追加と実行
    await arrange.testFlow
        .Test("foo", $"ようこそ '{name}' さん！")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が MenuDialog の choice であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "choice");
        })
        .Send("天気を確認")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が WeatherDialog の choice であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(WeatherDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "choice");
        })
        .Send("プロファイルの変更")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が ProfileDialog の name であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(ProfileDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "name");
        })
        .StartTestAsync();
}
```

4\. 天気ダイアログに遷移するテストとして以下 2 つのテストメソッドを追加。

```csharp
[TestMethod]
public async Task MyBot_ShouldGoToWeatherDialog()
{
    var arrange = ArrangeTest(false);
           
    // テストの追加と実行
    await arrange.testFlow
        .Send("天気を確認")
        .AssertReply((activity) =>
        {
            // Activity とアダプターからコンテキストを作成
            var turnContext = new TurnContext(arrange.adapter, activity as Activity);
            // ダイアログコンテキストを取得
            var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
            // 現在のダイアログスタックの一番上が WeatherDialog の choice であることを確認。
            var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(WeatherDialog)).First().State["dialogs"] as DialogState).DialogStack;
            Assert.AreEqual(dialogInstances[0].Id, "choice");
        })
        .StartTestAsync();
}

[TestMethod]
public async Task MyBot_ShouldGoToWeatherDialogWithEntityResult()
{
    var arrange = ArrangeTest(false);

    // テストの追加と実行
    await arrange.testFlow
        .Test("今日の天気を確認", "今日の天気は晴れです")
        .StartTestAsync();
}
```

5\. ヘルプを返すテストを追加。

```csharp
[TestMethod]
public async Task MyBot_ShouldGoToHelpDialog()
{
    var arrange = ArrangeTest(false);

    // テストの追加と実行
    await arrange.testFlow
        .Test("ヘルプ", "天気と予定が確認できます。")
        .StartTestAsync();
}
```

6\. 全てのテストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f47af620-70d7-a82e-42de-e4f9072f8331.png)

# ライブユニットテスト

コードが増えてきた場合、どのコードがテストにカバーされているか分かりずらくなりますが、Visual Studio エンタープライズエディションを使っている場合は、ライブユニットテストを使うことでより容易に状況が確認できます。

1\. テストメニュー | Live Unit Testing | 開始をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a95e9c8b-6b16-97b6-fe22-7133baa5a2d3.png)

2\. コードの左側にユニットテストの実行結果がリアルタイムに表示される。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/935005f1-8e61-06bd-a9d3-e831f2747ff3.png)

# まとめ

自然言語処理のユニットテストも通常のサービスと同じように行えます。実際のユーザーインプットがどのインテントに解析されるかという点を気にされる開発者も多いと思いますが、そのテストは完全に分離して行い、ボットの中では正しいインテントが返ってくる前提でテストしてください。(Separation of Concern)

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

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