かずきのBlog@hatena

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

VIsual Studio 2017 Previewで一押しの機能

DIコンテナ使ってプログラム書くと、大体こんな風になりますよね?

class Consumer
{
    private IService1 Service1 { get; }
    private IService2 Service2 { get; }
}

そして、コンストラクタインジェクションするために引数をprivateな変数に格納するだけの作業が発生します。 個人的に、面白くないプログラミング作業のうちの1つです。

eclipseやNetBeansを何年も前に使ってた時には、フィールドからコンストラクタ生成とかいう機能があったのになぁ…とずっと思ってたけど、ついにVisual Studioにもこの機能が来ます!!

ということでクラスの適当なところでクイックフィックス(Ctrl + .)を表示させるとこんなのが出てきます。

f:id:okazuki:20170723170315p:plain

上書きを生成するみたいなプレビュー品質っぽい翻訳もありますが気にしません。 今回は、この中のコンストラクタを生成するを選択します。するとこんなダイアログが出てきます!素敵!

f:id:okazuki:20170723170448p:plain

OKをすると、こんな感じのコンストラクタが生成されます。

class Consumer
{
    private IService1 Service1 { get; }
    private IService2 Service2 { get; }

    public Consumer(IService1 service1, IService2 service2)
    {
        this.Service1 = service1;
        this.Service2 = service2;
    }
}

また1つVisual Studioが快適になりました。

LINQで書くとデバッグしづらいよね?そんなことないよ

小ネタです。 LINQで処理をぱぱっと1ステートメントで書けると気持ちいいですよね。 でも、デバッグ難しくない?ということがあるのですが大丈夫です安心してください。

ブレークポイントのはり方のコツさえつかめば大丈夫です。例えば以下のようなコードがあるとします。

using System;
using System.Linq;

namespace ConsoleApp10
{
    class Program
    {
        static void Main(string[] args)
        {
            var numbers = new[] { 1, 2, 2, 3, 3, 3, 4, 4, 4 };
            var values = numbers
                .Where(x => x % 2 == 0)
                .Distinct()
                .Select(x => x * x);

            foreach (var value in values)
            {
                Console.WriteLine(value);
            }
        }
    }
}

Selectに意図した値が渡ってきてるのか見たい…!!(今回の場合2と4が渡ってくるはずですよね) そんなときのデバッグ手法ですが、まずシンプルな方法として古き良きprintfデバッグがありますよね。

using System;
using System.Linq;

namespace ConsoleApp10
{
    class Program
    {
        static void Main(string[] args)
        {
            var numbers = new[] { 1, 2, 2, 3, 3, 3, 4, 4, 4 };
            var values = numbers
                .Where(x => x % 2 == 0)
                .Distinct()
                .Select(x => { Console.WriteLine(x); return x* x; });

            foreach (var value in values)
            {
                Console.WriteLine(value);
            }
        }
    }
}

気持ちよさ台無しなうえに確認したいところに都度都度出力入れてくなんて現実的じゃないですよね。 ということでブレークポイントはりましょう。

ブレークポイントの一番簡単?というか多くの人が知ってるポピュラーなやり方としてはエディタの左端のグレー部分でクリックするものだと思います。

f:id:okazuki:20170723165211p:plain

でも、この方法だと行にしかはれないです。そうLINQのラムダ式の中で止めたいというのに対応できない。 最近のデバッガは進化してて、ラムダ式の中にもブレークポイントはることができます。

やり方は簡単、ラムダ式の中にカーソルを持って行ってF9を押すだけ。 マウスが好きな人は、ラムダ式の中にカーソルを持って行って右クリックメニューからブレークポイントの挿入をすればOKです。

f:id:okazuki:20170723165514p:plain

すると、こんな感じに色がつきます。

f:id:okazuki:20170723165602p:plain

この状態でデバッグ実行するとばっちりラムダ式の中でブレークしてくれます!!

f:id:okazuki:20170723165713p:plain

ということで快適なLINQ生活を!!

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

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で殴る
    • モックで差し替え可能なラッパーを作る

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

Bot FrameworkでDialogをテストしてみよう(3回目にしてついにユニットテスト書いたよ編)

過去記事

blog.okazuki.jp

blog.okazuki.jp

本文

