Bot Builder v4 でのテスト : アダプティブカードのユニットテスト

今回はダイアログから送れるアダプティブカードのユニットテストを見ていきます。アダプティブカードについては [Bot Builder v4 でボット開発 : ダイアログから基本的なアダプティブカードを送る](https://qiita.com/kenakamu/items/2725651445e55ec4545c) および [Bot Builder v4 でボット開発 : ダイアログから高度なアダプティブカードを送る](https://qiita.com/kenakamu/items/db725ae83e1a187271a6) を参照してください。

# ソリューションの準備
ボットのコードは [Bot Builder v4 でボット開発 : ダイアログから高度なアダプティブカードを送る](https://qiita.com/kenakamu/items/db725ae83e1a187271a6) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article11 のコードをベースに、article14 ブランチのコードをマージしてテストを開発します。

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

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

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

```shell
git checkout article14
git checkout test-article11
```

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

```shell
git checkout -b test-article12
```

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

```shell
git merge article14
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/bd2ff7a2-a7e5-b1e9-0d40-96b46b9fa8a4.png)

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

```shell
git mergetool
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/464f19ca-18f1-5a82-ab70-a2c5f407c8a5.png)

6\. マージのツールを起動しようとするので既定のまま Enter キーを押下。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/ea65a351-91a8-9b2c-810d-fc7fcf516b3e.png)

7\. コンストラクタは　Target を選択、それ以外は Source を選択してマージを実行。マージ後、AdaptiveJsons フォルダと Validators フォルダを myfirstbot フォルダに移動。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a8074740-dc2b-74b0-df42-fbe20482a2ea.png)

8\. 変更をコミット

```shell
git add .
git commit -m "article14 merged"
```

9\. myfirstbot.sln を Visual Studio で起動してソリューションをビルド。エラーがないことを確認してから全てのテストを実行。応答をアダプティブカードに変えているため多くのテストが失敗していることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/01b33242-88d2-8162-4533-0d2959602561.png)

# アダプティブカードのユニットテスト

アダプティブカードはユーザーから見ると特別な応答ですが、システム的には通常の Activity であることに変わりはありません。

## JSON ファイルのリンクを追加

今回はアダプティブカードを、コード内で相対パスで取得した JSON ファイルから作っていますが、ユニットテストプロジェクトから見た場合はパスが異なるため、以下の手順で JSON を含むディレクトリをリンクとして追加します。

1\. Visual Studio でユニットテストプロジェクトを右クリックし、「myfirstbot.unittest.csproj」の編集をクリック。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8b50db92-3de0-d4d9-7f46-3d6417f712a2.png)

2\. 開いた csproj ファイルの <project> ノード内に以下 XML を追加。

- JSON ファイルをリンクとして追加
- ビルド時に output としてファイルをコピー

```xml
<ItemGroup>
  <Content Include="..\myfirstbot\AdaptiveJsons\*.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    <Link>%(RecursiveDir)\AdaptiveJsons\%(FileName)%(Extension)</Link>
  </Content>
</ItemGroup>
```

3\. ソリューションエクスプローラーで JSON ファイルが含まれていることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/283f6331-0798-1555-37c8-c067351b2456.png)

4\. ソリューションをビルドした際にディレクトリに出力されていることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/339de267-d2c1-a4e6-c914-8e2fff308fdd.png)

# 天気ダイアログのテスト改修

まず天気ダイアログから変更していきます。

1\. WeatherDialogUnitTest.cs を以下のコードと差し替えます。

- アダプティブカードの比較は元の JSON と比較
- JSON 文字列のスペースなどの差異をなくすため、一旦パースした後文字列化

```csharp
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 Newtonsoft.Json.Linq;
using System.IO;
using System.Threading.Tasks;

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

        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 dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(new WeatherDialog());

            // アダプターを作成し必要なミドルウェアを追加
            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(WeatherDialog), null, cancellationToken);
                }
                else if(results.Status == DialogTurnStatus.Complete)
                {
                    await turnContext.SendActivityAsync("Done");
                }
            });

        }

        [TestMethod]
        [DataRow("明日")]
        [DataRow("明後日")]
        public async Task WeatherDialog_ShouldReturnChoice(string date)
        {
            await ArrangeTestFlow()
            .Send("foo")
            .AssertReply((activity) =>
            {
                // アダプティブカードを比較
                Assert.AreEqual(
                    JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                    JObject.Parse(File.ReadAllText("./AdaptiveJsons/Weather.json").Replace("{0}", "今日")).ToString()
                );
            })
            .Send("他の日の天気")
            .AssertReply((activity) =>
            {
                // アダプティブカードを比較
                Assert.AreEqual(
                    JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                    JObject.Parse(File.ReadAllText("./AdaptiveJsons/WeatherDateChoice.json")).ToString()
                );
            })
            .Send(date)
            .AssertReply((activity) =>
            {
                // アダプティブカードを比較
                Assert.AreEqual(
                    JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                    JObject.Parse(File.ReadAllText("./AdaptiveJsons/Weather.json").Replace("{0}", date)).ToString()
                );
            })
            .Test("終了", "Done")
            .StartTestAsync();
        }
        
        [TestMethod]
        public async Task WeatherDialog_ShouldReturnChoiceAndComplete()
        {
            await ArrangeTestFlow()
            .Send("foo")
            .AssertReply((activity) =>
            {
                // アダプティブカードを比較
                Assert.AreEqual(
                    JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                    JObject.Parse(File.ReadAllText("./AdaptiveJsons/Weather.json").Replace("{0}", "今日")).ToString()
                );
            })
            .Test("終了", "Done")
            .StartTestAsync();
        }
    }
}
```
2\. 保存してテストを実行。結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/931883af-20ee-5343-25a1-8cf0c9fe96a9.png)

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

