Bot Builder v4 でのテスト : Microsoft Graph 連携のテスト

今回は Microsoft Graph 連携のユニットテストを見ていきます。 Microsoft Graph 連携については [Bot Builder v4 でボット開発 : Microsoft Graph 連携](https://qiita.com/kenakamu/items/d6e05454d04d63904305) を参照してください。

# ソリューションの準備
ボットのコードは [Bot Builder v4 でボット開発 : Microsoft Graph 連携](https://qiita.com/kenakamu/items/d6e05454d04d63904305) で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article8 のコードをベースに、article10 ブランチのコードをマージしてテストを開発します。

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

```shell
git clone https://github.com/kenakamu/botbuilderv4completeguide/
cd botbuilderv4completeguide
```
2\. 以下のコマンドで article10 をチェックアウトした後、test-article8 をチェックアウトしてどちらもローカルにコピー。

```shell
git checkout article10
git checkout test-article8
```

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

```shell
git checkout -b test-article9
```

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

```shell
git merge article10
```
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/c793ebd9-20be-10df-fd1b-0f8b11ce6661.png)

5\. ルートに新しいダイアログが入った Dialogs フォルダと Services フォルダが出来るので、myfirstbot フォルダに移動。

6\. myfirstbot.sln を Visual Studio で開いてソリューションをビルド。既存のテストを実行。確認で予定ダイアログのテストが Microsoft Graph 連携があるため失敗していることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/968f3993-9fbd-9f89-e742-baa48b9267dc.png)

# Microsoft Graph を含むダイアログのテスト

Microsoft Graph は外部サービスのため、モックしてテストします。Microsoft Graph を呼ぶ個所は、以下の要素があります。

- MSGraph SDK
- 自分のコードである MSGraphService

## Microsoft Graph SDK

まず MSGraph SDK のモックについては、GraphServiceClient で IGraphServiceClient インターフェースを継承してるものの、コンストラクタでトークンを渡すような実装となっているため IoC 化が少し面倒という課題があります。

## MSGraphService

こちらもインターフェースを継承しておらず、利用される各ダイアログで直接インスタンス化されているためモックが行えません。

MSGraphService は自分のコードであるためテストは必須であることから、今回は以下の方向で改修します。

- MSGraph SDK のモック化
- MSGraph SDK と MSGraphService の IoC 化

# ボット側の改修

上記で説明した通り、ボット側のコードで MSGraph SDK、MSGraphService ともに IoC 化できるように対処していきます。

1\. MSGraphService.cs を以下の様に変更。

- コンストラクタで IGraphServiceClient を受け取り
- Token をパブリックプロパティに変更

```csharp
using Microsoft.Graph;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http.Headers;
using System.Threading.Tasks;

public class MSGraphService
{
    public string Token { get; set; }

    private IGraphServiceClient graphClient;


    public MSGraphService(IGraphServiceClient graphClient)
    {
        this.graphClient = graphClient;
    }

    public async Task<User> GetMeAsync()
    {
        var graphClient = GetAuthenticatedClient();
        var me = await graphClient.Me.Request().GetAsync();
        return me;
    }

    public async Task<Stream> GetPhotoAsync()
    {
        var graphClient = GetAuthenticatedClient();
        var profilePhoto = await graphClient.Me.Photo.Content.Request().GetAsync();       
        return profilePhoto;
    }

    public async Task UpdatePhotoAsync(Stream image)
    {
        var graphClient = GetAuthenticatedClient();
        await graphClient.Me.Photo.Content.Request().PutAsync(image);
        return;
    }

    public async Task<List<Event>> GetScheduleAsync()
    {
        var graphClient = GetAuthenticatedClient();
        var queryOption = new List<QueryOption>(){
            new QueryOption("startDateTime", DateTime.Today.ToString()),
            new QueryOption("endDateTime", DateTime.Today.AddDays(1).ToString())
        };
        var events = await graphClient.Me.CalendarView.Request(queryOption).GetAsync();
        return events.CurrentPage.ToList();
    }

    private IGraphServiceClient GetAuthenticatedClient()
    {
        if(string.IsNullOrEmpty(Token))
        {
            throw new ArgumentNullException("Token is null");
        }

        if (graphClient is GraphServiceClient)
        {
            (graphClient.AuthenticationProvider as DelegateAuthenticationProvider).AuthenticateRequestAsyncDelegate =
                    requestMessage =>
                    {
                    // トークンを指定
                    requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", Token);
                    // タイムゾーンを指定
                    requestMessage.Headers.Add("Prefer", "outlook.timezone=\"" + TimeZoneInfo.Local.Id + "\"");
                        return Task.CompletedTask;
                    };
        }
        return graphClient;
    }
}
```
2\. Startup.cs の ConfigureServices に GraphServiceClient と MSGraphServicesを登録。

