Bot Builder v4 でのテスト : プロアクティブメッセージのユニットテスト

今回はプロアクティブメッセージを使ったボットのユニットテストを見ていきます。プロアクティブメッセージの利用については [Bot Builder v4 でボット開発 : プロアクティブメッセージを送信する](https://qiita.com/kenakamu/items/988c64788a9682104314) を参照してください。

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

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

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

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

```shell
git checkout article19
git checkout test-article16
```

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

```shell
git checkout -b test-article17
```

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

```shell
git merge article19
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/6aa28421-a26d-6dc0-8bf3-b9bfe54731b5.png)

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

```shell
git mergetool
```

6\. マージ対象のファイルである ScheduleDialog.cs、Startup.cs ともに両方の変更を保持。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/d1e42f3b-6235-9ad3-fcbb-b17f6ee4b974.png)

7\. Controllers、Models、Resources、Services フォルダを myfirstbot フォルダに移動後、ソリューションを Visual Studio で開く。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/627dc7fa-db04-ac8d-4a1e-4b3884ccc4c8.png)

8\. ScheduleDialog.cs の 17 行目にある ```public ScheduleDialog(IServiceProvider serviceProvider, IStringLocalizer<ScheduleDialog> localizer) : base(nameof(ScheduleDialog))``` を削除かコメントアウト。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/0688dc65-f88e-c058-f2f3-a421f57fe7da.png)

9\. ソリューションをビルドして、myfirstbot プロジェクトはビルドが成功、ユニットテストプロジェクトはビルドが失敗することを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/cedfe3a6-f82d-c129-70cd-8b6f9ab145ef.png)

# ユニットテストプロジェクトに必要な要素の追加

プロアクティブメッセージの対応に伴い、ボット本体側のプロジェクトで様々な追加のコードがあります。まずはそれらをユニットテストプロジェクトでも対応してビルドできるようにします。

1\. Helpers/AccessorsFactory.cs を以下のコードに差し替え。

- Event プロパティを追加

```csharp
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Moq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest.Helpers
{
    public  class AccessorsFactory
    {
        static public MyStateAccessors GetAccessors(string language, bool returnUserProfile = true)
        {
            // ストレージとしてインメモリを利用
            IStorage dataStore = new MemoryStorage();
            // それぞれのステートを作成
            var mockStorage = new Mock<IStorage>();
            // User1用に返すデータを作成
            // UserState のキーは <channelId>/users/<userId>
            var dictionary = new Dictionary<string, object>();
            // ユーザープロファイルを設定
            if (returnUserProfile)
            {
                dictionary.Add("test/users/user1", new Dictionary<string, object>()
                {
                    { "UserProfile", new UserProfile() { Name = "Ken", Age = 0, Language = language } }
                });
            }
            // ストレージへの読み書きを設定
            mockStorage.Setup(ms => ms.WriteAsync(It.IsAny<Dictionary<string, object>>(), It.IsAny<CancellationToken>()))
                .Returns((Dictionary<string, object> dic, CancellationToken token) =>
                {
                    foreach (var dicItem in dic)
                    {
                        if (dicItem.Key != "test/users/user1")
                        {
                            if (dictionary.ContainsKey(dicItem.Key))
                            {
                                dictionary[dicItem.Key] = dicItem.Value;
                            }
                            else
                            {
                                dictionary.Add(dicItem.Key, dicItem.Value);
                            }
                        }
                    }

                    return Task.CompletedTask;
                });
            mockStorage.Setup(ms => ms.ReadAsync(It.IsAny<string[]>(), It.IsAny<CancellationToken>()))
                .Returns(() =>
                {
                    return Task.FromResult(result: (IDictionary<string, object>)dictionary);
                });

            // それぞれのステートを作成
            var conversationState = new ConversationState(mockStorage.Object);
            var userState = new UserState(mockStorage.Object);
            var accessors = new MyStateAccessors(userState, conversationState)
            {
                // DialogState を ConversationState のプロパティとして設定
                ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
                // Events を作成
                Events = conversationState.CreateProperty<IList<Microsoft.Graph.Event>>("Events"),
                // UserProfile を作成
                UserProfile = userState.CreateProperty<UserProfile>("UserProfile")
            };

            return accessors;
        }
    }
}
```

2\. ScheduleDialogUnitTest.cs を以下のコードと差し替え。

- コンストラクタの変更に対応
- 予定を返すかどうかを設定可能に変更
- ボットの実装にあったテストを追加

```csharp:ScheduleDialogUnitTest.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Localization;
using Microsoft.Graph;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using myfirstbot.unittest.Helpers;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest
{
    [TestClass]
    public class ScheduleDialogUnitTest
    {
        // ダミーの予定用の時刻
        DateTime datetime = DateTime.Now;

        private (ScheduleNotificationStore scheduleNotificationStore, TestFlow testFlow, StringLocalizer<ScheduleDialog> localizer) ArrangeTest(string language, bool returnEvents)
        {
            var accessors = AccessorsFactory.GetAccessors(language);

            // リソースを利用するため StringLocalizer を作成
            var localizer = StringLocalizerFactory.GetStringLocalizer<ScheduleDialog>();

            // Microsoft Graph 系のモック
            var mockGraphSDK = new Mock<IGraphServiceClient>();
            // ダミーの予定を返す。
            mockGraphSDK.Setup(x => x.Me.CalendarView.Request(It.IsAny<List<QueryOption>>()).GetAsync())
                .ReturnsAsync(() =>
                {
                    var page = new UserCalendarViewCollectionPage();
                    if (returnEvents)
                    {
                        page.Add(new Event()
                        {
                            Subject = "Dummy 1",
                            Start = new DateTimeTimeZone() { DateTime = datetime.ToString() },
                            End = new DateTimeTimeZone() { DateTime = datetime.AddMinutes(30).ToString() }
                        });
                        page.Add(new Event()
                        {
                            Subject = "Dummy 2",
                            Start = new DateTimeTimeZone() { DateTime = datetime.AddMinutes(60).ToString() },
                            End = new DateTimeTimeZone() { DateTime = datetime.AddMinutes(90).ToString() }
                        });
                    }
                    return page;
                });

            // IServiceProvider のモック
            var serviceProvider = new Mock<IServiceProvider>();

            // ScheduleDialog クラスで解決すべきサービスを登録
            serviceProvider.Setup(x => x.GetService(typeof(LoginDialog))).Returns(new LoginDialog(StringLocalizerFactory.GetStringLocalizer<LoginDialog>()));
            serviceProvider.Setup(x => x.GetService(typeof(MSGraphService))).Returns(new MSGraphService(mockGraphSDK.Object));

            // テスト対象のダイアログをインスタンス化
            var loginDialog = new LoginDialog(StringLocalizerFactory.GetStringLocalizer<LoginDialog>());
            // OAuthPrompt をテスト用のプロンプトに差し替え
            loginDialog.ReplaceDialog(new TestOAuthPrompt("login", new OAuthPromptSettings()));

            var scheduleNotificationStore = new ScheduleNotificationStore();
            var scheduleDialog = new ScheduleDialog(accessors, serviceProvider.Object, localizer, scheduleNotificationStore);
            // ログインダイアログを上記でつくったものに差し替え
            scheduleDialog.ReplaceDialog(loginDialog);
            var dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(scheduleDialog);
            dialogs.Add(loginDialog);

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

            // TestFlow の作成
            var testFlow = 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(ScheduleDialog), null, cancellationToken);
                }
                // ダイアログが完了した場合は、Complete をテスト側に返す
                else if (results.Status == DialogTurnStatus.Complete)
                {
                    await turnContext.SendActivityAsync("complete");
                }
            });

            return (scheduleNotificationStore, testFlow, localizer);
        }

        [TestMethod]
        [DataRow("ja-JP")]
        [DataRow("en-US")]
        public async Task ScheduleDialog_ShouldReturnEventsAndSuccessfullyCreateNotification(string language)
        {
            // 言語を指定してテストを作成
            var arrange = ArrangeTest(language, true);

            Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

            await arrange.testFlow
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, $"{datetime.ToString("HH:mm")}-{datetime.AddMinutes(30).ToString("HH:mm")} : Dummy 1");
            })
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, $"{datetime.AddMinutes(60).ToString("HH:mm")}-{datetime.AddMinutes(90).ToString("HH:mm")} : Dummy 2");
            })
            .AssertReply((activity) =>
            {
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["setnotification"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf($"{datetime.ToString("HH:mm")}-Dummy 1") >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf($"{datetime.AddMinutes(60).ToString("HH:mm")}-Dummy 2") >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["nonotification"]) >= 0);
            })
            .Send($"{datetime.ToString("HH:mm")}-Dummy 1")
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, arrange.localizer["notificationset"]);
                Assert.IsTrue(arrange.scheduleNotificationStore.Count == 1);
            })
            .StartTestAsync();
        }

        [TestMethod]
        [DataRow("ja-JP")]
        [DataRow("en-US")]
        public async Task ScheduleDialog_ShouldReturnEventsAndNotSetNotification(string language)
        {
            // 言語を指定してテストを作成
            var arrange = ArrangeTest(language, true);

            Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

            await arrange.testFlow
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, $"{datetime.ToString("HH:mm")}-{datetime.AddMinutes(30).ToString("HH:mm")} : Dummy 1");
            })
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, $"{datetime.AddMinutes(60).ToString("HH:mm")}-{datetime.AddMinutes(90).ToString("HH:mm")} : Dummy 2");
            })
            .AssertReply((activity) =>
            {
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["setnotification"]) >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf($"{datetime.ToString("HH:mm")}-Dummy 1") >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf($"{datetime.AddMinutes(60).ToString("HH:mm")}-Dummy 2") >= 0);
                Assert.IsTrue((activity as Activity).Text.IndexOf(arrange.localizer["nonotification"]) >= 0);
            })
            .Send(arrange.localizer["nonotification"])
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, "complete");
                Assert.IsTrue(arrange.scheduleNotificationStore.Count == 0);
            })
            .StartTestAsync();
        }

        [TestMethod]
        [DataRow("ja-JP")]
        [DataRow("en-US")]
        public async Task ScheduleDialog_ShouldReturnNoEventMessage(string language)
        {
            // 言語を指定してテストを作成
            var arrange = ArrangeTest(language, false);

            Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

            await arrange.testFlow
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.AreEqual((activity as Activity).Text, arrange.localizer["noevents"]);
            })
            .StartTestAsync();
        }
    }
}
```

3\. MenuDialogUnitTest.cs の 34 行目を以下のコードと差し替え。

- ScheduleDialog のコンストラクタ対応

```csharp
serviceProvider.Setup(x => x.GetService(typeof(ScheduleDialog))).Returns(new ScheduleDialog(accessors, serviceProvider.Object, StringLocalizerFactory.GetStringLocalizer<ScheduleDialog>(), new ScheduleNotificationStore()));
```
4\. MyBotUnitTest.cs の 47 行目も同様に差し替え。

```csharp
serviceProvider.Setup(x => x.GetService(typeof(ScheduleDialog))).Returns(new ScheduleDialog(accessors, serviceProvider.Object, StringLocalizerFactory.GetStringLocalizer<ScheduleDialog>(), new ScheduleNotificationStore()));
```

5\. ソリューションをリビルド。してユニットテストプロジェクトもビルドできることを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/a73955ca-f2e1-1048-c79c-b14935752658.png)

6\. 既存のテストが成功することを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214116/3a4f375e-b538-daee-bff4-144a4b8c9642.png)

# プロアクティブメッセージのユニットテスト

プロアクティブメッセージは WebApi として NotificationsController への Post 送信で実装しています。この中で、BotFrameworkAdapter を経由して Activity を送信しています。

## BotFrameworkAdapter と TestAdapter

BotFrameworkAdapter と TestAdapter は BotAdapter を共に継承しているものの、NotificationsController では IAdapterIntegration を解決して BotFrameworkAdapter を取得しているため、これまでの様に BotAdapter を使ったテストが行えません。

プロアクティブメッセージ送信は ContinueConversationAsync メソッドで行われ、以下の処理が実行されています。

- [ContinueConversationAsync メソッド](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder/BotFrameworkAdapter.cs#L124) 内で connectorClient を取得して [RunPipelineAsync](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder/BotAdapter.cs#L158) を実行し、TurnContext に登録
- プロアクティブメッセージの場合は [callback を実行](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder/BotAdapter.cs#L186) 
- 設定しているコールバックでは [turnContext.SendActivityAsync](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder/TurnContext.cs#L160) を実行
- その後各種処理を経て、再度 [BotFrameworkAdapter の SendActivitiesAsync](https://github.com/Microsoft/botbuilder-dotnet/blob/867ec4780022674f50cfb721d95ea29a581fca6a/libraries/Microsoft.Bot.Builder/BotFrameworkAdapter.cs#L263) を実行。
- TurnContext に登録した connectorClient を取得して ```connectorClient.Conversations.SendToConversationAsync``` 実行

つまり ConnectorClient を差し替えることができればテスト可能です。またソースコードから登録したミドルウェアが先に実行されることもわかるため、ここではテスト用の ConnectorClient に差し替えるミドルウェアを作ります。

IConnectorClient の使い方はソースコードを眺めてみてください。

1\. ユニットテストプロジェクトの Helpers フォルダに TestConnectorClient フォルダを作成。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/ef08682e-a5ad-0289-3a84-a1130e194ab8.png)

2\. TestConnectorClientValidator.cs を追加して以下のコードを張り付け。内容は [TestFlow.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/4.3/libraries/Microsoft.Bot.Builder/Adapters/TestFlow.cs) を参考に作成。

```csharp:TestConnectorClientValidator.cs
using Microsoft.Bot.Schema;
using System;
using System.Collections.Generic;

