かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

Bot FrameworkでFormFlowをテストしよう(基本編)

過去記事

blog.okazuki.jp

blog.okazuki.jp

blog.okazuki.jp

blog.okazuki.jp

はじめに

何か他に知りたいことない?っていうこと書いてたらFormFlowってどうやるの?と聞かれたのでやってみました。 結論からいうとユニットテストというには粒度がでかすぎて気に入らないけど、なんとなく頑張ればできそうなところまで持ってこれたのでひとまずメモっておこうと思います。

FormFlowを使うようにしよう

さて、テストするにはFormFlowを使ったものが必要なので作ります。今回は顧客情報を入力するということで以下のようなCustomerクラスを準備しました。

using Microsoft.Bot.Builder.FormFlow;
using System;

namespace DITestBotApp.Forms
{
    public enum CustomerType
    {
        None,
        Normal,
        Premium,
    }

    [Serializable]
    public class Customer
    {
        public string Name { get; set; }
        public int Age { get; set; }
        public CustomerType Type { get; set; }

        public static IForm<Customer> BuildForm()
        {
            return new FormBuilder<Customer>()
                .Message("カスタマーの情報を入れてね")
                .Field(nameof(Name), "名前を入力してください")
                .Field(nameof(Age), "年齢を入力してください")
                .Field(nameof(Type), "タイプを選択してください")
                .Confirm("上記内容でよろしいですか")
                .Build();
        }
    }
}

これを使うようにRootDialogを書き換えます。formと入力したらFormFlowを使ったダイアログに流すようにしました。

using DITestBotApp.Factories;
using DITestBotApp.Forms;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.FormFlow;
using Microsoft.Bot.Connector;
using System;
using System.Threading.Tasks;

namespace DITestBotApp.Dialogs
{
    [Serializable]
    public class RootDialog : IDialog<object>
    {
        private IGreetService GreetService { get; }
        private IDialogFactory DialogFactory { get; }

        public RootDialog(IDialogFactory dialogFactory, IGreetService greetService)
        {
            this.DialogFactory = dialogFactory;
            this.GreetService = greetService;
        }

        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.GreetInteractionAsync);
            return Task.CompletedTask;
        }

        public async Task GreetInteractionAsync(IDialogContext context, IAwaitable<object> result)
        {
            await context.PostAsync(this.GreetService.GetMessage());
            await this.MainInteractionAsync(context, result);
        }

        public async Task MainInteractionAsync(IDialogContext context, IAwaitable<object> result)
        {
            var activity = await result as Activity;
            if (activity.Text == "change")
            {
                context.Call(this.DialogFactory.Create<ISimpleDialog>(), this.ReturnFromSimpleDialogInteractionAsync);
            }
            else if (activity.Text == "form")
            {
                // ここ!!FormFlow!!
                context.Call(FormDialog.FromForm(Customer.BuildForm, FormOptions.PromptInStart), this.ReturnFromCustomerForm);
            }
            else
            {
                var length = (activity.Text ?? string.Empty).Length;
                await context.PostAsync($"You sent {activity.Text} which was {length} characters");
                context.Wait(this.MainInteractionAsync);
            }
        }

        public async Task ReturnFromCustomerForm(IDialogContext context, IAwaitable<Customer> result)
        {
            var customer = await result;
            await context.PostAsync($"{customer.Name} {customer.Age} {customer.Type}");
            context.Wait(this.MainInteractionAsync);
        }

        public async Task ReturnFromSimpleDialogInteractionAsync(IDialogContext context, IAwaitable<object> result)
        {
            await context.PostAsync("returned");
            context.Wait(this.MainInteractionAsync);
        }
    }
}

こんな風に動きます。

f:id:okazuki:20170710143557p:plain

何を単体テストするべきなの?

さて、何をテストすべきなんでしょうか? コードとしては宣言的ですが、出力メッセージとかを状況に応じてカスタマイズ可能だったりするので、そこらへんの確認はしたい気がします。(今回は固定だけど)

あと、正しい入力なのかチェックするロジックも設定可能なのでそこも見たい気もします。

ということで、FormBuilderが思った通りの設定のFormを作ってくれたか確認できればいいのですが、超重要な処理のステップが格納されたStepsプロパティがinternalでしたorz

悔しい。

Fieldsというプロパティで、どういうプロパティを集めて回る設定がされてるのか?という情報は取れるのですが、これはこれでDialogが開始したときのメッセージの情報が格納されてなかったりします。

悲しい。

ということなので、個別のプロパティの入力のみをテストするのは凄く大変かつBot Frameworkのpublicではないメンバーに関する知識が凄く要求されることになります。 単体テストも書き捨てじゃないので、個人的には息の長いコードを書くことが大事だと思います。なのでライブラリのinternalやprivateなメンバーに依存したコードを書いてるとライブラリをちょっとバージョンアップしたらテストが失敗するようになる可能性が上がるので個人的には凄く避けたいです。