```csharp:Startup.cs
using Microsoft.Graph;
```
- GraphServiceClient の引数は DelegateAuthenticationProvider
- DelegateAuthenticationProvider の引数はダミーで設定

```csharp:ConfigureServicesメソッド
// MSGraph 関連 を IoC に登録
services.AddTransient<IGraphServiceClient>(sp => new GraphServiceClient(new DelegateAuthenticationProvider((request)=> { return Task.CompletedTask; })));
services.AddTransient(sp => new MSGraphService(sp.GetRequiredService<IGraphServiceClient>()));
```
3\. MyBot.cs のコンストラクタで MSGraphServices を受け取るように変更。

```csharp:MyBot.csコンストラクタ
public MyBot(MyStateAccessors accessors, IRecognizer luisRecognizer, MSGraphService graphClient)
{
    this.accessors = accessors;
    this.luisRecognizer = luisRecognizer;
    this.dialogs = new DialogSet(accessors.ConversationDialogState);

    // コンポーネントダイアログを追加
    dialogs.Add(new ProfileDialog(accessors));
    dialogs.Add(new MenuDialog(graphClient));
    dialogs.Add(new WeatherDialog());
    dialogs.Add(new ScheduleDialog(graphClient));
    dialogs.Add(new PhotoUpdateDialog(graphClient));
}
```

4\. MenuDialog.cs のコンストラクタで MSGraphServices を受け取るように変更。

```csharp:MenuDialog.csコンストラクタ
public MenuDialog(MSGraphService graphClient) : base(nameof(MenuDialog))
{
    // ウォーターフォールのステップを定義。処理順にメソッドを追加。
    var waterfallSteps = new WaterfallStep[]
    {
        ShowMenuAsync,
        ProcessInputAsync,
        LoopMenu
    };

    // ウォーターフォールダイアログと各種プロンプトを追加
    AddDialog(new WaterfallDialog("menu", waterfallSteps));
    AddDialog(new ChoicePrompt("choice"));
    AddDialog(new WeatherDialog());
    AddDialog(new ScheduleDialog(graphClient));
}
```

5\. ScheduleDialog.cs を以下の様に変更。

- MSGraphService をコンストラクタで受け取り
- 利用箇所のコードを変更に合わせる

```csharp
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;

public class ScheduleDialog : ComponentDialog
{
    private MSGraphService graphClient;
    public ScheduleDialog(MSGraphService graphClient) : base(nameof(ScheduleDialog))
    {
        this.graphClient = graphClient;
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            LoginAsync,
            GetScheduleAsync,
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("schedule", waterfallSteps));
        AddDialog(new LoginDialog());
    }

    private async Task<DialogTurnResult> LoginAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        return await stepContext.BeginDialogAsync(nameof(LoginDialog), cancellationToken: cancellationToken);
    }
    private async Task<DialogTurnResult> GetScheduleAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // ログインの結果よりトークンを取得
        var accessToken = (string)stepContext.Result;

        if (!string.IsNullOrEmpty(accessToken))
        {
            this.graphClient.Token = accessToken;
            var events = await graphClient.GetScheduleAsync();
            events.ForEach(async x =>
            {
                await stepContext.Context.SendActivityAsync($"{System.DateTime.Parse(x.Start.DateTime).ToString("HH:mm")}-{System.DateTime.Parse(x.End.DateTime).ToString("HH:mm")} : {x.Subject}", cancellationToken: cancellationToken);
            });
        }
        else
            await stepContext.Context.SendActivityAsync($"サインインに失敗しました。", cancellationToken: cancellationToken);

        return await stepContext.EndDialogAsync(true, cancellationToken);
    }
}
```

