Bot Builder v4 でのテスト : より高度なダイアログのテスト

今回は単一の高度なダイアログのユニットテストを試してみます。高度なダイアログについては
[Bot Builder v4 でボット開発 : より高度なダイアログ](https://qiita.com/kenakamu/items/4833b290bb9e03bd281c) を参照してください。

# ソリューションの準備

ボットのコードは [Bot Builder v4 でボット開発 : より高度なダイアログ](https://qiita.com/kenakamu/items/4833b290bb9e03bd281c) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した [test-article3](https://github.com/kenakamu/botbuilderv4completeguide/tree/test-article3) のコードをベースに、[article5](https://github.com/kenakamu/botbuilderv4completeguide/tree/article5) ブランチのコードをマージしてテストを開発します。

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

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

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

```shell
git checkout article5
git checkout test-article3
```

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

```shell
git checkout -b test-article4
```

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

```shell
git merge article5
```

5\. 以下の結果より既存ファイルは myfirstbot フォルダにマージされ、 Models/UserProfile.cs と ProfileDialog.cs はルートにコピーされたことを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/1753334f-e133-5db1-4e81-7d92b2c84497.png)

6\. ルートに作成されたものを myfirstbot に移動。

```shell
move Models myfirstbot
move ProfileDialog.cs myfirstbot
```
7\. myfirstbot.sln を Visual Studio で開き、ソリューションをビルド。ビルドに失敗することを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/4ffa8677-d66f-6d49-9cf2-9734add419d3.png)

今回のボットはステート管理で UserState も必要のため、以下でこの点も直しながらテストを実装していきます。

# ロケールの設定

既定で提供されるプロンプトは、ComfirmPrompt のように Activity のロケールによって応答が変わるものがあります。TestFlow を使ったテストでロケールを指定したい場合、いくつか方法があります。

- Activity を作って、TestFlow のメソッドに送る
- ミドルウェアを使って TurnContext.Activity のロケールを差し替える
- TestFlow と TestAdapter に拡張メソッドを追加してロケールに対応する

極力簡単にテストをしたいので、今回はミドルウェアを使った方法を利用してみます。

# テストの実装と実行

高度なダイアログのテストをする場合、以下の 2 通りの方法が考えられます。

- IBot のメインからダイアログを呼ぶところも含めてテスト
- ダイアログ単体でテスト

それぞれのやり方を見ていきます。

## IBot からテスト

まずは MyBot クラスから ProfileDialog を呼ぶ流れも含めてテストしてみます。

1\. MyBotUnitTest.cs ファイルの MyBot_ShouldAskName メソッドを以下のコードに差し替え。必要に応じて using を追加。

- アクセサーに UserState を渡し、UserProfile プロパティを作成。

```csharp
[TestMethod]
public async Task MyBot_ShouldSaveProfile()
{
    // アダプターを作成
    var adapter = new TestAdapter();
    // ストレージとしてインメモリを利用
    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 bot = new MyBot(accessors);
    // テスト用の変数
    var name = "Ken";
    var age = "42";

    // テストの追加と実行
    await new TestFlow(adapter, bot.OnTurnAsync)
        .Test("foo", "名前を入力してください。")
        .Test(name, "年齢を聞いてもいいですか？")
        .Test("はい", "年齢を入力してください。")
        .Test(age, $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age}")
        .Test("はい", "プロファイルを保存します。")
        .StartTestAsync();
}
```

2\. プロジェクトをビルドして、該当のテストを実行。以下の様に失敗することを確認。ここには 2 つ問題がる。

- ComfirmPrompt の選択肢が、応答文字列の一部として返ってくる
- 上記で説明した通り、Yes/No と、英語の返答が返ってきている
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/921de8d7-b0e9-28aa-37ed-02bb0e81f839.png)

3\. myfirstbot.unittest プロジェクトに SetLocaleMiddleware.cs を追加して以下コードを張り付け。

```csharp
using Microsoft.Bot.Builder;
using Microsoft.Recognizers.Text;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest
{
    public class SetLocaleMiddleware : IMiddleware
    {
        private string locale = string.Empty;

        public SetLocaleMiddleware(string locale)
        {
            // 指定したロケールがサポートされたものであれば設定保持。違う場合は en-us を設定。
            if (Culture.SupportedCultures.Where(x => x.CultureName == locale) != null)
            {
                this.locale = locale;
            }
            else
            {
                this.locale = Culture.English;
            }
        }

        public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default(CancellationToken))
        {
            turnContext.Activity.Locale = locale;
            await next.Invoke(cancellationToken);
        }
    }
}
```

4\. MyBot_ShouldSaveProfile テストを以下の様に変更。必要に応じて using を追加。

- アダプターにミドルウェアを追加

```csharp
[TestMethod]
public async Task MyBot_ShouldSaveProfile()
{
    // アダプターを作成
    var adapter = new TestAdapter();
    adapter.Use(new SetLocaleMiddleware(Culture.Japanese));
    // ストレージとしてインメモリを利用
    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 bot = new MyBot(accessors);
    // テスト用の変数
    var name = "Ken";
    var age = "42";

    // テストの追加と実行
    await new TestFlow(adapter, bot.OnTurnAsync)
        .Test("foo", "名前を入力してください。")
        .Test(name, "年齢を聞いてもいいですか？ (1) はい または (2) いいえ")
        .Test("はい", "年齢を入力してください。")
        .Test(age, $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい または (2) いいえ")
        .Test("はい", "プロファイルを保存します。")
        .StartTestAsync();
}
```

