かずきのBlog@hatena

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

Bot FrameworkでDialogをテストしてみよう(もうちょっとユニットテスト書くよ金の力で編)

過去記事

blog.okazuki.jp

blog.okazuki.jp

blog.okazuki.jp

はじめに

今までの記事でBot Frameworkを使った場合のユニットテストの書き方がなんとなくわかった気がします。 ただ、ちょっとBot Frameworkを使うと、もう1つはまるだろうなというポイントがあるのでそこについて書きます。

PromptDialogクラス

選択肢を出したりするPromptDialogのChoiceメソッドってよく使いますよね? こいつは、ユニットテストの天敵であるstaticメソッドです。試しにSimpleDialogクラスをPromptDialogのChoiceを使うように書き換えてみました。

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

namespace DITestBotApp.Dialogs
{
    public interface ISimpleDialog : IDialog<object>
    {
        // こいつもテストするならHelloWorldAsyncとか定義することになるかな
    }

    [Serializable]
    public class SimpleDialog : ISimpleDialog
    {
        public async Task StartAsync(IDialogContext context)
        {
            await context.PostAsync("SimpleDialog started");
            PromptDialog.Choice(
                context,
                this.HelloWorldAsync,
                new[]
                {
                    "A", "B", "C",
                },
                "あなたの好きなのは??");
        }

        private async Task HelloWorldAsync(IDialogContext context, IAwaitable<string> result)
        {
            var input = await result;
            await context.PostAsync($"Hello world!! {input}");
            context.Done<object>(null);
        }
    }
}

選択肢を出して、その結果を受けて何かするっていうような感じですね。動かすとこんな感じになります。

f:id:okazuki:20170709125249p:plain

Choiceメソッドの中身は…

こんな感じになってます。

/// <summary>   Prompt for one of a set of choices. </summary>
/// <param name="context">  The context. </param>
/// <param name="resume">   Resume handler. </param>
/// <param name="options">  The possible options all of which must be convertible to a string.</param>
/// <param name="prompt">   The prompt to show to the user. </param>
/// <param name="retry">    What to show on retry. </param>
/// <param name="attempts"> The number of times to retry. </param>
/// <param name="promptStyle"> Style of the prompt <see cref="PromptStyle" /> </param>
/// <param name="descriptions">Descriptions to display for choices.</param>
public static void Choice<T>(IDialogContext context, ResumeAfter<T> resume, IEnumerable<T> options, string prompt, string retry = null, int attempts = 3, PromptStyle promptStyle = PromptStyle.Auto, IEnumerable<string> descriptions = null)
{
    Choice(context, resume, new PromptOptions<T>(prompt, retry, attempts: attempts, options: options.ToList(), promptStyler: new PromptStyler(promptStyle), descriptions: descriptions?.ToList()));
}

/// <summary>   Prompt for one of a set of choices. </summary>
/// <param name="context">  The context. </param>
/// <param name="resume">   Resume handler. </param>
/// <param name="choices"> Dictionary with the options to choose from as a key and their synonyms as a value.</param>
/// <param name="prompt">   The prompt to show to the user. </param>
/// <param name="retry">    What to show on retry. </param>
/// <param name="attempts"> The number of times to retry. </param>
/// <param name="promptStyle"> Style of the prompt <see cref="PromptStyle" /> </param>
/// <param name="descriptions">Descriptions to display for choices.</param>
/// <param name="recognizeChoices">(Optional) if true, the prompt will attempt to recognize numbers in the users utterance as the index of the choice to return. The default value is "true".</param>
/// <param name="recognizeNumbers">(Optional) if true, the prompt will attempt to recognize ordinals like "the first one" or "the second one" as the index of the choice to return. The default value is "true".</param>
/// <param name="recognizeOrdinals">(Optional) if true, the prompt will attempt to recognize the selected value using the choices themselves. The default value is "true".</param>
/// <param name="minScore">(Optional) minimum score from 0.0 - 1.0 needed for a recognized choice to be considered a match. The default value is "0.4".</param>
public static void Choice<T>(IDialogContext context, ResumeAfter<T> resume, IDictionary<T, IEnumerable<T>> choices, string prompt, string retry = null, int attempts = 3, PromptStyle promptStyle = PromptStyle.Auto, IEnumerable<string> descriptions = null, bool recognizeChoices = true, bool recognizeNumbers = true, bool recognizeOrdinals = true, double minScore = 0.4)
{
    Choice(context, resume, new PromptOptionsWithSynonyms<T>(prompt, retry, attempts: attempts, choices: choices.ToDictionary(x => x.Key, x => (IReadOnlyList<T>)x.Value.ToList().AsReadOnly()), promptStyler: new PromptStyler(promptStyle), descriptions: descriptions?.ToList()), recognizeChoices, recognizeNumbers, recognizeOrdinals, minScore: minScore);
}