6\. PhotoUploadDialog.cs も同様に変更。

```csharp
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Schema;

public class PhotoUpdateDialog : ComponentDialog
{
    private MSGraphService graphClient;
    public PhotoUpdateDialog(MSGraphService graphClient) : base(nameof(PhotoUpdateDialog))
    {
        this.graphClient = graphClient;
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            LoginAsync,
            UpdatePhotoAsync,
            GetPhotoAsync,
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("updatephoto", waterfallSteps));
        AddDialog(new LoginDialog());
    }

    private async Task<DialogTurnResult> LoginAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // 認証ダイアログはテキストがないと落ちるため、ダミーを設定
        stepContext.Context.Activity.Text = "dummy";
        return await stepContext.BeginDialogAsync(nameof(LoginDialog), cancellationToken: cancellationToken);       
    }
    private async Task<DialogTurnResult> UpdatePhotoAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // ログインの結果よりトークンを取得
        var accessToken = (string)stepContext.Result;
        // 親ダイアログより渡されたイメージを取得

        // 添付ファイルを取得して MemoryStream に格納
        var connector = new ConnectorClient(new Uri(stepContext.Context.Activity.ServiceUrl));
        var image = await connector.HttpClient.GetStreamAsync(stepContext.Options.ToString());
        if (!string.IsNullOrEmpty(accessToken))
        {
            graphClient.Token = accessToken;
            await graphClient.UpdatePhotoAsync(image);
        }
        else
            await stepContext.Context.SendActivityAsync($"サインインに失敗しました。", cancellationToken: cancellationToken);

        return await stepContext.NextAsync(accessToken, cancellationToken);
    }

    private async Task<DialogTurnResult> GetPhotoAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // 前の処理よりトークンを取得
        var accessToken = (string)stepContext.Result;
        if (!string.IsNullOrEmpty(accessToken))
        {
            // 返信の作成
            var reply = stepContext.Context.Activity.CreateReply();
            // 現在の写真を取得
            graphClient.Token = accessToken;
            var image = await graphClient.GetPhotoAsync();
            byte[] buffer = new byte[16 * 1024];
            using (MemoryStream ms = new MemoryStream())
            {
                int read;
                while ((read = image.Read(buffer, 0, buffer.Length)) > 0)
                {
                    ms.Write(buffer, 0, read);
                }

                var image64 = System.Convert.ToBase64String(ms.ToArray());
                // 返信に画像を設定
                reply.Attachments.Add(new Attachment(
                    contentType: "image/png",
                    contentUrl: $"data:image/png;base64,{image64}"
                    ));
            }
            await stepContext.Context.SendActivityAsync(reply, cancellationToken);
        }
        else
            await stepContext.Context.SendActivityAsync($"サインインに失敗しました。", cancellationToken: cancellationToken);

        return await stepContext.EndDialogAsync(true, cancellationToken);
    }
}
```
7\. プロジェクトをビルドしてエラーが無いことを確認。

# テストの実装

今回追加、変更するテストは主に以下の内容です。

- ログインダイアログが独立しているため、その対応
- MSGraph SDK で取得されるデータのモック
- 添付ファイルの扱い

## 予定ダイアログのテスト

TestOAuthPrompt を使うために以下の 2 つを行います。

- ログインダイアログの OAuthPrompt を前回のように TestOAuthPrompt に入れ替え
- 元のダイアログでログインダイアログを独自に作ったものに入れ替え

また MSGraph SDK のモック化を行います。

1\. ScheduleDialog.cs を以下の様に変更。

- IGraphServiceClient で MSGraph SDK をモック化
- 予定ダイアログとログインダイアログを作成して、必要に応じてプロンプトを差し替え
- 予定を取得するテストを追加

