Bot Builder v4 でのテスト : 複数ダイアログのテスト、ステート管理のモック化

今回は複数のダイアログを持つボットのユニットテストを見ていきます。複数ダイアログを持つについては [Bot Builder v4 でボット開発 : 複数ダイアログの管理](https://qiita.com/kenakamu/items/3fbfac20a1954164e8fd) を参照してください。またその中でステート管理もモック化します。

# ソリューションの準備

ボットのコードは [Bot Builder v4 でボット開発 : 複数ダイアログの管理](https://qiita.com/kenakamu/items/3fbfac20a1954164e8fd) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article4 のコードをベースに、article6 ブランチのコードをマージしてテストを開発します。

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

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

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

```shell
git checkout article6
git checkout test-article4
```

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

```shell
git checkout -b test-article5
```

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

```shell
git merge article6
```
5\. マージの競合あるというメッセージを確認。メッセージからは ProfileDialog.cs が現在のブランチでは myfirstbot フォルダにあるが、article6 では Dialogs フォルダにあるとなっている。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/914e3f2f-fabf-04f3-fcdc-cea7ced0fe6b.png)

6\. 以下コマンドでマージを続行。

```shell
git mergetool
```

7\. Dialogs/ProfileDialog.cs について、local にあるバージョンと remote のバージョンを選択するよう聞かれるので、最新の更新を取得するよう、remote (created) を選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7dab6189-b23e-db68-83ad-0c9eb4b2476f.png)

8\. ProfileDialog.cs についても聞かれるが、これはどちらのバージョンも削除済のため、ここでは remote (deleted) を選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/5c53b5f5-05ab-cd83-2970-730685eef3f9.png)

9\. myfirstbot\ProfileDialog.cs について聞かれるが前回のものは不要のため、remote (deleted) を選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/aee5bdf2-c95f-1fa4-11fd-b078184afcb3.png)

10\. 以下のコマンドでルートにできたものを myfirstbot に移動。

```shell
move Dialogs myfirstbot
```

11\. コミットを発行してマージを完了。

```shell
git add .
git commit -m "merged article6"
```
11\. myfirstbot.sln を Visual Studio で開き、ソリューションをビルド。

12\. 全てのテストを実行。実装が変わっていない場所のテストは問題ないがメインロジックは変わっているためエラーとなることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7a79209e-cb4e-6837-319b-a60c46b31221.png)

# テストの実装と実行

前回の記事で説明した通り、各ダイアログはそこで完結するようにテストするべきですが、今回のボットのダイアログ構成では、メニューから一連の流れが外せないため、最低限の単位に区切りつつテストします。これはダイアログを IoC に登録することで回避できますが、その手法はまた別の記事で紹介します。

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

メニューダイアログは、以下の動作をします。

-「天気を確認」か「予定を確認」メニューを表示
- 選択した内容によって結果を返す
- メニュー戻る

[実際のフロー]
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/0811beb5-48f5-687c-f465-889385c816ab.png)

では早速テストを追加していきましょう。

1\. ユニットテストプロジェクトに MenuDialogUnitTest.cs を追加し、以下のコードを張り付け。

- Test メソッドの場合送信と応答を一対でしか検証できないため、Send と AssetReply に明示的に分割
- Send メソッドでユーザーインプットを送信
- 複数の応答を検証するため、AssertReply を複数チェイン

```csharp:MenuDialogUnitTest.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest
{
    [TestClass]
    public class MenuDialogUnitTest
    {
        
        [TestMethod]
        public async Task MenuDialog_ShouldReturnWeatherResult()
        {
            // ストレージとしてインメモリを利用
            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 MenuDialog());

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

            // テストの追加と実行
            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(MenuDialog), null, cancellationToken);
                }
            })
            .Test("foo", "今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
            .Send("天気を確認")
            .AssertReply("今日の天気は晴れです")
            .AssertReply("今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
            .StartTestAsync();
        }
    }
}
```

2\. 続いて予定の確認用テストメソッドを追加。

```csharp
[TestMethod]
public async Task MenuDialog_ShouldReturnScheduleResult()
{
    // ストレージとしてインメモリを利用
    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 MenuDialog());

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

    // テストの追加と実行
    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(MenuDialog), null, cancellationToken);
        }
    })
    .Test("foo", "今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
    .Send("予定を確認")
    .AssertReply("今日は予定はありません。")
    .AssertReply("今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
    .StartTestAsync();
}
```

3\. テストの実行を行って成功することを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/de7963b7-a12d-1ecb-974d-42cf680973f6.png)


# プロファイルダイアログのテスト

ProfileDialogUnitTest.cs ファイルを追加して、MyBotUnitTest.cs より ProfileDialog のテストメソッドを全て移動しておきます。

# メインロジックのテスト

メインロジックでは ConversationUpdate 時の処理でステートに対してユーザープロファイルを作成し、その後メッセージを送った際にその値を使います。よって以下の 2 つをテストしますが、ステートについてはモックで対応します。

- ConversationUpdate の処理
- Message を送った際の処理



## ConversationUpdate ロジックのテスト

