かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Bot FrameworkでDialogをテストしてみよう(試行錯誤編)

前回のBlogでDI使っていい感じにできる環境が整いました。

blog.okazuki.jp

次は実際に単体テストをする段階です。

ということで、実際に試行錯誤しながらコードを書きつつブログをしたためようと思います。いつもは、割と試行錯誤とかの段階は記録に残してないのですが、なんとなく私がどういう風にやってるのかというプロセスのほうに興味がある人もいるかな?という気もしたので、今回はそういうアプローチで書いてみようと思います。

妄想

単体テストするために必要な最低限のものが出来たので実際に単体テストをするという観点でコードを見ていきましょう。とりあえずテスト対象をRootDialogクラスにするということで見ていきます。

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

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

        // DIする
        public RootDialog(IDialogFactory factory, IGreetService greetService)
        {
            this.Factory = factory;
            this.GreetService = greetService;
        }

        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.GreetAsync);

            return Task.CompletedTask;
        }

        private async Task GreetAsync(IDialogContext context, IAwaitable<object> result)
        {
            // DIしたやつを使う
            await context.PostAsync(this.GreetService.GetMessage());
            await this.MessageReceivedAsync(context, result);
        }

        private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<object> result)
        {
            var activity = await result as Activity;

            if (activity.Text == "change")
            {
                context.Call(this.Factory.Create<SimpleDialog>(), this.ResumeSimpleDialogAsync);
            }
            else
            {
                // calculate something for us to return
                int length = (activity.Text ?? string.Empty).Length;

                // return our reply to the user
                await context.PostAsync($"You sent {activity.Text} which was {length} characters");

                context.Wait(MessageReceivedAsync);
            }
        }

        private async Task ResumeSimpleDialogAsync(IDialogContext context, IAwaitable<object> result)
        {
            await context.PostAsync("returned");
            context.Wait(this.MessageReceivedAsync);
        }
    }
}

ぱっと見た感じ1つ1つのメソッドは十分に短いし、外部リソースに依存するものはinterfaceで分離すればいいということでいけそうな雰囲気を感じましたが、もうちょっと踏み込んでみると以下のようなところが問題だと思いました。

publicなものはコンストラクタとStartAsyncメソッドだけじゃん!?

これは致命的です。実際問題は置いといて、基本的には単体テストはpublicなメソッドとかを対象にするのが最初のステップだと思います。StartAsyncをテストするにしても、Moqを使って簡単に出来そうなものはIDIalogContextのWaitメソッドが期待する引数で呼び出されたかどうかだけ。つまりGreetAsyncメソッドやMessageReceivedAsyncメソッドはテスト時に実行することが叶わない…。(privateメソッドを無理やり叩けばいけるけどね)

どうしよう

「とりあえずprivateなメソッドをpublicにして凌ぐか」という方法と、Dialog自体は必要最低限のコードにして何か別のクラスに主処理を持って行って、そっちのクラスをテスタビリティ考えて作っていくか…という方法が考えられます。 まぁ前者は、既存コードが割とあるようなときの現実解としては有りかな?と思うけど、まぁ今回は試行錯誤し放題なので、やるとしたら後者だなと思いました。

やってみよう

ということで、RootDialogをリファクタリングしていきます。RootDialogに含められていて今回テストしたいなと思っているのはGreetAsyncがきちんと外部サービスを呼び出せているか?という点とMessageReceivedAsyncメソッドがユーザーの入力に対してきちんと応答できてるか?ということとResumeSimpleDialogAsyncで、また普通の処理のループに戻れているか?という感じなので、そこらへんを何となく何も考えずにinterfaceとして切り出してみたいと思います。

Interactions名前空間誕生

ということで対話なんてInteractions名前空間作りました。インターフェース切ります。IRootInteractionという名前にしました。とりあえずRootDialogクラスを見ながら適当にメソッドを切り出します。

using Microsoft.Bot.Builder.Dialogs;
using System.Threading.Tasks;

