今回は中断処理とグローバルコマンドのユニットテストを見ていきます。中断処理やグローバルコマンドについては Bot Builder v4 でボット開発 : 中断処理とグローバルコマンドのハンドリング を参照してください。
ソリューションの準備
ボットのコードは Bot Builder v4 でボット開発 : 中断処理とグローバルコマンドのハンドリング で開発したものを使うので、コードの詳細はそちらの記事を参照してください。また前回の記事で開発した test-article5 のコードをベースに、article7 ブランチのコードをマージしてテストを開発します。
1. 任意のフォルダでレポジトリをクローン。
git clone https://github.com/kenakamu/botbuilderv4completeguide/
cd botbuilderv4completeguide
2. 以下のコマンドで article7 をチェックアウトした後、test-article5 をチェックアウトしてどちらもローカルにコピー。
git checkout article7
git checkout test-article5
3. 以下コマンドで test-article6 ブランチを作成。
git checkout -b test-article6
4. article7 のブランチをマージ。
git merge article7
5. 競合なくマージできるので、myfirstbot.sln を Visual Studio で開いてソリューションをビルド後、全てのテストを実行。実装が変わっていない場所のテストは問題ないがロジックが変わっているところはエラーとなることを確認。
中断処理とグローバルコマンド
実装側では特別な処理が必要でしたが、テストとしては通常の会話フローと変わらないため、テストが網羅できるようにするだけです。
プロファイルダイアログのテスト
プロファイルダイアログでは、以下の点が変更になっている。
- プロファイル登録時の選択肢が、「はい、名前を変更する、年齢を変更する」に変更
- 選択肢によって名前と年齢確認のフローに再び戻る
これらを考慮してプロファイルダイアログのテストを変更。また全てのテストで使う準備部分 (Arrange) をプライベートメソッドとして抜き出します。
1. ProfileDialogUnitTest.cs に各テストで使う TestFlow を作成するメソッドを追加。
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")
};
// テスト対象のダイアログをインスタンス化
var dialogs = new DialogSet(accessors.ConversationDialogState);
dialogs.Add(new ProfileDialog(accessors));
// アダプターを作成し必要なミドルウェアを追加
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(ProfileDialog), null, cancellationToken);
}
});
}
2. ProfileDialog_ShouldSaveProfile メソッドを以下の様に変更。
- ArrangeTestFlow でテストをアレンジ
- 最終確認の選択肢を更新
public async Task ProfileDialog_ShouldSaveProfile()
{
// テスト用の変数
var name = "Ken";
var age = "42";
await ArrangeTestFlow()
.Test("foo", "名前を入力してください。")
.Test(name, "年齢を聞いてもいいですか? (1) はい または (2) いいえ")
.Test("はい", "年齢を入力してください。")
.Test(age, $"次の情報で登録します。いいですか?{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい、 (2) 名前を変更する、 または (3) 年齢を変更する")
.Test("はい", "プロファイルを保存します。")
.StartTestAsync();
}
3. ProfileDialog_ShouldSaveProfileWithoutAge を以下のコードに変更。
- ArrangeTestFlow でテストをアレンジ
- 最終確認の選択肢を更新
[TestMethod]
public async Task ProfileDialog_ShouldSaveProfileWithoutAge()
{
// テスト用の変数
var name = "Ken";
var age = 0;
// テストの追加と実行
await ArrangeTestFlow()
.Test("foo", "名前を入力してください。")
.Test(name, "年齢を聞いてもいいですか? (1) はい または (2) いいえ")
.Test("いいえ", $"次の情報で登録します。いいですか?{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい、 (2) 名前を変更する、 または (3) 年齢を変更する")
.Test("はい", "プロファイルを保存します。")
.StartTestAsync();
}
4. ProfileDialog_ShouldNotSaveProfile テストは削除。
5. 名前を変更するパターンと年齢を変更するパターンのテストをそれぞれ追加。
[TestMethod]
public async Task ProfileDialog_ShouldSaveProfileWithNewName()
{
// テスト用の変数
var name = "Ken";
var newName = "Kenichiro";
var age = "42";
// テストの追加と実行
await ArrangeTestFlow()
.Test("foo", "名前を入力してください。")
.Test(name, "年齢を聞いてもいいですか? (1) はい または (2) いいえ")
.Test("はい", "年齢を入力してください。")
.Test(age, $"次の情報で登録します。いいですか?{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい、 (2) 名前を変更する、 または (3) 年齢を変更する")
.Test("名前を変更する", "名前を入力してください。")
.Test(newName, $"次の情報で登録します。いいですか?{Environment.NewLine} 名前:{newName} 年齢:{age} (1) はい、 (2) 名前を変更する、 または (3) 年齢を変更する")
.Test("はい", "プロファイルを保存します。")
.StartTestAsync();
}
[TestMethod]
public async Task ProfileDialog_ShouldSaveProfileWithNewAge()
{
// テスト用の変数
var name = "Ken";
var age = "42";
var newAge = "43";
// テストの追加と実行
await ArrangeTestFlow()
.Test("foo", "名前を入力してください。")
.Test(name, "年齢を聞いてもいいですか? (1) はい または (2) いいえ")
.Test("はい", "年齢を入力してください。")
.Test(age, $"次の情報で登録します。いいですか?{Environment.NewLine} 名前:{name} 年齢:{age} (1) はい、 (2) 名前を変更する、 または (3) 年齢を変更する")
.Test("年齢を変更する", "年齢を入力してください。")
.Test(newAge, $"次の情報で登録します。いいですか?{Environment.NewLine} 名前:{name} 年齢:{newAge} (1) はい、 (2) 名前を変更する、 または (3) 年齢を変更する")
.Test("はい", "プロファイルを保存します。")
.StartTestAsync();
}
天気ダイアログのテスト
前回までと異なり天気ダイアログでも Choice プロンプトが実装されているため、テストとして独立したものを作ります。ここでは DataRow 属性を使って全ての Choice を一つのテストメソッドでテストします。
1. テストプロジェクトに WeatherDialogUnitTest.cs を追加し、以下のコードを追加。
- ArrangeTestFlow メソッドでテストのアレンジ
- DataRow 属性を使ってテストしたい値を全て渡す
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Adapters;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Recognizers.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;
namespace myfirstbot.unittest
{
[TestClass]
public class WeatherDialogUnitTest
{
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")
};
// テスト対象のダイアログをインスタンス化
var dialogs = new DialogSet(accessors.ConversationDialogState);
dialogs.Add(new WeatherDialog());
// アダプターを作成し必要なミドルウェアを追加
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(WeatherDialog), null, cancellationToken);
}
});
}
[TestMethod]
[DataRow("今日")]
[DataRow("明日")]
[DataRow("明後日")]
public async Task WeatherDialog_ShouldReturnWeatherForcast(string day)
{
await ArrangeTestFlow()
.Test("foo", "いつの天気を知りたいですか? (1) 今日、 (2) 明日、 または (3) 明後日")
.Test(day, $"{day}の天気は晴れです")
.StartTestAsync();
}
}
}
2. テストを実行して結果を確認。DataRow で渡したデータごとに結果が出る。
メニューロジックのテスト
メニューのロジックは変わっていないものの天気ダイアログの返すテキストが変わったためテストに失敗しています。同じ状況を繰り返さないため、ここではテストの方法を変えて、各ダイアログを呼び出した後、ボットから帰ってくるテキストではなく、ダイアログスタックを直接確認して、意図したダイアログが呼ばれているか確認します。
1. MenuDialogUnitTest.cs にテストアレンジ用のメソッドを追加。
- TestFlow、BotAdapter、DialogSet を Turple で返す
private (TestFlow testFlow, BotAdapter adapter, DialogSet dialogs) ArrangeTest()
{
// ストレージとしてインメモリを利用
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")
};
// テスト対象のダイアログをインスタンス化
var dialogs = new DialogSet(accessors.ConversationDialogState);
dialogs.Add(new MenuDialog());
// アダプターを作成し必要なミドルウェアを追加
var adapter = new TestAdapter()
.Use(new SetLocaleMiddleware(Culture.Japanese))
.Use(new AutoSaveStateMiddleware(userState, 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(MenuDialog), null, cancellationToken);
}
});
return (testFlow, adapter, dialogs);
}
2. 既存のテストメソッドを削除。
3. 天気ダイアログの遷移をテストするメソッドを追加。
- 結果の検証を AssetReply で実行
- 返った Activity と BotAdapter、DialogSet を使ってダイアログコンテキストを取得
- ダイアログコンテキストからダイアログスタックを確認
[TestMethod]
public async Task MenuDialog_ShouldGoToWeatherDialog()
{
var arrange = ArrangeTest();
// テストの追加と実行
await arrange.testFlow
.Test("foo", "今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
.Send("天気を確認")
.AssertReply(async (activity) =>
{
// Activity とアダプターからコンテキストを作成
var turnContext = new TurnContext(arrange.adapter, activity as Activity);
// ダイアログコンテキストを取得
var dc = await arrange.dialogs.CreateContextAsync(turnContext);
// 現在のダイアログスタックの一番上が WeatherDialog で その下が MenuDialog であることを確認。
var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
Assert.AreEqual(dialogInstances[0].Id, nameof(WeatherDialog));
})
.StartTestAsync();
}
4. 同様に予定ダイアログの呼び出しテストを追加。
[TestMethod]
public async Task MenuDialog_ShouldGoToScheduleDialog()
{
var arrange = ArrangeTest();
await arrange.testFlow
.Test("foo", "今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
.Send("予定を確認")
.AssertReply((activity) =>
{
// Activity とアダプターからコンテキストを作成
var turnContext = new TurnContext(arrange.adapter, activity as Activity);
// ダイアログコンテキストを取得
var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
// 現在のダイアログスタックの一番上が ScheduleDialog の choice であることを確認。
var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
Assert.AreEqual(dialogInstances[0].Id, "choice");
})
.StartTestAsync();
}
メインロジックのテスト
メインロジックにはグローバルコマンドが実装されています。
- キャンセル: 既存のダイアログスタックをクリアしてメニューダイアログに遷移
- プロファイルの変更: 既存のダイアログスタック上にプロファイルダイアログを追加
メニューダイアログと同じようにダイアログスタックを元に判断します。
1. MyBotUnitTest.cs にアレンジ用のメソッドとクラスレベルプロパティを追加。
- ArrangeTest では UserProfile を返すかどうか引数で指定
// テスト用変数
string name = "Ken";
private (TestFlow testFlow, BotAdapter adapter, DialogSet dialogs) ArrangeTest(bool returnUserProfile)
{
// アダプターを作成
var adapter = new TestAdapter();
adapter.Use(new SetLocaleMiddleware(Culture.Japanese));
// ストレージとしてモックのストレージを利用
var mock = 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 = name, Age = 0 } }
});
}
// ストレージへの読み書きを設定
mock.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;
});
mock.Setup(ms => ms.ReadAsync(It.IsAny<string[]>(), It.IsAny<CancellationToken>()))
.Returns(() =>
{
return Task.FromResult(result: (IDictionary<string, object>)dictionary);
});
// それぞれのステートを作成
var conversationState = new ConversationState(mock.Object);
var userState = new UserState(mock.Object);
var accessors = new MyStateAccessors(userState, conversationState)
{
// DialogState を ConversationState のプロパティとして設定
ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
// UserProfile を作成
UserProfile = userState.CreateProperty<UserProfile>("UserProfile")
};
// テスト対象のダイアログをインスタンス化
var dialogs = new DialogSet(accessors.ConversationDialogState);
dialogs.Add(new ProfileDialog(accessors));
dialogs.Add(new MenuDialog());
// テスト対象のクラスをインスタンス化
var bot = new MyBot(accessors);
// TestFlow の作成
var testFlow = new TestFlow(adapter, bot.OnTurnAsync);
return (testFlow, adapter, dialogs);
}
2. 既存の MyBot_ テストメソッドを削除し、代わりに以下のテストを追加。
- ArrangeTest の使用
- ダイアログスタックでのテスト判定
[TestMethod]
public async Task MyBot_ShouldGoToProfileDialogWithConversationUpdateWithoutUserProfile()
{
var arrange = ArrangeTest(false);
var conversationUpdateActivity = new Activity(ActivityTypes.ConversationUpdate)
{
Id = "test",
From = new ChannelAccount("TestUser", "Test User"),
ChannelId = "UnitTest",
ServiceUrl = "https://example.org",
MembersAdded = new List<ChannelAccount>() { new ChannelAccount("TestUser", "Test User") }
};
// テストの追加と実行
await arrange.testFlow
.Send(conversationUpdateActivity)
.AssertReply("ようこそ MyBot へ!")
.AssertReply((activity) =>
{
// Activity とアダプターからコンテキストを作成
var turnContext = new TurnContext(arrange.adapter, activity as Activity);
// ダイアログコンテキストを取得
var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
// 現在のダイアログスタックの一番上が ProfileDialog の name であることを確認。
var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(ProfileDialog)).First().State["dialogs"] as DialogState).DialogStack;
Assert.AreEqual(dialogInstances[0].Id, "name");
})
.StartTestAsync();
}
[TestMethod]
public async Task MyBot_ShouldGoToMenuDialogWithConversationUpdateWithUserProfile()
{
var arrange = ArrangeTest(true);
var conversationUpdateActivity = new Activity(ActivityTypes.ConversationUpdate)
{
Id = "test",
From = new ChannelAccount("TestUser", "Test User"),
ChannelId = "UnitTest",
ServiceUrl = "https://example.org",
MembersAdded = new List<ChannelAccount>() { new ChannelAccount("TestUser", "Test User") }
};
// テストの追加と実行
await arrange.testFlow
.Send(conversationUpdateActivity)
.AssertReply($"ようこそ '{name}' さん!")
.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();
}
[TestMethod]
public async Task MyBot_ShouldWelcomeAndMenuDialogWithMessage()
{
var arrange = ArrangeTest(true);
// テストの追加と実行
await arrange.testFlow
.Test("foo", $"ようこそ '{name}' さん!")
.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();
}
3. キャンセルグローバルコマンドのテストを追加。
[TestMethod]
public async Task MyBot_GlobalCommand_ShouldCancelAllDialog()
{
var arrange = ArrangeTest(true);
// テストの追加と実行
await arrange.testFlow
.Test("foo", $"ようこそ '{name}' さん!")
.AssertReply("今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
.Send("天気を確認")
.AssertReply((activity) =>
{
// Activity とアダプターからコンテキストを作成
var turnContext = new TurnContext(arrange.adapter, activity as Activity);
// ダイアログコンテキストを取得
var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
// 現在のダイアログスタックの一番上が WeatherDialog で その下が MenuDialog であることを確認。
var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
Assert.AreEqual(dialogInstances[0].Id, nameof(WeatherDialog));
})
.Test("キャンセル", "キャンセルします")
.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();
}
4. プロファイル変更のテストを追加。
- ダイアログスタックが積まれていることも確認
[TestMethod]
public async Task MyBot_GlobalCommand_ShouldGoToProfileDialog()
{
var arrange = ArrangeTest(true);
// テストの追加と実行
await arrange.testFlow
.Test("foo", $"ようこそ '{name}' さん!")
.AssertReply("今日はなにをしますか? (1) 天気を確認 または (2) 予定を確認")
.Send("天気を確認")
.AssertReply((activity) =>
{
// Activity とアダプターからコンテキストを作成
var turnContext = new TurnContext(arrange.adapter, activity as Activity);
// ダイアログコンテキストを取得
var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
// 現在のダイアログスタックの一番上が WeatherDialog であることを確認。
var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(MenuDialog)).First().State["dialogs"] as DialogState).DialogStack;
Assert.AreEqual(dialogInstances[0].Id, nameof(WeatherDialog));
})
.Send("プロファイルの変更")
.AssertReply((activity) =>
{
// Activity とアダプターからコンテキストを作成
var turnContext = new TurnContext(arrange.adapter, activity as Activity);
// ダイアログコンテキストを取得
var dc = arrange.dialogs.CreateContextAsync(turnContext).Result;
// 現在のダイアログスタックの一番上が ProfileDialog でその下が MenuDialog であることを確認。
// WeatherDialog は MenuDialog の最上部にある
Assert.AreEqual(dc.Stack[0].Id, nameof(ProfileDialog));
Assert.AreEqual(dc.Stack[1].Id, nameof(MenuDialog));
// ProfileDialog ダイアログスタックの一番上が ProfileDialog の name であることを確認。
var dialogInstances = (dc.Stack.Where(x => x.Id == nameof(ProfileDialog)).First().State["dialogs"] as DialogState).DialogStack;
Assert.AreEqual(dialogInstances[0].Id, "name");
})
.StartTestAsync();
}
まとめ
今回は中断処理とグローバルコマンドのテストを追加しましたが、その中でも DataRow の使い方やダイアログスタックを使ったテストの方法など新しい情報が沢山でました。是非詳細を確認してみてください。