Bot Builder v4.5 のユニットテスト : ダイアログクラスのテスト - MainDialog

[前回](https://qiita.com/kenakamu/items/61a331ad5a87cf57c35a) は IBot を継承するクラスのテストを見ていきましたが、今回はダイアログを見ていきます。

# ダイアログのテストスコープ

BotBuilder で作成するダイアログは通常の C# クラスと異なり、メソッド単位でテストは行わず、ある程度の会話の単位でテストを行います。その際、極力テスト以外部分の変更に影響が及ばないようにスコープを分けていきます。

# MainDialog 

今回は MainDialog のユニットテストを見ていきます。このダイアログでは、主に以下の処理を行います。

- LUIS が構成されていない場合にメッセージを表示して、予約詳細無しで BookingDialog を実行
- LUIS が構成されている場合、インテントによって処理を分岐
    - チケット予約の場合、都市の確認や LUIS の結果から予約詳細を作成して BookingDialog を実行 
    - 天気取得の場合、ユーザーに TODO: get weather flow here を返信
    - インテントが分からない場合、エラーを返す
- 処理が完了後、次にどうするかメッセージを表示。

では早速テストを見ていきます。

# MainDialogTests コンストラクタ

MainDialogTests クラス内で実行するテストに共通するものをコンストラクタで準備します。

```csharp
private readonly BookingDialog _mockBookingDialog;
private readonly Mock<ILogger<MainDialog>> _mockLogger;

public MainDialogTests(ITestOutputHelper output)
    : base(output)
{
    _mockLogger = new Mock<ILogger<MainDialog>>();
    var expectedBookingDialogResult = new BookingDetails()
    {
        Destination = "Seattle",
        Origin = "New York",
        TravelDate = $"{DateTime.UtcNow.AddDays(1):yyyy-MM-dd}"
    };
    _mockBookingDialog = SimpleMockFactory.CreateMockDialog<BookingDialog>(expectedBookingDialogResult).Object;
}
```

# ShowsMessageIfLuisNotConfiguredAndCallsBookDialogDirectly テスト

このテストでは LUIS が構成されていない場合のフローをテストします。

```csharp:MainDialogTests.cs
[Fact]
public async Task ShowsMessageIfLuisNotConfiguredAndCallsBookDialogDirectly()
{
    // Arrange
    var mockRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer>(null, constructorParams: new Mock<IConfiguration>().Object);
    mockRecognizer.Setup(x => x.IsConfigured).Returns(false);

    // Create a specialized mock for BookingDialog that displays a dummy TextPrompt.
    // The dummy prompt is used to prevent the MainDialog waterfall from moving to the next step
    // and assert that the dialog was called.
    var mockDialog = new Mock<BookingDialog>();
    mockDialog
        .Setup(x => x.BeginDialogAsync(It.IsAny<DialogContext>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
        .Returns(async (DialogContext dialogContext, object options, CancellationToken cancellationToken) =>
        {
            dialogContext.Dialogs.Add(new TextPrompt("MockDialog"));
            return await dialogContext.PromptAsync("MockDialog", new PromptOptions() { Prompt = MessageFactory.Text($"{nameof(BookingDialog)} mock invoked") }, cancellationToken);
        });

    var sut = new MainDialog(mockRecognizer.Object, mockDialog.Object, _mockLogger.Object);
    var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });

    // Act/Assert
    var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
    Assert.Equal("NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and 'LuisAPIHostName' to the appsettings.json file.", reply.Text);

    reply = testClient.GetNextReply<IMessageActivity>();
    Assert.Equal("BookingDialog mock invoked", reply.Text);
}
```

## 各種サービスのモック

依存サービスはすべて Moq や既存の機能を使ってモックしていきます。

### IRecognizer

自然言語処理は IRecognizer を継承する FlightBookingRecognizer で処理されるため、まずはこちらをモックします。今回は LUIS が構成されていない場合のテストであるため、IsConfigured で false を常に返します。

```csharp
// Arrange
var mockRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer>(null, constructorParams: new Mock<IConfiguration>().Object);
mockRecognizer.Setup(x => x.IsConfigured).Returns(false);
```

### ダイアログ

依存するダイアログである BookingDialog をモックします。また BeginDialogAsync メソッドが実行された際、ダイアログが呼び出されたメッセージだけ返すようにモックします。

```csharp:MainDialogTests.cs
// Create a specialized mock for BookingDialog that displays a dummy TextPrompt.
// The dummy prompt is used to prevent the MainDialog waterfall from moving to the next step
// and assert that the dialog was called.
var mockDialog = new Mock<BookingDialog>();
mockDialog
    .Setup(x => x.BeginDialogAsync(It.IsAny<DialogContext>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
    .Returns(async (DialogContext dialogContext, object options, CancellationToken cancellationToken) =>
    {
        dialogContext.Dialogs.Add(new TextPrompt("MockDialog"));
        return await dialogContext.PromptAsync("MockDialog", new PromptOptions() { Prompt = MessageFactory.Text($"{nameof(BookingDialog)} mock invoked") }, cancellationToken);
    });
```

## テストフロー

各種サービスのモックを作成したら、DialogTestClient を使ってテストを実行します。

### DialogTestClient

Bot Builder v4.5 で提供されるテスト用ツールで、以下のメソッドをサポートします。
[GitHub: DialogTestClient.cs](https://github.com/microsoft/botbuilder-dotnet/blob/4.5/libraries/Microsoft.Bot.Builder.Testing/DialogTestClient.cs)

基本的には TestAdapter のラッパーですで、ミドルウェアの管理や会話の管理を行えます。

**GetNextReply**
ボットからの次の応答を取得します。

**SendActivityAsync**
ボットに Activity を送信します。

コンストラクタにミドルウェアとして渡す XUnitDialogTestLogger はテストとボットのやり取りをコンソールに表示してくれます。
[GitHub: XUnitDialogTestLogger.cs](https://github.com/microsoft/botbuilder-dotnet/blob/4.5/libraries/Microsoft.Bot.Builder.Testing/XUnit/XUnitDialogTestLogger.cs)

```csharp
var sut = new MainDialog(mockRecognizer.Object, mockDialog.Object, _mockLogger.Object);
var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });
```

### テストの実行

ここではシンプルに "Hi" というメッセージを送信しています。

```csharp
// Act/Assert
var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
```

## 結果の確認

結果のテキストを Assert.Equal で比較するシンプルな結果確認です。

```csharp
Assert.Equal("NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and 'LuisAPIHostName' to the appsettings.json file.", reply.Text);

reply = testClient.GetNextReply<IMessageActivity>();
Assert.Equal("BookingDialog mock invoked", reply.Text);
```
XUnitDialogTestLogger を追加しているため、テスト エクスプローラーより追加の詳細を確認できます。

1\. テストエクスプローラーより結果を表示。「この結果に対して追加の出力を開く」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/7e7ddce6-915e-960f-6e9c-644b01c09db1.png)

2\. XUnitDialogTestLogger より出力された内容を確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/0e2e15b4-5261-7efa-c9cb-28f6bd28f1dc.png)

# ShowsPromptIfLuisIsConfigured テスト

このテストでは LUIS が構成されている場合のフローをテストします。スコープを分割するため、構成されている場合に出るメッセージまでを確認して終わっています。

```csharp:MainDialogTests.cs
[Fact]
public async Task ShowsPromptIfLuisIsConfigured()
{
    // Arrange
    var mockRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer>(null, constructorParams: new Mock<IConfiguration>().Object);
    mockRecognizer.Setup(x => x.IsConfigured).Returns(true);
    var sut = new MainDialog(mockRecognizer.Object, _mockBookingDialog, _mockLogger.Object);
    var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });

    // Act/Assert
    var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
    Assert.Equal("What can I help you with today?\nSay something like \"Book a flight from Paris to Berlin on March 22, 2020\"", reply.Text);
}
```

## 各種サービスのモック

依存サービスはすべて Moq や既存の機能を使ってモックしていきます。

### IRecognizer

LUIS が構成されている場合のテストであるため、IsConfigured で true を常に返します。

```csharp
// Arrange
var mockRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer>(null, constructorParams: new Mock<IConfiguration>().Object);
mockRecognizer.Setup(x => x.IsConfigured).Returns(true);
```

## テストの実行

ここではシンプルに "Hi" というメッセージを送信しています。

```csharp
var sut = new MainDialog(mockRecognizer.Object, _mockBookingDialog, _mockLogger.Object);
var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });
// Act/Assert
var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
```

## 結果の確認

結果のテキストを Assert.Equal で比較するシンプルな結果確認です。

```csharp
Assert.Equal("What can I help you with today?\nSay something like \"Book a flight from Paris to Berlin on March 22, 2020\"", reply.Text);
```

# TaskSelector テスト

このテストでは LUIS が構成されている場合に、解析結果によって適切なフローに行くかをテストしています。また xUnit の Theory を使って、テストに複数の引数をパターンとして渡しています。

```csharp:MainDialogTests.cs
[Theory]
[InlineData("I want to book a flight", "BookFlight", "BookingDialog mock invoked", "I have you booked to Seattle from New York")]
[InlineData("What's the weather like?", "GetWeather", "TODO: get weather flow here", null)]
[InlineData("bananas", "None", "Sorry, I didn't get that. Please try asking in a different way (intent was None)", null)]
public async Task TaskSelector(string utterance, string intent, string invokedDialogResponse, string taskConfirmationMessage)
{
    // Create a mock recognizer that returns the expected intent.
    var mockLuisRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer, FlightBooking>(
        new FlightBooking
        {
            Intents = new Dictionary<FlightBooking.Intent, IntentScore>
            {
                { Enum.Parse<FlightBooking.Intent>(intent), new IntentScore() { Score = 1 } },
            },
            Entities = new FlightBooking._Entities(),
        },
        new Mock<IConfiguration>().Object);
    mockLuisRecognizer.Setup(x => x.IsConfigured).Returns(true);
    var sut = new MainDialog(mockLuisRecognizer.Object, _mockBookingDialog, _mockLogger.Object);
    var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });

    // Execute the test case
    Output.WriteLine($"Test Case: {intent}");
    var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
    Assert.Equal("What can I help you with today?\nSay something like \"Book a flight from Paris to Berlin on March 22, 2020\"", reply.Text);

    reply = await testClient.SendActivityAsync<IMessageActivity>(utterance);
    Assert.Equal(invokedDialogResponse, reply.Text);

    // The Booking dialog displays an additional confirmation message, assert that it is what we expect.
    if (!string.IsNullOrEmpty(taskConfirmationMessage))
    {
        reply = testClient.GetNextReply<IMessageActivity>();
        Assert.StartsWith(taskConfirmationMessage, reply.Text);
    }

    // Validate that the MainDialog starts over once the task is completed.
    reply = testClient.GetNextReply<IMessageActivity>();
    Assert.Equal("What else can I do for you?", reply.Text);
}
```

## 各種サービスのモック

依存サービスはすべて Moq や既存の機能を使ってモックしていきます。

### IRecognizer

今回は IRecognizer の処理が実際に行われるため、まずは SampleMockFactory.cs の CreateMockLuisRecognizer メソッドでモックした IRecognizer を取得します。個のモックでは RecognizeAsync メソッド実行時に任意の結果が返せるようになっています。

```csharp:SampleMockFactory.cs
public static Mock<TRecognizer> CreateMockLuisRecognizer<TRecognizer, TReturns>(TReturns returns, params object[] constructorParams)
    where TRecognizer : class, IRecognizer
    where TReturns : IRecognizerConvert, new()
{
    var mockRecognizer = new Mock<TRecognizer>(constructorParams);
    mockRecognizer
        .Setup(x => x.RecognizeAsync<TReturns>(It.IsAny<ITurnContext>(), It.IsAny<CancellationToken>()))
        .Returns(() => Task.FromResult(returns));
    return mockRecognizer;
}
```

テストメソッドの中では、上記を利用して FlightBookingRecognizer のモックを作成、テストパータンによって適切なインテントとエンティティを返すようにします。

```csharp
// Create a mock recognizer that returns the expected intent.
var mockLuisRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer, FlightBooking>(
    new FlightBooking
    {
        Intents = new Dictionary<FlightBooking.Intent, IntentScore>
        {
            { Enum.Parse<FlightBooking.Intent>(intent), new IntentScore() { Score = 1 } },
        },
        Entities = new FlightBooking._Entities(),
    },
    new Mock<IConfiguration>().Object);
mockLuisRecognizer.Setup(x => x.IsConfigured).Returns(true);
```

## テストの実行と結果の確認

まず "hi" とメッセージを送って会話をはじめます。

```csharp
var sut = new MainDialog(mockLuisRecognizer.Object, _mockBookingDialog, _mockLogger.Object);
var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });
// Execute the test case
Output.WriteLine($"Test Case: {intent}");
var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
Assert.Equal("What can I help you with today?\nSay something like \"Book a flight from Paris to Berlin on March 22, 2020\"", reply.Text);
```

次にテストパターンで渡されたメッセージを送って、結果を比較します。

```csharp
reply = await testClient.SendActivityAsync<IMessageActivity>(utterance);
Assert.Equal(invokedDialogResponse, reply.Text);
```

BookingDialog だけは追加でメッセージがあるため、そちらも確認します。

```csharp
// The Booking dialog displays an additional confirmation message, assert that it is what we expect.
if (!string.IsNullOrEmpty(taskConfirmationMessage))
{
    reply = testClient.GetNextReply<IMessageActivity>();
    Assert.StartsWith(taskConfirmationMessage, reply.Text);
}
```

その後、MainDialog の最後のメッセージが来ることを確認します。

```csharp
// Validate that the MainDialog starts over once the task is completed.
reply = testClient.GetNextReply<IMessageActivity>();
Assert.Equal("What else can I do for you?", reply.Text);
```
テストエクスプローラーでは、テストパターン分の結果が表示され、個別の結果が確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/d3922c59-ab93-d1d2-cc3e-8042bf2725f5.png)