```csharp
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Graph;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using myfirstbot.unittest.Helpers;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

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

        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")
            };

            // Microsoft Graph 系のモック
            var mockGraphSDK = new Mock<IGraphServiceClient>();
            // ダミーの予定を返す。
            mockGraphSDK.Setup(x => x.Me.CalendarView.Request(It.IsAny<List<QueryOption>>()).GetAsync())
                .ReturnsAsync(() => 
                {
                    var page = new UserCalendarViewCollectionPage();
                    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;
                });
            var msGraphService = new MSGraphService(mockGraphSDK.Object);
            
            // テスト対象のダイアログをインスタンス化
            var loginDialog = new LoginDialog();
            // OAuthPrompt をテスト用のプロンプトに差し替え
            loginDialog.ReplaceDialog(new TestOAuthPrompt("login", new OAuthPromptSettings()));
            var scheduleDialog = new ScheduleDialog(msGraphService);
            // ログインダイアログを上記でつくったものに差し替え
            scheduleDialog.ReplaceDialog(loginDialog);
            var dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(scheduleDialog);
            dialogs.Add(loginDialog);

            // アダプターを作成し必要なミドルウェアを追加
            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(ScheduleDialog), null, cancellationToken);
                }
            });
        }

        [TestMethod]
        public async Task ScheduleDialog_ShouldReturnEvents()
        {
            await ArrangeTestFlow()
            .Send("foo")
            .AssertReply($"{datetime.ToString("HH:mm")}-{datetime.AddMinutes(30).ToString("HH:mm")} : Dummy 1")
            .AssertReply($"{datetime.AddMinutes(60).ToString("HH:mm")}-{datetime.AddMinutes(90).ToString("HH:mm")} : Dummy 2")
            .StartTestAsync();
        }
    }
}
```

2\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/795f436d-574a-51ce-7300-10f29fd56225.png)

## MyBot のテスト

今回 MyBot からも添付ファイルを送るパスが増えているためテストします。添付ファイルを送るとプロファル写真の更新ダイアログが呼ばれますがそこをテストする必要はないため、今回はいくつか工夫してテストしてみました。

1\. MyBot の DialogSet を差し替えられるよう拡張メソッドを追加。Helpers フォルダに　MyBotExtensions.cs を追加してコードを貼り付け。

```csharp:MyBotExtensions.cs
using Microsoft.Bot.Builder.Dialogs;
using System.Collections.Generic;
using System.Reflection;

namespace myfirstbot.unittest.Helpers
{
    public static class MyBotExtensions
    {
        public static void ReplaceDialog(this MyBot bot, Dialog dialog)
        {
            var field = typeof(MyBot).GetField("dialogs", BindingFlags.Instance | BindingFlags.NonPublic);
            var dialogSet = field.GetValue(bot) as DialogSet;
            field = typeof(DialogSet).GetField("_dialogs", BindingFlags.Instance | BindingFlags.NonPublic);
            var dialogs = field.GetValue(dialogSet) as Dictionary<string, Dialog>;
            dialogs[dialog.Id] = dialog;
        }
    }
}
```

2\. ダミーのダイアログを作れるよう、同じく helpers フォルダに DummyDialog.cs を追加して、以下コードを張り付け。

- コンストラクタでダイアログの Id が指定可能

```csharp:DummyDialog.cs
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;

public class DummyDialog : ComponentDialog
{
    public DummyDialog(string dialogId) : base(dialogId)
    {   
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            CompleteAsync,
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("dummy", waterfallSteps));
    }

    private async Task<DialogTurnResult> CompleteAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        return await stepContext.EndDialogAsync(true);
    }    
}
```
3\. MyBotUnitTest.cs の ArrangeTest メソッドで、MyBot インスタンス作成直後に PhotoUpdateDialog を差し替えるコードを追加。

```csharp
// 差し替える必要があるものを差し替え
var photoUpdateDialog = new DummyDialog(nameof(PhotoUpdateDialog));
bot.ReplaceDialog(photoUpdateDialog);
```

4\. MyBotTestUnit.cs に以下のテストメソッドを追加。