さて、初回でDIができるようになり2回目でテストできそうな雰囲気まで考えてみました。 今回はついに実際にユニットテストを書いてみようと思います。採用したテスティングフレームワークは、Visual Stduioでテストプロジェクトを新規作成したらついてくるVSのやつです。 xunitとかのほうがイケてる感じはあるけど、まぁ今回はAssertできればいいやってことで、VSのにしました。というかまぁ、どのテスティングフレームワークでもあんま変わんないしね。

もっくもっく

RootDialogが依存するものは、ことごとくインターフェースになったので、その部分をモックに差し替えてやればテストできるだろうという算段です。 モックは自分で書いてもいいのですがめんどくさいのでOSSのモックを作るためのライブラリのMoqを採用しました。

いざテスト書いてみよう

まず、小手調べにStartAsyncメソッドからテスト書いてみようと思います。テストターゲットのメソッドを見てみましょう。

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

確認したいことはメソッドを呼んだら、きちんとGreetInteractionAsyncに処理を渡しているかという点になります。

ということで単体テストプロジェクトを作ってMoqをNuGetから追加してBotのあるプロジェクトを参照に追加してテストクラスを作っていきます。 IDialogContextのモックを作ってWaitが期待する引数で呼ばれたかVerifyしてやればいいだけですね。

[TestMethod]
public async Task StartAsyncTest()
{
    var target = new RootDialog(null, null);

    var dialogContextMock = new Mock<IDialogContext>();
    dialogContextMock.Setup(x => x.Wait(It.Is<ResumeAfter<IMessageActivity>>(y => y.Method.Name == nameof(RootDialog.GreetInteractionAsync))))
        .Verifiable();

    await target.StartAsync(dialogContextMock.Object);
    dialogContextMock.Verify();
}

実行すると成功!!楽勝ですね。

拡張メソッドの罠

この調子で、次はGreetInteractionAsyncメソッドをテストしてみたいと思います。GreetInteractionAsyncメソッドは内部でMainInteractionAsyncも呼んでるのでMainInteractionAsyncのchangeと入力してないケースのテストも兼ねてしまいましょう。

テスト対象のコードはこんな感じですね。

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);
    }
}

PostAsyncにGreetServiceの返すメッセージがきちんとわたってるか、そのあとMainInteractionAsyncで入力した文字列をカウントして正しい文字列をユーザーに返しているかという点が確認ポイントになりそうですね。

ここで注意すべき点というかハマった点があります。 IDialogContext#PostAsyncメソッドは実は拡張メソッドになってます。拡張メソッドは単なるstaticメソッド呼び出しのシンタックスシュガーに過ぎません。 そして、staticメソッドの呼び出しはモックにすることが出来ません。 そう、staticメソッドはユニットテストにとって敵。滅ぼすべき悪です。

ということで、stringを渡すPostAsyncは、実際はIMessageActivityを受け取るPostAsyncのショートカットに過ぎないということをBot Frameworkの該当ソースを呼んで把握しました。

public static async Task PostAsync(this IBotToUser botToUser, string text, string locale = null, CancellationToken cancellationToken = default(CancellationToken))
{
    var message = botToUser.MakeMessage();
    message.Text = text;

    if (!string.IsNullOrEmpty(locale))
    {
        message.Locale = locale;
    }

    await botToUser.PostAsync(message, cancellationToken);
}

IBotToUserインターフェースはIDialogContextも継承してるのでIDialogContextのことなんだなっていう理解で問題ありません。 さて、ここで問題になるのが

var message = botToUser.MakeMessage();

になります。IDialogContextのMakeMessageメソッドもきちんと正しい値を返すようにしておかないと単体テストがこけてしまいます。Moqはデフォルトでモックのメソッドの戻り値はnullを返すので、NullReferenceExceptionになっちゃうので要注意です。 ここらへん辛いね。

以上のことを踏まえてGreetInteractionAsyncメソッドのテストは以下のようになります。

[TestMethod]
public async Task GreetInteractionAsyncTest()
{
    var dialogFactoryMock = new Mock<IDialogFactory>();
    var greetServiceMock = new Mock<IGreetService>();
    greetServiceMock.Setup(x => x.GetMessage())
        .Returns("Hello world")
        .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 == "Hello world"), default(CancellationToken)))
        .Returns(Task.CompletedTask)
        .Verifiable();
    dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "You sent okazuki which was 7 characters"), default(CancellationToken)))
        .Returns(Task.CompletedTask)
        .Verifiable();
    dialogContextMock.Setup(x => x.Wait(It.Is<ResumeAfter<IMessageActivity>>(y => y.Method.Name == nameof(RootDialog.MainInteractionAsync))))
        .Verifiable();
    var resultMock = Awaitable.FromItem<object>(new Activity { Text = "okazuki" });

    var target = new RootDialog(dialogFactoryMock.Object, greetServiceMock.Object);
    await target.GreetInteractionAsync(dialogContextMock.Object, resultMock);

    greetServiceMock.Verify();
    dialogContextMock.Verify();
}

