Bot Builder v4.5 のユニットテスト : IBot を継承するクラスのテスト

[前回](https://qiita.com/drafts/3b0399a6911f377f00a9)に引き続き、テンプレートで作成されるユニットテストの詳細を見ていきます。今回はボットの基本クラスである IBot を ActivityHandler 経由で継承したクラスのテストです。

# Bot クラスのテスト

Bots フォルダに含まれる ActivityHandler を継承するクラスである DialogBot.cs と、それを継承する DialogAndWelcomeBot.cs をテストしています。ActivityHandler を含むクラスのテストは OnTurnAsync や OnMessageActivityAsync、OnMembersAddedAsync などのユーザーメッセージのハンドラーやシステムメッセージのテストがあります。

# DialogBot のテスト : SavesTurnStateUsingMockWithVirtualSaveChangesAsync

DialogBot では OnTurnAsync メソッドで ActivityHandler の OnTurnAsync を呼び出し、その後ステートを保存する処理をしています。

```csharp:Dialog.Bot.cs
public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
    await base.OnTurnAsync(turnContext, cancellationToken);

    // Save any state changes that might have occured during the turn.
    await ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
    await UserState.SaveChangesAsync(turnContext, false, cancellationToken);
}
```

これに対して以下テストがあります。詳細を見ていきましょう。

```csharp:DialogBotTests.cs
[Fact]
public async Task SavesTurnStateUsingMockWithVirtualSaveChangesAsync()
{
    // Note: this test requires that SaveChangesAsync is made virtual in order to be able to create a mock.
    var memoryStorage = new MemoryStorage();
    var mockConversationState = new Mock<ConversationState>(memoryStorage)
    {
        CallBase = true,
    };

    var mockUserState = new Mock<UserState>(memoryStorage)
    {
        CallBase = true,
    };

    var mockRootDialog = SimpleMockFactory.CreateMockDialog<Dialog>(null, "mockRootDialog");
    var mockLogger = new Mock<ILogger<DialogBot<Dialog>>>();

    // Act
    var sut = new DialogBot<Dialog>(mockConversationState.Object, mockUserState.Object, mockRootDialog.Object, mockLogger.Object);
    var testAdapter = new TestAdapter();
    var testFlow = new TestFlow(testAdapter, sut);
    await testFlow.Send("Hi").StartTestAsync();

    // Assert that SaveChangesAsync was called
    mockConversationState.Verify(x => x.SaveChangesAsync(It.IsAny<TurnContext>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
    mockUserState.Verify(x => x.SaveChangesAsync(It.IsAny<TurnContext>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
}
```

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

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

### ストレージ

各種ステートを保存するためのストレージは、開発向けにサポートされているメモリストレージを使います。会話ステート、ユーザーステートをそれぞれメモリストレージベースでインスタンス化します。またこのテストでは各ステートクラスの SaveChangesAsync メソッドを呼ぶため、CallBase を有効にしています。

```csharp
// Note: this test requires that SaveChangesAsync is made virtual in order to be able to create a mock.
var memoryStorage = new MemoryStorage();
var mockConversationState = new Mock<ConversationState>(memoryStorage)
{
    CallBase = true,
};
var mockUserState = new Mock<UserState>(memoryStorage)
{
    CallBase = true,
};
```

### ダイアログ

テストするメインのダイアログ以外のダイアログは全て依存コンポーネントであるため、モックします。各ダイアログで受け取る引数が変わるため、一般化するためサンプルコードが提供されいます。モックは Moq を使っています。


```csharp:SimpleMockFactory.cs
public static Mock<T> CreateMockDialog<T>(object expectedResult = null, params object[] constructorParams)
    where T : Dialog
{
    var mockDialog = new Mock<T>(constructorParams);
    var mockDialogNameTypeName = typeof(T).Name;
    mockDialog
        .Setup(x => x.BeginDialogAsync(It.IsAny<DialogContext>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
        .Returns(async (DialogContext dialogContext, object options, CancellationToken cancellationToken) =>
        {
            await dialogContext.Context.SendActivityAsync($"{mockDialogNameTypeName} mock invoked", cancellationToken: cancellationToken);

            return await dialogContext.EndDialogAsync(expectedResult, cancellationToken);
        });

    return mockDialog;
}
```

このコードを使って存在しないダイアログをモックしています。ここで存在しないダイアログをモックしている理由は、DialogBot に対して渡すダイアログが変わってもテストに影響がないことと、影響を与えないためです。

```csharp
var mockRootDialog = SimpleMockFactory.CreateMockDialog<Dialog>(null, "mockRootDialog");
```

### ILogger

ダイアログ同様に Moq でシンプルにモックしています。

```csharp
var mockLogger = new Mock<ILogger<DialogBot<Dialog>>>();
```

## テストフロー

各種サービスのモックを作成したら、DialogBot をインスタンス化してテストを実行します。

### TestAdapter と TestFlow

TestAdapter と TestFlow は以前のバージョンからもサポートされている会話実行テストフレームワークです。詳細は [Bot Builder v4 でのテスト : ユニットテストの概要と準備](https://qiita.com/kenakamu/items/2a567e31e5d2960dda70) を参照してください。

### テストの実行

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

```csharp
// Act
var sut = new DialogBot<Dialog>(mockConversationState.Object, mockUserState.Object, mockRootDialog.Object, mockLogger.Object);
var testAdapter = new TestAdapter();
var testFlow = new TestFlow(testAdapter, sut);
await testFlow.Send("Hi").StartTestAsync();
```

## 結果の確認

Mock クラスの Verify メソッドを使い SaveChangesAsync メソッドが 1 度だけ呼ばれたことを確認します。SaveChangesAsync メソッドが行う処理については BotBuilder SDK 側の役割のため、ここではメソッドが期待された回数実行されたことをもってテスト完了としています。

```csharp
// Assert that SaveChangesAsync was called
mockConversationState.Verify(x => x.SaveChangesAsync(It.IsAny<TurnContext>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
mockUserState.Verify(x => x.SaveChangesAsync(It.IsAny<TurnContext>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
```

# DialogBot のテスト : LogsInformationToILogger

DialogBot クラスの OnMessageActivityAsync メソッドでは、ダイアログ開始をログして、実際のダイアログを呼び出す処理を行います。

```csharp:DialogBot.cs
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    Logger.LogInformation("Running dialog with Message Activity.");

    // Run the Dialog with the new message Activity.
    await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>("DialogState"), cancellationToken);
}
```
これに対して以下テストがあります。

```csharp:DialogBotTest.cs
[Fact]
public async Task LogsInformationToILogger()
{
    // Arrange
    var memoryStorage = new MemoryStorage();
    var conversationState = new ConversationState(memoryStorage);
    var userState = new UserState(memoryStorage);

    var mockRootDialog = SimpleMockFactory.CreateMockDialog<Dialog>(null, "mockRootDialog");
    var mockLogger = new Mock<ILogger<DialogBot<Dialog>>>();
    mockLogger.Setup(x =>
        x.Log(It.IsAny<LogLevel>(), It.IsAny<EventId>(), It.IsAny<object>(), null, It.IsAny<Func<object, Exception, string>>()));

    // Run the bot
    var sut = new DialogBot<Dialog>(conversationState, userState, mockRootDialog.Object, mockLogger.Object);
    var testAdapter = new TestAdapter();
    var testFlow = new TestFlow(testAdapter, sut);
    await testFlow.Send("Hi").StartTestAsync();

    // Assert that log was changed with the expected parameters
    mockLogger.Verify(
        x => x.Log(
            LogLevel.Information,
            It.IsAny<EventId>(),
            It.Is<object>(o => o.ToString() == "Running dialog with Message Activity."),
            null,
            It.IsAny<Func<object, Exception, string>>()),
        Times.Once);
}
```
## 各種サービスのモック

ここでも依存はモックしていきます。

### ストレージ

前回と異なり SaveChangesAsync などメソッドが実行される場面がないため、インスタンスだけ作成します。

```csharp
// Arrange
var memoryStorage = new MemoryStorage();
var conversationState = new ConversationState(memoryStorage);
var userState = new UserState(memoryStorage);
```

### ダイアログ

前回と同様です。

```csharp
var mockRootDialog = SimpleMockFactory.CreateMockDialog<Dialog>(null, "mockRootDialog");
```

### ILogger

ILogger は LogInformation メソッドを呼び出しますが、Log メソッドの LogLevel によって呼び出しされていることから、ここではすべてのレベルのログに対応するよう、Log メソッドを呼び出すように設定しています。

```csharp
var mockLogger = new Mock<ILogger<DialogBot<Dialog>>>();
mockLogger.Setup(x =>
    x.Log(It.IsAny<LogLevel>(), It.IsAny<EventId>(), It.IsAny<object>(), null, It.IsAny<Func<object, Exception, string>>()));
```

## テストの実行

各種サービスのモックを作成したら、DialogBot をインスタンス化してテストを実行します。ここでもシンプルに "Hi" というメッセージを送信しています。

```csharp
// Run the bot
var sut = new DialogBot<Dialog>(conversationState, userState, mockRootDialog.Object, mockLogger.Object);
var testAdapter = new TestAdapter();
var testFlow = new TestFlow(testAdapter, sut);
await testFlow.Send("Hi").StartTestAsync();
```

## 結果の確認

Mock クラスの Verify メソッドを使い Log メソッドが LogLevel.Information で 1 度だけ呼び出され、メッセージが "Running dialog with Message Activity." であることを検証します。

```csharp
// Assert that log was changed with the expected parameters
mockLogger.Verify(
    x => x.Log(
        LogLevel.Information,
        It.IsAny<EventId>(),
        It.Is<object>(o => o.ToString() == "Running dialog with Message Activity."),
        null,
        It.IsAny<Func<object, Exception, string>>()),
    Times.Once);
```

# DialogAndWelcomeBot のテスト : ReturnsWelcomeCardOnConversationUpdate

DialogAndWelcomeBot クラスの OnMembersAddedAsync メソッドでは、ユーザーが会話に参加した際に、Welcome カードを送ってダイアログを開始します。

```csharp:DialogBot.cs
protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
{
    foreach (var member in membersAdded)
    {
        // Greet anyone that was not the target (recipient) of this message.
        // To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details.
        if (member.Id != turnContext.Activity.Recipient.Id)
        {
            var welcomeCard = CreateAdaptiveCardAttachment();
            var response = MessageFactory.Attachment(welcomeCard, ssml: "Welcome to Bot Framework!");
            await turnContext.SendActivityAsync(response, cancellationToken);
            await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>("DialogState"), cancellationToken);
        }
    }
}

// Load attachment from embedded resource.
private Attachment CreateAdaptiveCardAttachment()
{
    var cardResourcePath = "MyBotWithUnitTest.Cards.welcomeCard.json";

    using (var stream = GetType().Assembly.GetManifestResourceStream(cardResourcePath))
    {
        using (var reader = new StreamReader(stream))
        {
            var adaptiveCard = reader.ReadToEnd();
            return new Attachment()
            {
                ContentType = "application/vnd.microsoft.card.adaptive",
                Content = JsonConvert.DeserializeObject(adaptiveCard),
            };
        }
    }
}
```
これに対して以下テストがあります。

```csharp:DialogBotTest.cs
[Fact]
public async Task ReturnsWelcomeCardOnConversationUpdate()
{
    // Arrange
    var mockRootDialog = SimpleMockFactory.CreateMockDialog<Dialog>(null, "mockRootDialog");
    var memoryStorage = new MemoryStorage();
    var sut = new DialogAndWelcomeBot<Dialog>(new ConversationState(memoryStorage), new UserState(memoryStorage), mockRootDialog.Object, null);

    // Create conversationUpdate activity
    var conversationUpdateActivity = new Activity
    {
        Type = ActivityTypes.ConversationUpdate,
        MembersAdded = new List<ChannelAccount>
        {
            new ChannelAccount { Id = "theUser" },
        },
        Recipient = new ChannelAccount { Id = "theBot" },
    };
    var testAdapter = new TestAdapter(Channels.Test);

    // Act
    // Send the conversation update activity to the bot.
    await testAdapter.ProcessActivityAsync(conversationUpdateActivity, sut.OnTurnAsync, CancellationToken.None);

    // Assert we got the welcome card
    var reply = (IMessageActivity)testAdapter.GetNextReply();
    Assert.Equal(1, reply.Attachments.Count);
    Assert.Equal("application/vnd.microsoft.card.adaptive", reply.Attachments.FirstOrDefault()?.ContentType);

    // Assert that we started the main dialog.
    reply = (IMessageActivity)testAdapter.GetNextReply();
    Assert.Equal("Dialog mock invoked", reply.Text);
}
```
## 各種サービスのモック

ここでも依存はモックしていきます。

### ストレージ、ダイアログ

ストレージやダイアログに対しての処理はないため、シンプルに DialogAndWelcomeBot インスタンスを作成しています。

```csharp
// Arrange
var mockRootDialog = SimpleMockFactory.CreateMockDialog<Dialog>(null, "mockRootDialog");
var memoryStorage = new MemoryStorage();
var sut = new DialogAndWelcomeBot<Dialog>(new ConversationState(memoryStorage), new UserState(memoryStorage), mockRootDialog.Object, null);
```

## テストフロー

### Activity

今回ボットに送るメッセージはユーザーからのテキストではなく、ConversationUpdate のシステムメッセージであるため、事前に作成します。

```csharp
// Create conversationUpdate activity
var conversationUpdateActivity = new Activity
{
    Type = ActivityTypes.ConversationUpdate,
    MembersAdded = new List<ChannelAccount>
    {
        new ChannelAccount { Id = "theUser" },
    },
    Recipient = new ChannelAccount { Id = "theBot" },
};
```

### TestAdapter とチャネル

BotFramework は複数のチャネルをサポートするため、チャネルという概念があります。今回のテストではテスト用のチャネルを指定してアダプターを作成します。

```csharp
var testAdapter = new TestAdapter(Channels.Test);
```

### テストの実行

ユーザーメッセージと異なり、システムメッセージはアダプターで処理されます。

```csharp
// Act
// Send the conversation update activity to the bot.
await testAdapter.ProcessActivityAsync(conversationUpdateActivity, sut.OnTurnAsync, CancellationToken.None);
```

## 結果の確認

アダプターより GetNextReply で次のメッセージを取得して検証します。カードを送る場合は、reply.Attachments を精査します。

```csharp
// Assert we got the welcome card
var reply = (IMessageActivity)testAdapter.GetNextReply();
Assert.Equal(1, reply.Attachments.Count);
Assert.Equal("application/vnd.microsoft.card.adaptive", reply.Attachments.FirstOrDefault()?.ContentType);

// Assert that we started the main dialog.
reply = (IMessageActivity)testAdapter.GetNextReply();
Assert.Equal("Dialog mock invoked", reply.Text);
```

# まとめ

既に前のバージョンでユニットテストをしてきた方にとっては、ある程度なじみがある内容だと思います。次回はダイアログのテストを見ていきます。

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