Microsoft Bot Framework の複雑なダイアログをテストする
はじめに
Microsoft Bot Framework を使ったボットを開発する際、どのようにテストするかは重要な関心事項です。Microsoft Bot Framework では DialogTestClient クラスを使用してボットの単体テストを実施できます。
DialogTestClient クラスのテスト対象はダイアログです。簡単なダイアログであれば問題ありませんが、ウォーターフォール ダイアログのような複雑なダイアログの場合は課題があります。ダイアログ全体をテストするとエンドツーエンドのテストになってしまい、単体テストとは言い難くなります。できればウォーターフォール ダイアログのステップごとにテストを実施したいところです。Microsoft のサンプルも、ステップがテストに向いていない書き方になっているため、もう少しテストしやすい書き方に修正します。
問題点
ウォーターフォール ダイアログの仕組みについては以下に図があります。
説明のため、こちらでも図を掲載します。
Microsoft のサンプルでは、それぞれのウォーターフォール ステップは 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