```csharp
[TestMethod]
public async Task MyBot_ShouldGoToPhotoUpdateDialog()
{
    var arrange = ArrangeTest(true);
    var attachmentActivity = new Activity(ActivityTypes.Message)
    {
        Id = "test",
        From = new ChannelAccount("TestUser", "Test User"),
        ChannelId = "UnitTest",
        ServiceUrl = "https://example.org",
        Attachments = new List<Microsoft.Bot.Schema.Attachment>()
        {
            new Microsoft.Bot.Schema.Attachment(
                "image/pgn",
                "https://github.com/apple-touch-icon.png"
            )
        }
    };

    await arrange.testFlow
    .Send(attachmentActivity)
    .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();
}
```

5\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/27b04d87-0cbb-9d82-9ae1-c57bbb9a275a.png)

## プロファイル写真更新ダイアログのテスト

予定ダイアログ同様認証がある他、画像の扱いがあります。ユニットテストは外部サービスに接続しないことが原則ですが、ボット本体の中で ConnectorClient を作って画像を取得する箇所があるため、テストをシンプルにするため今回そこだけは外部アドレスにある画像を取得するようにしました。

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

- ダイアログ呼び出し時に引数に画像のアドレスを渡す
- Assert は画像データの比較ではなく添付ファイルがあるかだけでチェック <br>
※データレベルで比較したほうが良いが今回は単純化

```csharp:PhotoUpadteDialogUnitTest.cs
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Graph;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using myfirstbot.unittest.Helpers;
using System.IO;
using System.Threading.Tasks;

namespace myfirstbot.unittest
{
    [TestClass]
    public class PhotoUpdateDialogUnitTest
    {
        private string attachmentUrl = "https://github.com/apple-touch-icon.png";

        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")
            };

            // Microsoft Graph 系のモック
            var mockGraphSDK = new Mock<IGraphServiceClient>();
            // プロファイル写真の操作をモック
            mockGraphSDK.Setup(x => x.Me.Photo.Content.Request(null).PutAsync(It.IsAny<Stream>()))
                .Returns(Task.FromResult(default(Stream)));

            mockGraphSDK.Setup(x => x.Me.Photo.Content.Request(null).GetAsync())
                .Returns(async () =>
                {
                    return new MemoryStream();
                });

            var msGraphService = new MSGraphService(mockGraphSDK.Object);
            
            // テスト対象のダイアログをインスタンス化
            var loginDialog = new LoginDialog();
            // OAuthPrompt をテスト用のプロンプトに差し替え
            loginDialog.ReplaceDialog(new TestOAuthPrompt("login", new OAuthPromptSettings()));
            var photoUpdateDialog = new PhotoUpdateDialog(msGraphService);
            // ログインダイアログを上記でつくったものに差し替え
            photoUpdateDialog.ReplaceDialog(loginDialog);
            var dialogs = new DialogSet(accessors.ConversationDialogState);
            dialogs.Add(photoUpdateDialog);
            dialogs.Add(loginDialog);

            // アダプターを作成し必要なミドルウェアを追加
            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(PhotoUpdateDialog), attachmentUrl, cancellationToken);
                }
            });
        }

        [TestMethod]
        public async Task PhotoUpdateDialogShouldUpdateAndReturnPicture()
        {            
            await ArrangeTestFlow()
            .Send("foo")
            .AssertReply((activity) =>
            {
                Assert.IsTrue((activity as Activity).Attachments.Count == 1);
            })
            .StartTestAsync();
        }
    }
}
```

2\. テストを実行して結果を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/868673af-f5a5-f9be-b099-fd55d4768030.png)

# まとめ

今回は外部サービスを利用した際に、きちんとインターフェース化をしていないことから、テストが出来ないという事が分かりました。サンプルであってもインターフェース化できるものはした方が良いと反省しています。
また画像のテストなどはどこまでをユニットテスト内で完結できるかも含めて実装が必要そうです。

本来はボットのコードを直した場合はしかるべきブランチを更新すべきですが、ボット開発本編側の構成を変える予定がないので、テストシリーズはこのままうまくマージしながらやっていきます。

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

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