Bot Frameworkは便利な拡張メソッドが結構提供されてるっぽいので、意識しなくても拡張メソッドを呼んでて、その中で実はIDialogContextの別メソッドとか呼んでて死ぬってことがあると思うので気をつけようね。 ということで、ソースコードは手元にcloneしておくことを推奨です。

余談ですがVisual Studio 2017ではコード参照機能が結構強化されてます。今回PostToAsync拡張メソッドを探すにあたってCtrl + Tを押してPostToAsyncを検索してさくっと見つけました。

におうぞ…

ということで、あとは流すだけですねMainInteractionAsyncメソッドでユーザーがchangeを入力したときのフローをやってみようと思います。

IDialogContextのCallを呼んでるだけなので、こんな感じのテストメソッドになりますね。

[TestMethod]
public async Task MainInteractionAsyncChangeCase()
{
    var dialogFactoryMock = new Mock<IDialogFactory>();
    var simpleDialog = new SimpleDialog();
    dialogFactoryMock.Setup(x => x.Create<SimpleDialog>())
        .Returns(simpleDialog)
        .Verifiable();

    var dialogContextMock = new Mock<IDialogContext>();
    dialogContextMock.Setup(x => x.Call(It.Is<SimpleDialog>(y => y == simpleDialog),
        It.Is<ResumeAfter<object>>(y => y.Method.Name == nameof(RootDialog.ReturnFromSimpleDialogInteractionAsync))))
        .Verifiable();

    var resultMock = Awaitable.FromItem<object>(new Activity { Text = "change" });

    var target = new RootDialog(dialogFactoryMock.Object, null);
    await target.MainInteractionAsync(dialogContextMock.Object, resultMock);

    dialogFactoryMock.Verify();
    dialogContextMock.Verify();
}

テストも無事成功!なんですが、今回SimpleDialogクラスのインスタンスが必要になったので適当にnewして使ってますがSimpleDialogが別クラスに依存したりしてないから、こんなんでいいですが、SimpleDialogクラスのインスタンスを組み立てるのが大変になってきたらめんどくさいですね。ということで、Dialog系クラスはインターフェースを切っておいたほうがよさそうな雰囲気を感じました。

感じたのでリファクタリングしておきます。

SimpleDialogのインターフェースとしてISimpleDialogを定義して実装するようにします。

using Microsoft.Bot.Builder.Dialogs;
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");
            context.Wait(this.HelloWorldAsync);
        }

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

そして、DIコンテナにISimpleDialogというインターフェースで登録するようにします。

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

MainInteractionAsyncメソッドもインターフェースを使うように書き換えます。

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

ということで、テストメソッドはこうなりました。

[TestMethod]
public async Task MainInteractionAsyncChangeCase()
{
    var dialogFactoryMock = new Mock<IDialogFactory>();
    var simpleDialogMock = new Mock<ISimpleDialog>();
    dialogFactoryMock.Setup(x => x.Create<ISimpleDialog>())
        .Returns(simpleDialogMock.Object)
        .Verifiable();

    var dialogContextMock = new Mock<IDialogContext>();
    dialogContextMock.Setup(x => x.Call(It.Is<ISimpleDialog>(y => y == simpleDialogMock.Object),
        It.Is<ResumeAfter<object>>(y => y.Method.Name == nameof(RootDialog.ReturnFromSimpleDialogInteractionAsync))))
        .Verifiable();

    var resultMock = Awaitable.FromItem<object>(new Activity { Text = "change" });

    var target = new RootDialog(dialogFactoryMock.Object, null);
    await target.MainInteractionAsync(dialogContextMock.Object, resultMock);

    dialogFactoryMock.Verify();
    dialogContextMock.Verify();
}

いい感じですね。テスト対象のRootDialog以外は、自分で作った具象クラスを排除しました!これでほかの具象クラスの変更に引きずられることなく単体テストが機能するようになりました。(インターフェースが変わるとそれは仕方ない)

