Microsoft Bot Framework の複雑なダイアログをテストする
はじめに
Microsoft Bot Framework を使ったボットを開発するときにどのようにテストするかは重要な関心事項です。Microsoft Bot Framework では DialogTestClient
クラスを使用してボットの単体テストを実施できます。
DialogTestClient
クラスのテスト対象はダイアログです。簡単なダイアログでは問題ありませんが、ウォーターフォール ダイアログのような複雑なダイアログの場合は問題があります。ダイアログ全体だと、エンドツーエンドのテストになってしまうため、単体テストとは言い難くなってしまいます。できればウォーターフォール ダイアログのステップごとにテストを実施したいところです。マイクロソフトのサンプルも、ステップがテストに向いてない書き方なので、もう少しテストしやすい書き方にしてみます。
問題点
ウォーターフォール ダイアログの仕組みついては以下に図があります。
説明のためにこちらでも図を転記しておきます。
マイクロソフトのサンプルでは、それぞれのウォーターフォール ステップは 1 つの private
メソッドになります。ここで問題になるのは以下の 2 点です。
- メソッドが
private
なのでテスト メソッドからアクセスできない - 1 つのメソッドに 2 つの処理が含まれている (単一責任原則の違反)
特に後者が問題で、前のプロンプトの後処理と次のプロンプトの前処理が一緒になっているために、テストを困難にしています。1 つのプロンプトをテストしようにも複数のメソッドを実行しなければならなくなります。
解決方法
当たり前ですが、1 つのメソッドでやることは 1 つにするべきです。WaterfallStepContext
クラスにはプロンプトを出さずに次のステップに進む NextAsync
メソッドがあるため、これを使ってメソッドを分割します。これをもとに先ほどの図を書き換えます。
プロンプトの前後のステップを 1 つのクラスにまとめて OnBefore
メソッドと OnAfter
メソッドにします。ステップ数は増えたものの、処理が分離されたため、わかりやすくなりました。独立したクラスになっているため、テストもしやすそうです。
実行手順
図をもとに実際にコードを書いていきます。
基底クラス
Step クラス
ステップを示す Step
クラスを作成します。Dialog
プロパティはこのステップで表示するプロンプトです。DialogId
プロパティはプロンプトを一意に識別する文字列です。OnBeforeAsync
メソッド、OnAfterAsync
メソッドはプロンプトの前後で実行されるイベント メソッドです。
public abstract class Step
{
public abstract string DialogId { get; }
public abstract Dialog Dialog { get; }
public abstract Task<DialogTurnResult> OnBeforeAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken = default);
public abstract Task<DialogTurnResult> OnAfterAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken = default);
}
StepCollection クラス
それぞれのステップを管理するための StepCollection
クラスを作成します。ここではダイアログを初期化するための Actions
プロパティと Dialogs
プロパティを定義します。
public abstract class StepCollection
{
private readonly Step[] collection;
protected StepCollection(params Step[] collection)
{
this.collection = collection;
}
public IEnumerable<WaterfallStep> Actions
{
get
{
foreach (var provider in this.collection)
{
yield return provider.OnBeforeAsync;
yield return provider.OnAfterAsync;
}
}
}
public IEnumerable<Dialog> Dialogs => this.collection.Select(item => item.Dialog);
}
派生クラス
SampleNameStep クラス
例としてユーザーに名前を確認するプロンプトを作成してみます。SampleNameStep
クラスは Step
クラスを継承します。OnBeforeAsync
メソッドでは PromptAsync
メソッドを、OnAfterAsync
メソッドでは NextAsync
メソッドを呼び出すようにします。
public class SampleNameStep : Step
{
public override string DialogId => "5ab9c986-f72c-4d71-b56b-f1f9602a3c34";
public override Dialog Dialog => new TextPrompt(this.DialogId);
public override Task<DialogTurnResult> OnBeforeAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken = default)
{
return await stepContext.PromptAsync(
this.DialogId,
new PromptOptions
{
Prompt = "お名前を教えてください。"
},
cancellationToken
);
}
public override Task<DialogTurnResult> OnAfterAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken = default)
{
var name = (string)stepContext.Result;
_ = await stepContext.Context.SendActivityAsync(
$"{name} さん、ようこそ!",
cancellationToken
);
return await stepContext.NextAsync(cancellationToken: cancellationToken);
}
}
SampleStepCollection クラス
StepCollection
を継承するクラスを作成します。あとで DI するだけなので中身は空で問題ありません。
public class SampleStepCollection : StepCollection
{
public SampleStepCollection(params Step[] collection)
: base(collection)
{
}
}
SampleDialog クラス
ステップを実行するダイアログを作成します。コンストラクタで SampleStepCollection
クラスのインスタンスを受け取るようにします。これによりステップの登録も簡単になります。
public class SampleDialog : ComponentDialog
{
public SampleDialog(SampleStepCollection collection)
: base(nameof(SampleDialog))
{
_ = this.AddDialog(new WaterfallDialog(nameof(WaterfallDialog), collection.Actions));
foreach (var dialog in collection.Dialogs)
{
_ = this.AddDialog(dialog);
}
}
}
Program クラス
DI を定義します。複数のステップがある場合はすべてコレクションに追加します。
_ = services.AddScoped<SampleDialog>();
_ = services.AddScoped<SampleStep>();
_ = services.AddScoped(provider => new SampleStepCollection(
provider.GetRequiredService<SampleStep>()
));
テスト クラス
SampleNameStepTests クラス
ステップをテストするクラスを作成します。プロンプトの前後のみをテストできるので、ほかのプロンプトによる依存関係が少なくなり、テストしやすくなります。
public class SampleNameStepTests
{
[Test()]
public async Task SampleNameStepTest()
{
var step = new SampleNameStep();
var dialog = new ComponentDialog();
_ = dialog.AddDialog(new WaterfallDialog(
nameof(WaterfallDialog),
[
step.OnBeforeAsync,
step.OnAfterAsync
]
));
_ = dialog.AddDialog(step.Dialog);
var client = new DialogTestClient(Channels.Msteams, dialog);
var actual = await client.SendActivityAsync<IMessageActivity>(new Activity(ActivityTypes.Message));
_ = await client.SendActivityAsync<IMessageActivity>(new Activity(ActivityTypes.Message, text: "Hikaru Hoshi"));
Assert.That(actual.Text, Is.EqualTo("お名前を教えてください。"));
}
}
おわりに
テストがしやすいだけではなく、ボットの動きも理解しやすくなりますので、ぜひ参考にしてみてください。
Discussion