かずきのBlog@hatena

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

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