# ShowsUnsupportedCitiesWarning テスト

このテストではユーザーが入力した行先がサポートされない場合のチェックをテストします。先ほど同様 Theory によって複数のパターンをテストしています。

```csharp:MainDialogTests.cs
[Theory]
[InlineData("FlightToMadrid.json", "Sorry but the following airports are not supported: madrid")]
[InlineData("FlightFromMadridToChicago.json", "Sorry but the following airports are not supported: madrid,chicago")]
[InlineData("FlightFromCdgToJfk.json", "Sorry but the following airports are not supported: cdg")]
[InlineData("FlightFromParisToNewYork.json", "BookingDialog mock invoked")]
public async Task ShowsUnsupportedCitiesWarning(string jsonFile, string expectedMessage)
{
    // Load the LUIS result json and create a mock recognizer that returns the expected result.
    var luisResultJson = GetEmbeddedTestData($"{GetType().Namespace}.TestData.{jsonFile}");
    var mockLuisResult = JsonConvert.DeserializeObject<FlightBooking>(luisResultJson);
    var mockLuisRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer, FlightBooking>(
        mockLuisResult,
        new Mock<IConfiguration>().Object);
    mockLuisRecognizer.Setup(x => x.IsConfigured).Returns(true);

    var sut = new MainDialog(mockLuisRecognizer.Object, _mockBookingDialog, _mockLogger.Object);
    var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });

    // Execute the test case
    Output.WriteLine($"Test Case: {mockLuisResult.Text}");
    var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
    Assert.Equal("What can I help you with today?\nSay something like \"Book a flight from Paris to Berlin on March 22, 2020\"", reply.Text);

    reply = await testClient.SendActivityAsync<IMessageActivity>(mockLuisResult.Text);
    Assert.Equal(expectedMessage, reply.Text);
}

/// <summary>
/// Loads the embedded json resource with the LUIS as a string.
/// </summary>
private string GetEmbeddedTestData(string resourceName)
{
    using (var stream = GetType().Assembly.GetManifestResourceStream(resourceName))
    {
        using (var reader = new StreamReader(stream))
        {
            return reader.ReadToEnd();
        }
    }
}
```

