Bot Builder v4 でボット開発 : スキルを開発する - その 6 ユニットテスト

Visual Studio で作成したソリューションは、既定でユニットテストが付いてきます。最高ですね。今回はスキルのユニットテストについて見ていきます。

Bot Builder のユニットテスト詳細については、[Bot Builder v4 でのユニットテスト](https://qiita.com/kenakamu/items/6dc043cfc1f199032883#bot-builder-v4-%E3%81%A7%E3%81%AE%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88%E3%83%86%E3%82%B9%E3%83%88) を参照してください。

# コードの確認

まずテンプレートで展開されたコードを見ていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/49316abc-647d-b38e-7f28-0ca8c63064aa.png)

## Utterances リソース

モックで利用するリソースが登録されています。Value に入力される文字列、Name が Intent となっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/00046ff3-23cb-0828-ebb6-d6dd55b9df51.png)

## モック

LuisRecognizer と QnA サービスが Mocks フォルダでモックされています。QnA サービスは既定のテンプレートでは使われていませんが、使い方は LuisRecognizer とほぼ同じです。

**MockLuisRecognizer**
LuisRecognizer のモックで、以下メソッドを提供します。

- RegisterUtterances: リソースファイルを読み込んで、文言とインテントのペアを作成
- RecognizeAsync: 渡された文言からインテントを返す

## SkillTestBase.cs

すべてのユニットテストが SkillTestBase を継承していて、SkillTestBase は BotTestBase を継承しています。