namespace myfirstbot.unittest.Helpers
{
    public class TestConnectorClientValidator
    {
        private Queue<IActivity> storedActivities = new Queue<IActivity>();

        private IActivity GetNextActivity()
        {
            return storedActivities.Dequeue();
        }

        public void AddActivity(IActivity activity)
        {
            storedActivities.Enqueue(activity);
        }

        public TestConnectorClientValidator AssertReply(Action<IActivity> validateActivity)
        {
            validateActivity(GetNextActivity());
            return this;
        }
    }
}
```
3\. TestConnectorClientConversation.cs を追加し以下のコードを張り付け。

- 実際に呼ばれる ReplyToActivityWithHttpMessagesAsync のみ実装

```csharp:TestConnectorClientConversation.cs
using Microsoft.Bot.Connector;
using Microsoft.Bot.Schema;
using Microsoft.Rest;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest.Helpers
{
    public class TestConnectorClientConversation : IConversations
    {
        private TestConnectorClientValidator testConnectorClientValidator;

        public TestConnectorClientConversation(TestConnectorClientValidator testConnectorClientValidator)
        {
            this.testConnectorClientValidator = testConnectorClientValidator;
        }

        public Task<HttpOperationResponse<ConversationResourceResponse>> CreateConversationWithHttpMessagesAsync(ConversationParameters parameters, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse> DeleteActivityWithHttpMessagesAsync(string conversationId, string activityId, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse> DeleteConversationMemberWithHttpMessagesAsync(string conversationId, string memberId, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<IList<ChannelAccount>>> GetActivityMembersWithHttpMessagesAsync(string conversationId, string activityId, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<IList<ChannelAccount>>> GetConversationMembersWithHttpMessagesAsync(string conversationId, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<PagedMembersResult>> GetConversationPagedMembersWithHttpMessagesAsync(string conversationId, int? pageSize = null, string continuationToken = null, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<ConversationsResult>> GetConversationsWithHttpMessagesAsync(string continuationToken = null, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<ResourceResponse>> ReplyToActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default(CancellationToken))
        {
            testConnectorClientValidator.AddActivity(activity);
            return Task.FromResult(new HttpOperationResponse<ResourceResponse>());
        }

        public Task<HttpOperationResponse<ResourceResponse>> SendConversationHistoryWithHttpMessagesAsync(string conversationId, Transcript transcript, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<ResourceResponse>> SendToConversationWithHttpMessagesAsync(string conversationId, Activity activity, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<ResourceResponse>> UpdateActivityWithHttpMessagesAsync(string conversationId, string activityId, Activity activity, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }

        public Task<HttpOperationResponse<ResourceResponse>> UploadAttachmentWithHttpMessagesAsync(string conversationId, AttachmentData attachmentUpload, Dictionary<string, List<string>> customHeaders = null, CancellationToken cancellationToken = default)
        {
            throw new NotImplementedException();
        }
    }
}
```

4\. TestConnectorClient.cs を追加して以下のコードを張り付け。

```csharp:TestConnectorClient.cs
using Microsoft.Bot.Connector;
using Microsoft.Rest;
using Newtonsoft.Json;
using System;

namespace myfirstbot.unittest.Helpers
{
    public class TestConnectorClient : IConnectorClient
    {
        public TestConnectorClient(TestConnectorClientValidator testConnectorClientValidator)
        {
            Conversations = new TestConnectorClientConversation(testConnectorClientValidator);
        }

        public Uri BaseUri
        {
            get { return new Uri("https://test.com"); }
            set { throw new NotImplementedException(); }
        }

        public JsonSerializerSettings SerializationSettings
        {
            get { throw new NotImplementedException(); }
            set { throw new NotImplementedException(); }
        }

        public JsonSerializerSettings DeserializationSettings
        {
            get { throw new NotImplementedException(); }
            set { throw new NotImplementedException(); }
        }

        public ServiceClientCredentials Credentials
        {
            get { throw new NotImplementedException(); }
            set { throw new NotImplementedException(); }
        }

        public IAttachments Attachments
        {
            get { throw new NotImplementedException(); }
            set { throw new NotImplementedException(); }
        }

        public IConversations Conversations { get; }

        public void Dispose()
        {
        }
    }
}
```

5\. TestConnectorClientMiddleware.cs を追加して以下のコードを張り付け。

```csharp:TestConnectorClientMiddleware.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Connector;
using System.Threading;
using System.Threading.Tasks;

namespace myfirstbot.unittest.Helpers
{
    public class TestConnectorClientMiddleware : IMiddleware
    {
        private TestConnectorClientValidator testConnectorClientValidator;
        public TestConnectorClientMiddleware(TestConnectorClientValidator testConnectorClientValidator)
        {
            this.testConnectorClientValidator = testConnectorClientValidator;
        }
        public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (turnContext.Activity.ServiceUrl.Contains("https://test.com"))
            {
                if (turnContext.TurnState["Microsoft.Bot.Connector.IConnectorClient"] is ConnectorClient)
                {
                    turnContext.TurnState.Remove("Microsoft.Bot.Connector.IConnectorClient");
                    turnContext.TurnState.Add("Microsoft.Bot.Connector.IConnectorClient",
                        new TestConnectorClient(testConnectorClientValidator)
                    );
                }
            }

            await next.Invoke(cancellationToken);
        }
    }
}
```

## NotificationContollers ユニットテスト

準備が出来たのでテストを作成します。

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

- 通知に使うダミーデータの作成
- BotFrameworkAdapter を作成して上記で作成したミドルウェアを指定
- BotConfiguration を最低限の情報で作成

```csharp:NotificationsControllerUnitTest.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration;
using Microsoft.Bot.Configuration;
using Microsoft.Bot.Connector.Authentication;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Localization;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using myfirstbot.unittest.Helpers;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;

namespace myfirstbot.unittest
{
    [TestClass]
    public class NotificationsControllerUnitTest
    {
        // ダミーのデータ作成
        ScheduleNotification scheduleNotification = new ScheduleNotification
        {
            Title = "Dummy 1",
            NotificationTime = DateTime.Now,
            StartTime = DateTime.Now,
            WebLink = "dummylink",
            ConversationReference = new ConversationReference(
                    Guid.NewGuid().ToString(),
                    user: new ChannelAccount("user1", "User1"),
                    bot: new ChannelAccount("bot", "Bot"),
                    conversation: new ConversationAccount(false, "convo1", "1", "test"),
                    channelId: "test",
                    serviceUrl: "https://test.com"),
        };

        private (NotificationsController notificationsController,
            ScheduleNotificationStore scheduleNotificationStore,
            StringLocalizer<NotificationsController> localizer)
            ArrangeTest(string language, TestConnectorClientValidator testConnectorClientValidator)
        {
            var accessors = AccessorsFactory.GetAccessors(language);

            var scheduleNotificationStore = new ScheduleNotificationStore();
            scheduleNotificationStore.Add(scheduleNotification);

            var appId = Guid.NewGuid().ToString();

            // アダプターを作成し必要なミドルウェアを追加
            var adapter = new BotFrameworkAdapter(new SimpleCredentialProvider(appId, ""))
                .Use(new TestConnectorClientMiddleware(testConnectorClientValidator));

            // IServiceProvider のモック
            var serviceProvider = new Mock<IServiceProvider>();
            // 解決すべきサービスを登録
            serviceProvider.Setup(x => x.GetService(typeof(IAdapterIntegration))).Returns(adapter);

            var localizer = StringLocalizerFactory.GetStringLocalizer<NotificationsController>();
            var botConfiguration = new BotConfiguration()
            {
                Services = new List<ConnectedService>()
                {
                    new EndpointService() {
                        Id = "DummyId",
                        Endpoint = "https://test.com",
                        AppId = appId, AppPassword = "" }
                }
            };

            // コントローラーの作成
            NotificationsController notificationsController =
                new NotificationsController(serviceProvider.Object, localizer, botConfiguration, scheduleNotificationStore);

            return (notificationsController, scheduleNotificationStore, localizer);
        }
    }
}
```

2\. テストメソッドの追加。

- DataRow を使って言語を指定
- testConnectorClientValidator で結果を検証

```csharp
[TestMethod]
[DataRow("ja-JP")]
[DataRow("en-US")]
public void NotificationService_ShouldSendNotification(string language)
{
    var testConnectorClientValidator = new TestConnectorClientValidator();
    // 言語を指定してテストを作成
    var arrange = ArrangeTest(language, testConnectorClientValidator);

    Thread.CurrentThread.CurrentCulture = new CultureInfo(language);
    Thread.CurrentThread.CurrentUICulture = new CultureInfo(language);

    // Post メソッドの呼び出し
    arrange.notificationsController.Post();
    // 結果の検証
    testConnectorClientValidator.AssertReply((activity)=>{
        Assert.AreEqual( 
            (activity as Activity).Text, 
            $"{arrange.localizer["notification"]}:{scheduleNotification.StartTime.ToString("HH:mm")} - [{scheduleNotification.Title}]({scheduleNotification.WebLink})");
    });
}
```

3\. プロジェクトをビルドしてテストを実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/4c29f725-8e5f-52c4-fc0b-cab387744d78.png)

# まとめ

今回は初めて TestFlow と TestAdapter が使えないパターンでした。なんとかテスト出来ましたがもう少しスマートな方法がある気もします。アイデアがあれば是非コメントしてください。

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

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