/// <summary>
/// Prompt for one of a set of choices.
/// </summary>
/// <remarks><typeparamref name="T"/> should implement <see cref="object.ToString"/></remarks>
/// <typeparam name="T"> The type of the options.</typeparam>
/// <param name="context"> The dialog context.</param>
/// <param name="resume"> Resume handler.</param>
/// <param name="promptOptions"> The prompt options.</param>
/// <param name="recognizeChoices">(Optional) if true, the prompt will attempt to recognize numbers in the users utterance as the index of the choice to return. The default value is "true".</param>
/// <param name="recognizeNumbers">(Optional) if true, the prompt will attempt to recognize ordinals like "the first one" or "the second one" as the index of the choice to return. The default value is "true".</param>
/// <param name="recognizeOrdinals">(Optional) if true, the prompt will attempt to recognize the selected value using the choices themselves. The default value is "true".</param>
/// <param name="minScore">(Optional) minimum score from 0.0 - 1.0 needed for a recognized choice to be considered a match. The default value is "0.4".</param>
public static void Choice<T>(IDialogContext context, ResumeAfter<T> resume, IPromptOptions<T> promptOptions, bool recognizeChoices = true, bool recognizeNumbers = true, bool recognizeOrdinals = true, double minScore = 0.4)
{
    var child = new PromptChoice<T>(promptOptions, recognizeChoices, recognizeNumbers, recognizeOrdinals, minScore);
    context.Call<T>(child, resume);
}

ということなので、Choiceを呼ぶと最終的にPromptChoice型がIDialogContextのCallにわたってくるということです。 これを前提にMoqのIt.Is<T>(…)を使ってアサートかけてやればいい…!ということになります。

ただ、PrimptChoiceのコンストラクタに渡されてるpromptOptions引数(今回見たい情報が詰まってるクラス)はprotectedなメンバーに格納されてるので外部から覗き見ることが出来ません…!詰んだ…!!!

金の力で殴る

さて、こんな風にライブラリによって提供されるにっくきstaticメソッドと、カプセル化による情報の隠匿によってユニットテストへの道が閉ざされた場合の解決策としてMicrosoft Fakesというのがあります。 これはVisual Stduio 2017 Enterprise Editionのみに提供されている機能になります。Microsoft Fakesの詳細については以下のページを参照してください。

Microsoft Fakes を使用したテストでのコードの分離

Microsoft FkaesにはShimという超強力な黒魔術が提供されていて、staticメソッドだろうが非virtualなメソッドだろうがおかまいなく置き換えてしまうということが出来ます。 使い方は簡単で、Fakesの対象にしたいアセンブリ(今回の場合はBot Frameworkのアセンブリ)を右クリックしてFakesに追加してやるだけです。

f:id:okazuki:20170708210036p:plain

これで準備が出来ました。

あとはMicrosoft Fakesのお作法に従いPromptDialogのChoiceメソッドを差し替えて、引数チェックのアサートを追加するだけで単体テスト完成です。

using DITestBotApp.Dialogs;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Fakes;
using Microsoft.Bot.Connector;
using Microsoft.QualityTools.Testing.Fakes;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace DITestBotApp.Test
{
    [TestClass]
    public class SimpleDialogTest
    {
        [TestMethod]
        public async Task StartAsyncTest_Fakes()
        {
            using (ShimsContext.Create())
            {
                var choiceCalled = false;
                ShimPromptDialog.ChoiceOf1IDialogContextResumeAfterOfM0IEnumerableOfM0StringStringInt32PromptStyleIEnumerableOfString<string>(
                    (context, resumeAfter, chioce, primpt, _, __, ___, ____) =>
                    {
                        Assert.AreEqual("A", chioce.ElementAtOrDefault(0));
                        Assert.AreEqual("B", chioce.ElementAtOrDefault(1));
                        Assert.AreEqual("C", chioce.ElementAtOrDefault(2));
                        Assert.AreEqual("あなたの好きなのは??", primpt);
                        Assert.AreEqual(nameof(SimpleDialog.HelloWorldAsync), resumeAfter.Method.Name);
                        choiceCalled = true;
                    });

                var dialogContextMock = new Mock<IDialogContext>();
                dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "SimpleDialog started"), default(CancellationToken)))
                    .Returns(Task.CompletedTask)
                    .Verifiable();
                dialogContextMock.Setup(x => x.MakeMessage()).Returns(Activity.CreateMessageActivity());
                var target = new SimpleDialog();
                await target.StartAsync(dialogContextMock.Object);

                Assert.IsTrue(choiceCalled);
            }
        }
    }
}

これで、Choiceに意図された引数が渡されたかが確認できるようになりました。めでたしめでたし。ユニットテストもお金の力で解決ですね。 リポジトリのmoneyブランチにコードがあります。

github.com

Visual Studio 2017 Enterprise Editionなんて持ってないパターン