最後!

ReturnFromSimpleDialogInteractionAsyncメソッドもテストしましょう。 これは新たな問題が起きることなく無事いけました。

[TestMethod]
public async Task ReturnFromSimpleDialogInteractionAsync()
{
    var dialogContextMock = new Mock<IDialogContext>();
    dialogContextMock.Setup(x => x.PostAsync(It.Is<IMessageActivity>(y => y.Text == "returned"), default(CancellationToken)))
        .Returns(Task.CompletedTask)
        .Verifiable();

    dialogContextMock.Setup(x => x.MakeMessage())
        .Returns(Activity.CreateMessageActivity());
    dialogContextMock.Setup(x => x.Wait(It.Is<ResumeAfter<IMessageActivity>>(y => y.Method.Name == nameof(RootDialog.MainInteractionAsync))))
        .Verifiable();

    var target = new RootDialog(null, null);
    await target.ReturnFromSimpleDialogInteractionAsync(dialogContextMock.Object, null);

    dialogContextMock.Verify();
}

今日の学び

ということで実際に単体テストを書いてみました。ポイントは以下になります。

  • Bot Frameworkの提供する拡張メソッドは素直にモック出来ないので気を付ける
    • stringを受け取るPostToAsyncメソッドは内部でIDialogContext#MakeMessageメソッドを呼んでるので、それもモックしてあげないといけないとか
  • Dialog系クラスも基本的にはIDialog<T>を拡張した自前インターフェースを定義して、別のダイアログからダイアログを使うときはインターフェースを介して操作するようにする

ソースコードは例によってリポジトリにあります。コミットの履歴から遍歴を辿ってね。

github.com

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

Bot Frameworkでテスタブルな感じに作りたいのでDIしてみた

Bot Frameworkは実はAutofacを使ってるみたいですね。

github.com

ただ、これを使う方法のドキュメントが見つけられない…。サンプルの中には、これを使って作られてるものがあったりもします。

github.com

でも、微妙にInternalsな名前空間使ってたりして、もんにょりするのですがとりあえずDI出来る感じにする手順をやってみたいと思います。因みに、2017/07/05現在の情報なので割とさくっとやり方かわるかもしれないので注意です。

作ってみよう

Bot Frameworkのプロジェクトを作ります。

Update NuGet packages

作ったらAutofacとSystem.IdentityModel.Tokens.Jwtの2つ以外を最新にします。Autofacは3系と4系で.NET Std対応してたりするので怖くて上げてません。Jwtのほうは何かあげれませんでした。

とりあえずサービス作ってみる

今回はDIすることが目的なので、さくっと固定メッセージを返すだけの以下のようなインターフェースとクラスをでっちあげました。

namespace DITestBotApp.Services
{
    public interface IGreetService
    {
        string GetMessage();
    }

    public class GreetService : IGreetService
    {
        public string GetMessage()
        {
            return "これはDIしたサービスから返されたメッセージです。";
        }
    }
}

DIコンテナのセットアップはConversationクラスのUpdateContainerメソッドで行います。Global.asax.csあたりでやるっぽいですね。先ほど作成したGreetServiceを登録しています。ContainerBuilderが渡ってくるのでよしなにするという感じです。ここらへんはAutofacのドキュメント参照って感じですね。

using Autofac;
using DITestBotApp.Dialogs;
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<RootDialog>()
                    .As<IDialog<object>>()
                    .InstancePerLifetimeScope();

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

んで、ポイントはKeyedメソッドでFilberModule.Key_DoNotSerializeを指定しておくというところです。シリアライズしないでねっていう印みたいです。あとは、IDialog<object>で会話の起点となるルートのDialogを登録しておきます。

RootDialogを以下のように書き換えてGreetServiceをさくっと使うようにしてみました。

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

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

        // DIする
        public RootDialog(IGreetService greetService)
        {
            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;

            // 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);
        }
    }
}

あとは、MessagesControllerでDIコンテナからDialogを作って渡すように書き換えます。

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        using (var scope = DialogModule.BeginLifetimeScope(Conversation.Container, activity))
        {
            var dialog = scope.Resolve<IDialog<object>>();
            await Conversation.SendAsync(activity, () => dialog);
        }
    }
    else
    {
        HandleSystemMessage(activity);
    }
    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
}