1\. MyBotUnitTest.cs から既存の MyBot 向けのテストメソッドを削除。

2\. ConversationUpdate をテストするため以下のメソッドを追加。

```shell
[TestMethod]
public async Task MyBot_ShouldWelcomeAndProfileDialogWithConversationUpdate()
{
    // アダプターを作成
    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 conversationUpdateActivity = new Activity(ActivityTypes.ConversationUpdate)
    {
        Id = "test",
        From = new ChannelAccount("TestUser", "Test User"),
        ChannelId = "UnitTest",
        ServiceUrl = "https://example.org",
        MembersAdded = new List<ChannelAccount>() { new ChannelAccount("TestUser", "Test User") }
    };

    // テストの追加と実行
    await new TestFlow(adapter, bot.OnTurnAsync)
        .Send(conversationUpdateActivity)
        .AssertReply("ようこそ MyBot へ！")
        .AssertReply("名前を入力してください。")
        .StartTestAsync();
}
```
3\. テストを実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/d9e34540-df61-383a-414b-1ab3294d0c5f.png)

## Message ロジックのテスト

通常のフローの場合、ConversationUpdate から ProfileDialog が呼ばれ、ユーザーの名前と年齢が保存された状態でメインの Message ロジックが呼ばれます。しかしユニットテストでは直接 Message ロジックをテストするため、ステートを事前に作って渡す必要があります。

方法としては以下の 2 種類が考えられます。

- MemoryStorage を使い、事前に値をセットする
- IStorage をモックして独自に値を返す

MemoryStorage を直接使った場合はその仕様を確認する必要があるため、ここではモックを使います。またモックにつかうフレームワークは Moq ライブラリを使います。

モックする内容は以下の 2 メソッドです。

- ReadAsync : データの読み取り
- WriteAsync : データの書き込み

今回書き込みの処理はすべてスルーして、読み取り時に事前に用意したデータを渡します。またキーとなる値は公開されている BotBuilder のソースコードから読み解きます。

1\. ストレージをモックするため、プロジェクトに対して Moq NuGet パッケージを追加。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/d18b8570-1a09-9c46-8277-fcf28ee2ef0f.png)

2\. 以下のメソッドを追加。

- Mock を使って IStorage をモックし、WriteAsync/ReadAsync メソッドをモック
- ReadAsync では常に一定の値を返すように設定
- 返す値は UserProfile のため、UserState として作成。
- UserState はキーとして channelId/users/userId をとる [参照 GitHub: UserState.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/6f9c41f3cd413863d3d33c7891c71a5e4845ccb1/libraries/Microsoft.Bot.Builder/UserState.cs#L27)
- ChannelId と UserId は TestAdapter が作成するものを利用 [参照 GitHub: TestAdapter.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/6f9c41f3cd413863d3d33c7891c71a5e4845ccb1/libraries/Microsoft.Bot.Builder/Adapters/TestAdapter.cs#L31)

```csharp
[TestMethod]
public async Task MyBot_ShouldWelcomeAndMenuDialogWithMessage()
{
    var name = "Ken";
          
    // アダプターを作成
    var adapter = new TestAdapter();
    adapter.Use(new SetLocaleMiddleware(Culture.Japanese));

    // ストレージとしてモックのストレージを利用
    var mock = new Mock<IStorage>();
    // User1用に返すデータを作成
    // UserState のキーは <channelId>/users/<userId>
    var dictionary = new Dictionary<string, object>();
    dictionary.Add("test/users/user1", new Dictionary<string, object>()
    {
        { "UserProfile", new UserProfile() { Name = name, Age = 0 } }
    });
    // ストレージへの読み書きを設定
    mock.Setup(ms => ms.WriteAsync(It.IsAny<Dictionary<string, object>>(), It.IsAny<CancellationToken>()))
        .Returns(() => Task.CompletedTask);
    mock.Setup(ms => ms.ReadAsync(It.IsAny<string[]>(), It.IsAny<CancellationToken>()))
        .Returns(()=>
        {
            return Task.FromResult(result: (IDictionary<string, object>)dictionary);
        });
            
    // それぞれのステートを作成
    var conversationState = new ConversationState(mock.Object);
    var userState = new UserState(mock.Object);
    var accessors = new MyStateAccessors(userState, conversationState)
    {
        // DialogState を ConversationState のプロパティとして設定
        ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
        // UserProfile を作成
        UserProfile = userState.CreateProperty<UserProfile>("UserProfile")
    };

    var bot = new MyBot(accessors);
           
    // テストの追加と実行
    await new TestFlow(adapter, bot.OnTurnAsync)
        .Test("foo", $"ようこそ '{name}' さん！")
        .AssertReply("今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
        .StartTestAsync();
}
```
3\. テストの実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/fdf3ad12-1efe-5f38-fea4-73dac516f1b0.png)

# まとめ

今回はダイアログ毎にテストを分割した他、ステート管理部分のモック化も行いました。ステートをモックすることでより最小限の単位でテストがやり易くなります。次回は中断処理とグローバルコマンドのハンドリングに対するユニットテストを考えていきます。

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

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