なので、なるべくpublicなメンバーかつ変更の可能性が少なそうなものに依存するのが望ましいですね。

ということで、今回はFormDialog<T>を作ってしまって、データ入力のフローを流して意図した結果が得られるか?というのをユニットテストでやってみたいと思います。

今回の妥協点

FormDialog.FromFormメソッドの戻り値が IFormDialog<T>なのですが、このインターフェースだと本当に必要最低限しかメソッドがないので、実装クラスのFormDialog<T>にキャストして頑張ることにしました。 FormDialog.FromFormメソッドの戻り値の実装クラスが差し替えられる可能性もなきにしもあらずですが、まぁ結構がっつりと内部でこのクラスであることを前提としてるコードがあるので多分変わる可能性は低いでしょうということで妥協してます。本当はこれもしたくなかったですね。

ユニットテストを書こう

FormDialog<T>クラスはMessageReceivedが呼ばれると、その時のステートに応じて情報を収集していくという感じになってます。 なので、FormDialogを作ってMessageReceivedにユーザーの入力を順番に渡してやるといい感じに動いてくれます。

ということで、今回はユニットテストで正常入力のフローを書いてみました。

using DITestBotApp.Forms;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.FormFlow;
using Microsoft.Bot.Connector;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace DITestBotApp.Test.Forms
{
    [TestClass]
    public class CustomerTest
    {
        [TestMethod]
        public async Task BuildFormTest()
        {
            var dialogContextMock = new Mock<IDialogContext>();
            // 内部で呼ばれるメソッドの下準備
            dialogContextMock.Setup(x => x.MakeMessage())
                .Returns(Activity.CreateMessageActivity());
            dialogContextMock.Setup(x => x.Wait(It.IsAny<ResumeAfter<IMessageActivity>>()));

            // テスト対象のダイアログを作成
            var target = FormDialog.FromForm(Customer.BuildForm) as FormDialog<Customer>;

            // 初回メッセージを投げたときの応答
            dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "カスタマーの情報を入れてね"), default(CancellationToken)))
                .Returns(Task.CompletedTask)
                .Verifiable();
            dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "名前を入力してください"), default(CancellationToken)))
                .Returns(Task.CompletedTask)
                .Verifiable();

            // 初回メッセージを投げ込んで意図した内容が渡ってきたか確認
            await target.MessageReceived(dialogContextMock.Object, Awaitable.FromItem<IMessageActivity>(new Activity { Text = "hi" }));
            dialogContextMock.Verify();

            // 名前を入力したら次は年齢の入力が来るのでセットアップ
            dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "年齢を入力してください"), default(CancellationToken)))
                .Returns(Task.CompletedTask)
                .Verifiable();
            // 名前を入力して確認
            await target.MessageReceived(dialogContextMock.Object, Awaitable.FromItem<IMessageActivity>(new Activity { Text = "tanaka" }));
            dialogContextMock.Verify();

            // 年齢を入力したら次はタイプの入力が来るのでセットアップ
            dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "タイプを選択してください"), default(CancellationToken)))
                .Returns(Task.CompletedTask)
                .Verifiable();
            // 年齢を入力
            await target.MessageReceived(dialogContextMock.Object, Awaitable.FromItem<IMessageActivity>(new Activity { Text = "20" }));
            dialogContextMock.Verify();

            // タイプを入力すると最終確認が来るのでセットアップ
            dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "上記内容でよろしいですか"), default(CancellationToken)))
                .Returns(Task.CompletedTask)
                .Verifiable();
            // タイプを入力
            await target.MessageReceived(dialogContextMock.Object, Awaitable.FromItem<IMessageActivity>(new Activity { Text = "Normal" }));
            dialogContextMock.Verify();

            // 最終確認ではいを入力すると入力した結果が渡ってくるのでセットアップ
            dialogContextMock.Setup(x => x.Done(It.Is<Customer>(y =>
                y.Name == "tanaka" &&
                y.Age == 20 &&
                y.Type == CustomerType.Normal)))
                .Verifiable();
            // はいを入力
            await target.MessageReceived(dialogContextMock.Object, Awaitable.FromItem<IMessageActivity>(new Activity { Text = "はい" }));
            dialogContextMock.Verify();
        }
    }
}

IDialogContextインターフェースのMakeMessageとWaitが内部で結構呼ばれてるのでモックに設定してます。 そして、PostAsyncメソッドに意図したメッセージが渡ってくるか確認できるようにセットアップしてからMessageReceivedにユーザーの入力を渡してやります。

一通りの入力フローが終わったらIDialogContextインターフェースのDoneメソッドにデータの詰まったクラスが渡ってくるので確認します。

まとめ

FormFlowテストしにくいので嫌ですね。まぁでも便利な機能なのは違いありません。はい。 ソースコードはGitHubで参照できます。

github.com