## 各種サービスのモック

依存サービスはすべて Moq や既存の機能を使ってモックしていきます。

### IRecognizer

先ほど同様、SampleMockFactory の CreateMockLuisRecognizer で IRecognizer をモックしますが、結果の LuisResult を、テストパターンから渡された json ファイルから取得するようにしている点が異なります。json を使うことで結果全体を簡単に再利用できます。

```json:FlightFromParisToNewYork.json
{
  "Text": "flight from paris to new york",
  "AlteredText": null,
  "Intents": {
    "BookFlight": {
      "score": 0.9953049
    }
  },
  "Entities": {
    "datetime": null,
    "Airport": null,
    "From": [
      {
        "Airport": [
          [
            "Paris"
          ]
        ],
        "$instance": {
          "Airport": [
            {
              "startIndex": 12,
              "endIndex": 17,
              "text": "paris",
              "score": null,
              "type": "Airport",
              "subtype": null
            }
          ]
        }
      }
    ],
    "To": [
      {
        "Airport": [
          [
            "New York"
          ]
        ],
        "$instance": {
          "Airport": [
            {
              "startIndex": 21,
              "endIndex": 29,
              "text": "new york",
              "score": null,
              "type": "Airport",
              "subtype": null
            }
          ]
        }
      }
    ],
    "$instance": {
      "datetime": null,
      "Airport": null,
      "From": [
        {
          "startIndex": 12,
          "endIndex": 17,
          "text": "paris",
          "score": 0.94712317,
          "type": "From",
          "subtype": null
        }
      ],
      "To": [
        {
          "startIndex": 21,
          "endIndex": 29,
          "text": "new york",
          "score": 0.8602996,
          "type": "To",
          "subtype": null
        }
      ]
    }
  }
}
```
```csharp
// Load the LUIS result json and create a mock recognizer that returns the expected result.
var luisResultJson = GetEmbeddedTestData($"{GetType().Namespace}.TestData.{jsonFile}");
var mockLuisResult = JsonConvert.DeserializeObject<FlightBooking>(luisResultJson);
var mockLuisRecognizer = SimpleMockFactory.CreateMockLuisRecognizer<FlightBookingRecognizer, FlightBooking>(
    mockLuisResult,
    new Mock<IConfiguration>().Object);
mockLuisRecognizer.Setup(x => x.IsConfigured).Returns(true);
```

## テストの実行と結果の確認

まず "hi" とメッセージを送って会話をはじめます。

```csharp
var sut = new MainDialog(mockLuisRecognizer.Object, _mockBookingDialog, _mockLogger.Object);
var testClient = new DialogTestClient(Channels.Test, sut, middlewares: new[] { new XUnitDialogTestLogger(Output) });

// Execute the test case
Output.WriteLine($"Test Case: {mockLuisResult.Text}");
var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");Assert.Equal("What can I help you with today?\nSay something like \"Book a flight from Paris to Berlin on March 22, 2020\"", reply.Text);
```

次にテストパターンで渡されたメッセージを送って、結果を比較します。

```csharp
reply = await testClient.SendActivityAsync<IMessageActivity>(mockLuisResult.Text);
Assert.Equal(expectedMessage, reply.Text);
```
こちらもテストエクスプローラーですべてのパターン毎の結果が確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/8c8b2c2f-8a57-d530-e65a-04cbb6bc9755.png)

# まとめ

今回は MainDialog のテストを見ましたが、こちらも以前の手法とあまり変わりませんでした。次回は BookingDialog のテストを見ていきます。

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