🧪

Microsoft Bot Framework の複雑なダイアログをテストする

2024/07/19に公開

はじめに

Microsoft Bot Framework を使ったボットを開発するときにどのようにテストするかは重要な関心事項です。Microsoft Bot Framework では DialogTestClient クラスを使用してボットの単体テストを実施できます。

https://learn.microsoft.com/ja-jp/azure/bot-service/unit-test-bots?WT.mc_id=M365-MVP-5002941

DialogTestClient クラスのテスト対象はダイアログです。簡単なダイアログでは問題ありませんが、ウォーターフォール ダイアログのような複雑なダイアログの場合は問題があります。ダイアログ全体だと、エンドツーエンドのテストになってしまうため、単体テストとは言い難くなってしまいます。できればウォーターフォール ダイアログのステップごとにテストを実施したいところです。マイクロソフトのサンプルも、ステップがテストに向いてない書き方なので、もう少しテストしやすい書き方にしてみます。

問題点

ウォーターフォール ダイアログの仕組みついては以下に図があります。

https://learn.microsoft.com/ja-jp/azure/bot-service/bot-builder-concept-waterfall-dialogs?WT.mc_id=M365-MVP-5002941

説明のためにこちらでも図を転記しておきます。

マイクロソフトのサンプルでは、それぞれのウォーターフォール ステップは 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