普通持ってませんよね…。ということでMicrosoft Fakesを使わない場合どういうアプローチが考えられるかですが、これはユニットテストのためにひと手間かけてやる必要があります。 一般的なアプローチとしては、staticメソッドにアクセスするためのラッパークラスを用意してやるという形になります。こんな感じのコードを用意してやることになります。

using Microsoft.Bot.Builder.Dialogs;
using System.Collections.Generic;

namespace DITestBotApp.Prompts
{
    public interface IPromptService
    {
        void Choice<T>(IDialogContext context, ResumeAfter<T> resume, IEnumerable<T> options, string prompt, string retry = null, int attempts = 3, PromptStyle promptStyle = PromptStyle.Auto, IEnumerable<string> descriptions = null);
    }

    public class PromptService : IPromptService
    {
        public void Choice<T>(IDialogContext context, ResumeAfter<T> resume, IEnumerable<T> options, string prompt, string retry = null, int attempts = 3, PromptStyle promptStyle = PromptStyle.Auto, IEnumerable<string> descriptions = null)
        {
            PromptDialog.Choice<T>(context, resume, options, prompt, retry, attempts, promptStyle, descriptions);
        }
    }
}

今回はChoiceメソッドの1つのオーバーロードしか作ってませんが、必要なぶんだけこういうラッパーを用意してやることになります。 そして、こいつをDIコンテナに登録します。

using Autofac;
using DITestBotApp.Dialogs;
using DITestBotApp.Factories;
using DITestBotApp.Prompts;
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>()
                    .As<ISimpleDialog>()
                    .InstancePerDependency();

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

                builder.RegisterType<PromptService>()
                    .Keyed<IPromptService>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .SingleInstance();
            });
        }
    }
}

そして、このPromptServiceを使うようにSimpleDialogをリファクタリングします。

using DITestBotApp.Prompts;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Internals;
using Microsoft.Bot.Connector;
using System;
using System.Threading.Tasks;

namespace DITestBotApp.Dialogs
{
    public interface ISimpleDialog : IDialog<object>
    {
        // こいつもテストするならHelloWorldAsyncとか定義することになるかな
    }

    [Serializable]
    public class SimpleDialog : ISimpleDialog
    {
        private IPromptService PromptService { get; }

        public SimpleDialog(IPromptService promptService)
        {
            this.PromptService = promptService;
        }

        public async Task StartAsync(IDialogContext context)
        {
            await context.PostAsync("SimpleDialog started");
            this.PromptService.Choice(
                context,
                this.HelloWorldAsync,
                new[]
                {
                    "A", "B", "C",
                },
                "あなたの好きなのは??");
        }

        public async Task HelloWorldAsync(IDialogContext context, IAwaitable<string> result)
        {
            var input = await result;
            await context.PostAsync($"Hello world!! {input}");
            context.Done<object>(null);
        }
    }
}

こうすると、ただのインターフェースに依存しただけのコードになるのでMoqを使ってモッククラスに差し替えることでテストができるようになります。

using DITestBotApp.Dialogs;
using DITestBotApp.Prompts;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace DITestBotApp.Test
{
    [TestClass]
    public class SimpleDialogTest
    {
        [TestMethod]
        public async Task StartAsyncTest()
        {
            var promptServiceMock = new Mock<IPromptService>();
            promptServiceMock
                .Setup(x => x.Choice(It.IsNotNull<IDialogContext>(),
                    It.Is<ResumeAfter<string>>(y => y.Method.Name == nameof(SimpleDialog.HelloWorldAsync)),
                    It.Is<IEnumerable<string>>(y => y.ElementAtOrDefault(0) == "A" &&
                        y.ElementAtOrDefault(1) == "B" &&
                        y.ElementAtOrDefault(2) == "C"),
                    "あなたの好きなのは??",
                    null,
                    3,
                    PromptStyle.Auto,
                    null))
                    .Verifiable();

            var dialogContextMock = new Mock<IDialogContext>();
            dialogContextMock
                .Setup(x => x.MakeMessage())
                .Returns(Activity.CreateMessageActivity());

            dialogContextMock
                .Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "SimpleDialog started"), default(CancellationToken)))
                .Returns(Task.CompletedTask)
                .Verifiable();

            var target = new SimpleDialog(promptServiceMock.Object);
            await target.StartAsync(dialogContextMock.Object);

            promptServiceMock.Verify();
            dialogContextMock.Verify();
        }
    }
}

いい感じですね。ソースコードはリポジトリにあります。

github.com

まとめ

ということで一般的な話ですがこういうことです。

  • ユーテリティ系staticメソッドへのアプローチ方法
    • Microsoft Fakesで殴る
    • モックで差し替え可能なラッパーを作る

理想的にはモックで差し替え可能なような下準備をしてあげてプロジェクトを始めるのがいいんでしょうね。(難しいけど)