[GitHub: SkillTestBase.cs](https://github.com/microsoft/botframework-solutions/blob/master/sdk/csharp/libraries/microsoft.bot.solutions/Testing/BotTestBase.cs)

**InitializeSkill**
テストに必要な準備を行います。

- ServiceCollection に依存関係を登録
- BotService: cognitivemodels.json からではなく明示的にモック LuisRecognizer を追加
- ステート管理は MemoryStorage を利用
- アダプターは DefaultTestAdapter を利用
- その他は実際のスキルと同じコード

[GitHub: DefaultTestAdapter.cs](https://github.com/microsoft/botframework-solutions/blob/master/sdk/csharp/libraries/microsoft.bot.solutions/Testing/DefaultTestAdapter.cs) はテスト用の TestAdapter を継承し、追加で EventDebuggerMiddleware を登録します。

```csharp:SkillTestBase.cs
[TestInitialize]
public virtual void InitializeSkill()
{
    Services = new ServiceCollection();
    Services.AddSingleton(new BotSettings());
    Services.AddSingleton(new BotServices()
    {
        CognitiveModelSets = new Dictionary<string, CognitiveModelSet>
        {
            {
                "en-us", new CognitiveModelSet
                {
                    LuisServices = new Dictionary<string, LuisRecognizer>
                    {
                        { "General", GeneralTestUtil.CreateRecognizer() },
                        { "HelloSkill", SkillTestUtil.CreateRecognizer() }
                    }
                }
            }
        }
    });

    Services.AddSingleton<IBotTelemetryClient, NullBotTelemetryClient>();
    Services.AddSingleton(new MicrosoftAppCredentials("appId", "password"));
    Services.AddSingleton(new UserState(new MemoryStorage()));
    Services.AddSingleton(new ConversationState(new MemoryStorage()));
    Services.AddSingleton(sp =>
    {
        var userState = sp.GetService<UserState>();
        var conversationState = sp.GetService<ConversationState>();
        return new BotStateSet(userState, conversationState);
    });

    var localizedTemplates = new Dictionary<string, List<string>>();
    var templateFiles = new List<string>() { "MainResponses", "SampleResponses" };
    var supportedLocales = new List<string>() { "en-us", "de-de", "es-es", "fr-fr", "it-it", "zh-cn" };

    foreach (var locale in supportedLocales)
    {
        var localeTemplateFiles = new List<string>();
        foreach (var template in templateFiles)
        {
            // LG template for en-us does not include locale in file extension.
            if (locale.Equals("en-us"))
            {
                localeTemplateFiles.Add(Path.Combine(".", "Responses", $"{template}.lg"));
            }
            else
            {
                localeTemplateFiles.Add(Path.Combine(".", "Responses", $"{template}.{locale}.lg"));
            }
        }

        localizedTemplates.Add(locale, localeTemplateFiles);
    }

    TemplateEngine = new LocaleTemplateEngineManager(localizedTemplates, "en-us");
    System.Threading.Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo("en-us");
    Services.AddSingleton(TemplateEngine);
    Services.AddTransient<MainDialog>();
    Services.AddTransient<SampleDialog>();
    Services.AddTransient<SampleAction>();
    Services.AddSingleton<TestAdapter, DefaultTestAdapter>();
    Services.AddTransient<IBot, DefaultActivityHandler<MainDialog>>();
}
```

**GetTestFlow**
Dispatch 連携用のアクションをテストする際に使います。DefaultTestAdapter を使った TestFlow を返します。

**GetSkillTestFlow**
PVA 経由のアクションをテストする際に使います。DefaultTestAdapter を使った TestFlow に対して、イベントであると判別できる Claim を追加しています。この情報で [IsSkill](https://github.com/microsoft/botframework-solutions/blob/master/templates/csharp/Skill/Skill/Extensions/ITurnContextEx.cs) メソッドが true を返します。

**GetTemplates**
lg ファイルより現在の言語の文字列を返します。

## MainDialogTests.cs、SampleDialogTests.cs、InterruptionTests.cs

MainDialogTests、SampleDialogTests、InterruptionTests ではメッセージタイプの処理をテストしています。スキル固有のテストはありません。

## InterruptionTests.cs

MainDialog での中断処理をテストします。メッセージタイプのみでスキル固有のテストはありません。

## LocalizationTests.cs

スレッドのカルチャーで言語を指定して、多言語テストをしています。ここでは初めのあいさつの部分のみのテストです。

## SkillModeTests.cs

Dispatch 連携、PVA 経由どちらのアクションもテストしています。SampleDialogTest もあるのでこちらで Dispatch 連携のテストをするのはおかしい感じですが、気にせず行きます。

**Test_Sample_Action**
PVA 経由のアクションをテストする場合、アクティビティのタイプをイベントにします。
また終了時には EndOfConversation が返るため、こちらを検証します。

```csharp
public async Task Test_Sample_Action()
{
    await GetSkillTestFlow()
        .Send(new Activity(type: ActivityTypes.Event, name: "SampleAction"))
        .AssertReplyOneOf(GetTemplates("NamePromptText"))
        .Send(SampleDialogUtterances.NamePromptResponse)
        .AssertReplyOneOf(GetTemplates("HaveNameMessageText", new { Name = SampleDialogUtterances.NamePromptResponse }))
        .AssertReply((activity) =>
        {
            var a = (Activity)activity;
            Assert.AreEqual(ActivityTypes.EndOfConversation, a.Type);
            Assert.AreEqual(typeof(SampleActionOutput), a.Value.GetType());
        })
        .StartTestAsync();
}
```

**Test_Sample_Action_w_Input**
インプットを使うアクションの場合、事前にインプットを作って Activity.value に渡します。

```csharp
[TestMethod]
public async Task Test_Sample_Action_w_Input()
{
    var actionInput = new SampleActionInput() { Name = "test" };

    await GetSkillTestFlow()
        .Send(new Activity(type: ActivityTypes.Event, name: "SampleAction", value: JObject.FromObject(actionInput)))
        .AssertReplyOneOf(GetTemplates("HaveNameMessageText", new { actionInput.Name }))
        .AssertReply((activity) =>
        {
            var a = (Activity)activity;
            Assert.AreEqual(ActivityTypes.EndOfConversation, a.Type);
            Assert.AreEqual(typeof(SampleActionOutput), a.Value.GetType());
        })
        .StartTestAsync();
}
```
# テストの実行

1\. [前回](https://qiita.com/kenakamu/items/b0493a618ddfcbcf0571)までの記事で追加したダイアログを SkillTestBase に追加する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/90b9626d-8264-c68b-e45b-9a006b65d09d.png)

2\. テストエクスプローラーよりすべてのテストを実行。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/3d2198ba-7819-39f6-5fe4-64f1d789d9c4.png)

# テストの追加

追加した GetTime 用のテストを追加します。

1\. Utterances に GetTimeDialogUtterances.resx を追加して Trigger を指定。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/e052d1b7-0f7a-dec5-0f80-9951addd9d01.png)

2\. SkillTestBase.cs の templateFiles を読み込む箇所で GetTimeResponses を追加。

```csharp
var templateFiles = new List<string>() { "MainResponses", "SampleResponses", "GetTimeResponses" };
```
3\. SkillTestUtil.cs の _utterances プロパティに GetTime インテントを追加。

```csharp
private static Dictionary<string, IRecognizerConvert> _utterances = new Dictionary<string, IRecognizerConvert>
{
    { SampleDialogUtterances.Trigger, CreateIntent(SampleDialogUtterances.Trigger, HelloSkillLuis.Intent.Sample) },
    { GetTimeDialogUtterances.Trigger, CreateIntent(GetTimeDialogUtterances.Trigger, HelloSkillLuis.Intent.GetTime) },
};
```
4\. GetTimeDialogTests.cs を追加。

- GetTestFlow で TestFlow を取得
- 戻り値の値のコントロールは現在時刻のためできず、無理やり日時を削除した状態で検証

```csharp
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using HelloSkill.Tests.Utterances;
using System;
using System.Linq;

namespace HelloSkill.Tests
{
    [TestClass]
    public class GetTimeDialogTests : SkillTestBase
    {
        [TestMethod]
        public async Task ShouldReturnCurrentDate()
        {
            var currentTime = DateTime.Now.ToString();
            await GetTestFlow()
                .Send(GetTimeDialogUtterances.Trigger)
                .AssertReplyOneOf(GetTemplates("FirstPromptText"))
                .Send(GetTimeDialogUtterances.Trigger)
                .AssertReplyContains(GetTemplates("CurrentTimeText", new { CurrentTime = currentTime }).First().Replace(currentTime,""))
//                .AssertReplyOneOf(GetTemplates("CurrentTimeText", new { CurrentTime = currentTime }))
                .StartTestAsync();
        }
    }
}
```

5\. 同様に GetTimeActionTests.cs を追加。

- GetSkillTestFlow で TestFlow を取得
- 戻り値の値のコントロールは現在時刻のためできず、型のチェックを行っている

```csharp
using HelloSkill.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;

namespace HelloSkill.Tests
{
    [TestClass]
    public class GetTimeActionTests : SkillTestBase
    {
        [TestMethod]
        public async Task ShouldReturnCurrentDate()
        {
            DateTime output;
            await GetSkillTestFlow()
               .Send(new Activity(type: ActivityTypes.Event, name: "GetTimeAction"))
               .AssertReply((activity) =>
               {
                   var a = (Activity)activity;
                   Assert.AreEqual(ActivityTypes.EndOfConversation, a.Type);
                   Assert.AreEqual(typeof(GetTimeActionOutput), a.Value.GetType());
                   Assert.IsTrue((a.Value as GetTimeActionOutput).CurrentTime is string);
                   Assert.IsTrue(DateTime.TryParse((a.Value as GetTimeActionOutput).CurrentTime, out output));
               })
               .StartTestAsync();
        }
    }
}
```

6\. ソリューションをビルドして、テストを実行。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/553924dc-1b86-672b-bbd0-667942da3629.png)

# まとめ

ユニットテストの基礎がテンプレートに含まれているのは便利です。テストの分け方や名前についてはプロジェクトに合わせて変えてください。

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