namespace DITestBotApp.Interactions
{
    public interface IRootInteraction
    {
        Task GreetInteractionAsync(IDialogContext context, IAwaitable<object> result);
        Task MainInteractionAsync(IDialogContext context, IAwaitable<object> result);
        Task ReturnFromSimpleDialogInteractionAsync(IDialogContext context, IAwaitable<object> result);
    }
}

実装しましょう。RootDialogから、ほぼ単純に引越しですね。

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

namespace DITestBotApp.Interactions
{
    [Serializable]
    public class RootInteraction : IRootInteraction
    {
        private IGreetService GreetService { get; }
        private IDialogFactory DialogFactory { get; }

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

        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<SimpleDialog>(), this.ReturnFromSimpleDialogInteractionAsync);
            }
            else
            {
                var length = (activity.Text ?? string.Empty).Length;
                await context.PostAsync($"You sent {activity.Text} which was {length} characters");
                context.Wait(this.ReturnFromSimpleDialogInteractionAsync);
            }
        }

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

実際のコードでは、こいつがユーザーとの対話を行うので対話の結果とか保持してほしいのでSerializableにしておかないといけないなと思ったのでSerializableにしておきました。

DIコンテナに登録しましょう。

using Autofac;
using DITestBotApp.Dialogs;
using DITestBotApp.Factories;
using DITestBotApp.Interactions;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Internals.Fibers;
using System.Web.Http;

namespace DITestBotApp
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            GlobalConfiguration.Configure(WebApiConfig.Register);

            Conversation.UpdateContainer(builder =>
            {
                builder.RegisterType<DialogFactory>()
                    .Keyed<IDialogFactory>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .InstancePerLifetimeScope();

                builder.RegisterType<RootDialog>()
                    .As<IDialog<object>>()
                    .InstancePerLifetimeScope();

                builder.RegisterType<SimpleDialog>()
                    .InstancePerDependency();

                builder.RegisterType<RootInteraction>()
                    .AsImplementedInterfaces()
                    .InstancePerDependency();

                builder.RegisterType<GreetService>()
                    .Keyed<IGreetService>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .SingleInstance();
            });
        }
    }
}

RootDialogで、RootInteractionを使うように書き換えます。すごくシンプル!

using DITestBotApp.Interactions;
using Microsoft.Bot.Builder.Dialogs;
using System;
using System.Threading.Tasks;

namespace DITestBotApp.Dialogs
{
    [Serializable]
    public class RootDialog : IDialog<object>
    {
        private IRootInteraction RootInteraction { get; }

        public RootDialog(IRootInteraction rootInteraction)
        {
            this.RootInteraction = rootInteraction;
        }

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

これならテスト書かなくてもいいんじゃね?って気がしますね。

書いてて思ったこと

Interactionっていう人たちをDialogの後ろに設けてIDialogインターフェースに依存しない感じで1レイヤ設けようという目論見でした。作って思ったことはやってることがBot FrameworkがDialogでやろうとしてることと全く同じじゃない?というところですかね。これならDialogのprivateなメソッドをそのままpublicにしても同じじゃん?という結論に至りそう!!!いや至る!

ということで、逆引越しだ。RootDialogを以下のようにしてInteractions名前空間をごそっと消します。

using DITestBotApp.Factories;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
using DITestBotApp.Factories;
using DITestBotApp.Interactions;
using DITestBotApp.Services;
using Microsoft.Bot.Builder.Dialogs;
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<SimpleDialog>(), this.ReturnFromSimpleDialogInteractionAsync);
            }
            else
            {
                var length = (activity.Text ?? string.Empty).Length;
                await context.PostAsync($"You sent {activity.Text} which was {length} characters");
                context.Wait(this.ReturnFromSimpleDialogInteractionAsync);
            }
        }

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

テストしやすさって意味では、変わらんな。Dialogが無駄にpublicなメソッドが増えるのがもにょるけど。

github.com