実行してみるとこんな感じに動きます。ちゃんとDIしたやつがいけてますね。

f:id:okazuki:20170705141734p:plain

DialogからDialogを使うケース

DialogからDialogを使うときは、新たにDialogのインスタンスを作ってIDialogContextクラスのCallメソッドに渡してやる必要があります。これはDialogを作るファクトリを作って、こいつがAutofacのクラスを使ってインスタンス作って返す感じです。やってみましょう。

こんなDialogを用意します。

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

namespace DITestBotApp.Dialogs
{
    [Serializable]
    public class SimpleDialog : IDialog<object>
    {
        public async Task StartAsync(IDialogContext context)
        {
            await context.PostAsync("SimpleDialog started");
            context.Wait(this.HelloWorldAsync);
        }

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

Global.asax.csで、こいつを登録します。

using Autofac;
using DITestBotApp.Dialogs;
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<RootDialog>()
                    .As<IDialog<object>>()
                    .InstancePerLifetimeScope();

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

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

ファクトリのクラスを作ります。こいつはIComponentContext(Autofacのインターフェース)を使ってインスタンスを作ります。

using Autofac;

namespace DITestBotApp.Factories
{
    public interface IDialogFactory
    {
        T Create<T>();
    }

    public class DialogFactory : IDialogFactory
    {
        private IComponentContext Scope { get; }

        public DialogFactory(IComponentContext scope)
        {
            this.Scope = scope;
        }

        public T Create<T>()
        {
            return this.Scope.Resolve<T>();
        }
    }
}

これも、DIコンテナに登録します。

using Autofac;
using DITestBotApp.Dialogs;
using DITestBotApp.Factories;
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<GreetService>()
                    .Keyed<IGreetService>(FiberModule.Key_DoNotSerialize)
                    .AsImplementedInterfaces()
                    .SingleInstance();
            });
        }
    }
}

では、RootDialogでSimpleDialogを作るようにしてみよう。

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);
        }
    }
}

因みに、最初はDialogFactory作らずにIComponentContextを直接RootDialogにDIしてやればいいやって思ったのですが、この人をDIさせるとシリアライズできないと怒られてしまいましたので、1枚ラップして明示的にシリアライズしないよってマークしたやつをRootDialogにDIするようにしました。

実行してみましょう。

f:id:okazuki:20170705151116p:plain

いい感じですね。

ソースコードは以下の場所に置いてあります。

github.com

SignalRの.NETクライアントで認証プロキシを突破する方法

追記

初音さんが確認してくれて、IEのプロキシ設定があれば自動でそっちを見てくれるみたいです。 IE側でプロキシ設定されてたら、それを使って接続を試して、それでだめだったら今回紹介した方法で指定した情報を使ってつなぎにいくという動きをするっぽいですね。

本文

転職してから認証プロキシのない世界に来たのですが、前職で認証プロキシ配下でSignalR使うことをちょいちょいやってたのでメモっておきます。 ちなみに、検証環境がすでにないので間違ってるかもしれないのでダメだったら教えてください!!(ついでにOKだった場合も教えてもらえると嬉しい)

SignalRでは、HubConnectionのProxyプロパティにプロキシの情報を設定します。そこにWebProxyを設定してやればOKです。認証プロキシの場合はWebProxyのCredentialsプロパティにNetworkCredentialを指定してやります。

コードはこんな雰囲気になります。

var conn = new HubConnection("server address");
conn.Proxy = new WebProxy("http://example.com:8080")
{
    Credentials = new NetworkCredential("user@example.com", "p@ssw0rd"),
};

それでは、良い認証プロキシライフを!

【東工大生限定招待】 Xamarin ハンズオンラーニング with JXUG,MSP で発表してきました

東工大でXamarin関連のことについて話してきました。そこでの資料を公開します。

www.slideshare.net

かずきのXamarin.Forms入門を更新しました

5か月ぶりくらいに内容をアップデートしました。

SlideShareにサインインするとダウンロードボタンが表示されるので、そこからダウンロードしてご利用ください。

www.slideshare.net

変更内容

  • Xamarin.Forms 2.3.4ベースの内容に更新しました
  • Prism.Forms 6.3ベースの内容に更新しました

今後

内容のアップデートだけなので、これから前にもらったフィードバックなどを反映させたあとSlideShareのPDFを再更新かけてAmazonにも放流しようと思います。