グローバルコマンドでは不要なチェックもしていたので、これを機に必要なチェックだけするようにします。

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

- メニューダイアログや天気ダイアログに遷移した際の戻り値のチェックはスキップ

```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();
}
```

2\. 同様に MyBot_GlobalCommand_ShouldGoToProfileDialog も以下の様に変更。

- 不要なチェックはスキップ
- プロファイルダイアログのチェックを name から adaptive に変更

```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, "adaptive");
        })
        .StartTestAsync();
}
```

3\. MyBot_ShouldGoToWeatherDialog メソッドもスタックのチェック内容を変更。

```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, "date");
        })
        .StartTestAsync();
}
```

4\. MyBot_ShouldGoToWeatherDialogWithEntityResult メソッドはアダプティブカードが戻るか確認を入れる。

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

    // テストの追加と実行
    await arrange.testFlow
        .Send("今日の天気を確認")
        .AssertReply((activity) =>
        {
            // アダプティブカードを比較
            Assert.AreEqual(
                JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                JObject.Parse(File.ReadAllText("./AdaptiveJsons/Weather.json").Replace("{0}", "今日")).ToString()
            );
        })
        .StartTestAsync();
}
```
5\. 保存してテストを実行し、結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/cc993cfc-1072-f2b5-1482-f28e0d7c6a72.png)

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

プロファイルダイアログではアダプティブカードを受け取るだけでなく、アダプティブカードをの結果を送る必要があり、また値の検証も行われます。アダプティブカードの結果は Activity.Value に文字列としてわたるためテストでもそのようにデータを作って送ります。

1\. ProfileDialogUnitTest.cs を以下のコードと差し替えます。

- 不要なテストの削除
- アダプティブカードの入力結果を JObject で作成して送信
- DataRow を使った複数パターンテストも追加

```csharp
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 Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;

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

        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 dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(new ProfileDialog(accessors));

            // アダプターを作成し必要なミドルウェアを追加
            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(ProfileDialog), null, cancellationToken);
                }
            });
        }
       
        [TestMethod]
        public async Task ProfileDialog_ShouldSaveProfile()
        {
            await ArrangeTestFlow()
            .Send("foo")
            .AssertReply((activity) =>
            {
                // アダプティブカードを比較
                Assert.AreEqual(
                    JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                    JObject.Parse(File.ReadAllText("./AdaptiveJsons/Profile.json")).ToString()
                );
            })
            .Send(new Activity()
            {
                Value = new JObject
                {
                    {"name", "Ken" },
                    {"email" , "kenakamu@microsoft.com"},
                    {"phone" , "xxx-xxxx-xxxx"},
                    {"birthday" , new DateTime(1976, 7, 21)},
                    {"hasCat" , true},
                    {"catNum" , "3"},
                    {"catTypes", "キジトラ,サバトラ,ハチワレ" },
                    {"playWithCat" , true}
                }.ToString()
            })
            .AssertReply((activity) =>
            {   
                Assert.AreEqual(
                    (activity as Activity).Text,
                    "プロファイルを保存します。"
                );
            })
            .StartTestAsync();
        }
        

        [TestMethod]
        [DataRow(1800)]
        [DataRow(2020)] 
        public async Task ProfileDialog_ShouldAskRetryWhenAgeOutOfRange(int year)
        {
            // テストの追加と実行
            await ArrangeTestFlow()
            .Send("foo")
            .AssertReply((activity) =>
            {
                // アダプティブカードを比較
                Assert.AreEqual(
                    JObject.Parse((activity as Activity).Attachments[0].Content.ToString()).ToString(),
                    JObject.Parse(File.ReadAllText("./AdaptiveJsons/Profile.json")).ToString()
                );
            })
            .Send(new Activity()
            {
                Value = new JObject
                {
                    {"name", "Ken" },
                    {"email" , "kenakamu@microsoft.com"},
                    {"phone" , "xxx-xxxx-xxxx"},
                    {"birthday" , new DateTime(year, 7, 21)},
                    {"hasCat" , true},
                    {"catNum" , "3"},
                    {"catTypes", "キジトラ,サバトラ,ハチワレ" },
                    {"playWithCat" , true}
                }.ToString()
            }).AssertReply((activity) =>
            {
                var birthday = new DateTime(year, 7, 21);
                var age = DateTime.Now.Year - birthday.Year;
                if (DateTime.Now < birthday.AddYears(age))
                    age--;
               
                Assert.AreEqual(
                    (activity as Activity).Text,
                    $"年齢が{age}歳になります。ただしい誕生日を入れてください。"
                );
            })
            .StartTestAsync();
        }
    }
}
```

2\. 保存してテストを実行。結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f3cde527-2e7d-7466-12d9-00f9c5dd3386.png)

# まとめ

今回はアダプティブカードのテストを見ていきました。これまでのシンプルな値のやり取りとはことなり、少し工夫が必要でしたが、基本的な対応はできたと思います。

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

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