5\. 再度テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/02fe7639-1bff-e630-1e5f-b72ee7f8c89f.png)

6\. 他のパターンのテストも追加。

```csharp
[TestMethod]
public async Task MyBot_ShouldNotSaveProfile()
{
    // アダプターを作成
    var adapter = new TestAdapter();
    adapter.Use(new SetLocaleMiddleware(Culture.Japanese));
    // ストレージとしてインメモリを利用
    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 bot = new MyBot(accessors);
    // テスト用の変数
    var name = "Ken";
    var age = "42";

    // テストの追加と実行
    await new TestFlow(adapter, bot.OnTurnAsync)
        .Test("foo", "名前を入力してください。")
        .Test(name, "年齢を聞いてもいいですか？ (1) はい または (2) いいえ")
        .Test("はい", "年齢を入力してください。")
        .Test(age, $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい または (2) いいえ")
        .Test("いいえ", "プロファイルを破棄します。")
        .StartTestAsync();
}

[TestMethod]
public async Task MyBot_ShouldSaveProfileWithoutAge()
{
    // アダプターを作成
    var adapter = new TestAdapter();
    adapter.Use(new SetLocaleMiddleware(Culture.Japanese));
    // ストレージとしてインメモリを利用
    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 bot = new MyBot(accessors);
    // テスト用の変数
    var name = "Ken";
    var age = 0;

    // テストの追加と実行
    await new TestFlow(adapter, bot.OnTurnAsync)
        .Test("foo", "名前を入力してください。")
        .Test(name, "年齢を聞いてもいいですか？ (1) はい または (2) いいえ")
        .Test("いいえ", $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい または (2) いいえ")
        .Test("はい", "プロファイルを保存します。")
        .StartTestAsync();
}
```

7\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/4a3207b4-3b24-17e8-3d67-fe6a3fcae549.png)

# ダイアログ単体でテスト

次にダイアログ単体でテストする方法を見ていきます。ダイアログ単体でテストする場合は、MyBot.cs で作っているようなダイアログ呼び出しロジックをテスト側で作るとともに、ステートの保存も AutoSaveStateMiddleware を使って実行します。

1\. MyBotUnitTest.cs に以下のメソッドを追加。

- TestAdapter で AutoSaveStateMiddleware を指定
- AutoSaveStateMiddleware で保存したい BotState を引き渡し
- TestFlow のコールバックにダイアログを起動するだけのコードを追加

```csharp
[TestMethod]
public async Task ProfileDialog_ShouldSaveProfile()
{
    // ストレージとしてインメモリを利用
    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 dialogs = new DialogSet(accessors.ConversationDialogState);
    dialogs.Add(new ProfileDialog(accessors));

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

    // テスト用の変数
    var name = "Ken";
    var age = "42";

    // テストの追加と実行
    await 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(ProfileDialog), null, cancellationToken);
        }
    })
    .Test("foo", "名前を入力してください。")
    .Test(name, "年齢を聞いてもいいですか？ (1) はい または (2) いいえ")
    .Test("はい", "年齢を入力してください。")
    .Test(age, $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい または (2) いいえ")
    .Test("はい", "プロファイルを保存します。")
    .StartTestAsync();
}
```

2\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/1ba1deb9-6fd9-cdb8-03e9-3fdb2b87017c.png)

3\. 同様に残りのパターンも追加。

```csharp
[TestMethod]
public async Task ProfileDialog_ShouldNotSaveProfile()
{
    // ストレージとしてインメモリを利用
    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 dialogs = new DialogSet(accessors.ConversationDialogState);
    dialogs.Add(new ProfileDialog(accessors));

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

    // テスト用の変数
    var name = "Ken";
    var age = "42";

    // テストの追加と実行
    await 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(ProfileDialog), null, cancellationToken);
        }
    })
    .Test("foo", "名前を入力してください。")
    .Test(name, "年齢を聞いてもいいですか？ (1) はい または (2) いいえ")
    .Test("はい", "年齢を入力してください。")
    .Test(age, $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい または (2) いいえ")
    .Test("いいえ", "プロファイルを破棄します。")
    .StartTestAsync();
}

[TestMethod]
public async Task ProfileDialog_ShouldSaveProfileWithoutAge()
{
    // ストレージとしてインメモリを利用
    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 dialogs = new DialogSet(accessors.ConversationDialogState);
    dialogs.Add(new ProfileDialog(accessors));

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

    // テスト用の変数
    var name = "Ken";
    var age = 0;

    // テストの追加と実行
    await 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(ProfileDialog), null, cancellationToken);
        }
    })
    .Test("foo", "名前を入力してください。")
    .Test(name, "年齢を聞いてもいいですか？ (1) はい または (2) いいえ")
    .Test("いいえ", $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい または (2) いいえ")
    .Test("はい", "プロファイルを保存します。")
    .StartTestAsync();
}
```

4\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/12cf92c4-d42c-c25e-a360-84b445c8f2f9.png)

# まとめ

今回はロケールの設定方法やダイアログのテスト方法を数種類紹介しましたが、基本はダイアログ単位でテストが行えるよう実装してください。次回はさらに複数のダイアログがある場合のテストを見ていきます。

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

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