今回は Microsoft Graph 連携のユニットテストを見ていきます。 Microsoft Graph 連携については Bot Builder v4 でボット開発 : Microsoft Graph 連携 を参照してください。
ソリューションの準備
ボットのコードは Bot Builder v4 でボット開発 : Microsoft Graph 連携 で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article8 のコードをベースに、article10 ブランチのコードをマージしてテストを開発します。
1. 任意のフォルダでレポジトリをクローン。
git clone https://github.com/kenakamu/botbuilderv4completeguide/
cd botbuilderv4completeguide
2. 以下のコマンドで article10 をチェックアウトした後、test-article8 をチェックアウトしてどちらもローカルにコピー。
git checkout article10
git checkout test-article8
3. 以下コマンドで test-article9 ブランチを作成。
git checkout -b test-article9
4. article10 のブランチをマージ。
git merge article10
5. ルートに新しいダイアログが入った Dialogs フォルダと Services フォルダが出来るので、myfirstbot フォルダに移動。
6. myfirstbot.sln を Visual Studio で開いてソリューションをビルド。既存のテストを実行。確認で予定ダイアログのテストが Microsoft Graph 連携があるため失敗していることを確認。
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 をパブリックプロパティに変更
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を登録。
using Microsoft.Graph;
- GraphServiceClient の引数は DelegateAuthenticationProvider
- DelegateAuthenticationProvider の引数はダミーで設定
// 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 を受け取るように変更。
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 を受け取るように変更。
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 をコンストラクタで受け取り
- 利用箇所のコードを変更に合わせる
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 も同様に変更。
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 をモック化
- 予定ダイアログとログインダイアログを作成して、必要に応じてプロンプトを差し替え
- 予定を取得するテストを追加
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();
}
}
}
MyBot のテスト
今回 MyBot からも添付ファイルを送るパスが増えているためテストします。添付ファイルを送るとプロファル写真の更新ダイアログが呼ばれますがそこをテストする必要はないため、今回はいくつか工夫してテストしてみました。
1. MyBot の DialogSet を差し替えられるよう拡張メソッドを追加。Helpers フォルダに 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 が指定可能
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 を差し替えるコードを追加。
// 差し替える必要があるものを差し替え
var photoUpdateDialog = new DummyDialog(nameof(PhotoUpdateDialog));
bot.ReplaceDialog(photoUpdateDialog);
4. MyBotTestUnit.cs に以下のテストメソッドを追加。
[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();
}
プロファイル写真更新ダイアログのテスト
予定ダイアログ同様認証がある他、画像の扱いがあります。ユニットテストは外部サービスに接続しないことが原則ですが、ボット本体の中で ConnectorClient を作って画像を取得する箇所があるため、テストをシンプルにするため今回そこだけは外部アドレスにある画像を取得するようにしました。
1. PhotoUpadteDialogUnitTest.cs を追加して以下のコードを張り付け。
- ダイアログ呼び出し時に引数に画像のアドレスを渡す
- Assert は画像データの比較ではなく添付ファイルがあるかだけでチェック
※データレベルで比較したほうが良いが今回は単純化
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();
}
}
}
まとめ
今回は外部サービスを利用した際に、きちんとインターフェース化をしていないことから、テストが出来ないという事が分かりました。サンプルであってもインターフェース化できるものはした方が良いと反省しています。
また画像のテストなどはどこまでをユニットテスト内で完結できるかも含めて実装が必要そうです。
本来はボットのコードを直した場合はしかるべきブランチを更新すべきですが、ボット開発本編側の構成を変える予定がないので、テストシリーズはこのままうまくマージしながらやっていきます。