ボットの単体テストを実行する方法

この記事の対象: SDK v4

このトピックでは、次の操作方法について説明します。

  • ボットの単体テストを作成します。
  • Assert を使用して、予想した値に対してダイアログ ターンによって返されたアクティビティを確認します。
  • Assert を使用して、ダイアログによって返された結果を確認します。
  • さまざまな種類のデータ ドリブン テストを作成します。
  • 言語認識エンジンなど、ダイアログのさまざまな依存関係のモック オブジェクトを作成します。

前提条件

このトピックで使用されている CoreBot テスト サンプルは、Microsoft.Bot.Builder.Testing パッケージ、XUnit、および Moq を参照して単体テストを作成します。

コア ボット サンプルでは、Language Understanding (LUIS) を使用してユーザーの意図を識別します。ただし、ユーザーの意図を特定することは、この記事の主目的ではありません。 ユーザーの意図を識別する方法については、「自然言語の理解」および「ボットに自然言語の理解を追加する」をご覧ください。

Note

Language Understanding (LUIS) は、2025 年 10 月 1 日に廃止されます。 2023 年 4 月 1 日以降は、新しい LUIS リソースを作成することはできません。 より新しいバージョンの言語理解が、現在、Azure AI Language の一部として提供されています。

Azure AI Language の機能である会話言語理解 (CLU) は、LUIS の更新バージョンです。 Bot Framework SDK での言語理解のサポートの詳細については、「自然言語の理解」を参照してください。

ダイアログのテスト

Corebot サンプルでは、ダイアログの単体テストは DialogTestClient クラスを使用して行われます。これにより、コードを Web サービスにデプロイすることなく、ボットの外部で単独でテストするメカニズムが提供されます。

このクラスを使用すると、ダイアログの応答をターンごとに検証する単体テストを作成できます。 DialogTestClient クラスを使用した単体テストは、botbuilder ダイアログ ライブラリを使って作成された他のダイアログでも機能するはずです。

次の例は、DialogTestClient から派生したテストを示しています。

var sut = new BookingDialog();
var testClient = new DialogTestClient(Channels.Msteams, sut);

var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
Assert.Equal("Where would you like to travel to?", reply.Text);

reply = await testClient.SendActivityAsync<IMessageActivity>("Seattle");
Assert.Equal("Where are you traveling from?", reply.Text);

reply = await testClient.SendActivityAsync<IMessageActivity>("New York");
Assert.Equal("When would you like to travel?", reply.Text);

reply = await testClient.SendActivityAsync<IMessageActivity>("tomorrow");
Assert.Equal("OK, I will book a flight from Seattle to New York for tomorrow, Is this Correct?", reply.Text);

reply = await testClient.SendActivityAsync<IMessageActivity>("yes");
Assert.Equal("Sure thing, wait while I finalize your reservation...", reply.Text);

reply = testClient.GetNextReply<IMessageActivity>();
Assert.Equal("All set, I have booked your flight to Seattle for tomorrow", reply.Text);

DialogTestClient クラスは、Microsoft.Bot.Builder.Testing 名前空間に定義されており、Microsoft.Bot.Builder.Testing NuGet パッケージに含まれています。

DialogTestClient

DialogTestClient の最初のパラメーターはターゲット チャネルです。 これにより、ボットのターゲット チャネル (Teams、Slack など) に基づいてさまざまなレンダリング ロジックをテストできます。 ターゲット チャネルが不明の場合は、Emulator または Test のチャネル ID を使用できますが、一部のコンポーネントでは、現在のチャネルによって動作が異なる場合があることに注意してください。たとえば、ConfirmPrompt は、Test チャネルと Emulator チャネルとで Yes/No のオプションを異なる方法でレンダリングします。 また、このパラメーターを使用して、チャネル ID に基づいてダイアログの条件付きレンダリング ロジックをテストすることもできます。

2 番目のパラメーターは、テスト対象のダイアログのインスタンスです。 この記事のサンプル コードでは、sutテストの対象となるシステムを表します。

DialogTestClient コンストラクターには追加のパラメーターが用意されており、必要に応じてクライアントの動作をさらにカスタマイズしたり、テスト対象のダイアログにパラメーターを渡したりできます。 ダイアログの初期化データを渡したり、カスタム ミドルウェアを追加したり、独自の TestAdapter と ConversationState インスタンスを使用したりできます。

メッセージの送受信

SendActivityAsync<IActivity> メソッドは、テキストの発話や IActivity をダイアログに送信することを可能にして、受信する最初のメッセージを返します。 <T> パラメーターは、キャストせずにアサートできるように、厳密に型指定された応答のインスタンスを返すために使用されます。

var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
Assert.Equal("Where would you like to travel to?", reply.Text);

一部のシナリオでは、ボットは 1 つのアクティビティへの応答で複数のメッセージを送信することがあります。この場合、DialogTestClient は応答をキューに入れ、ユーザーは GetNextReply<IActivity> メソッドを使用して、応答キューから次のメッセージをポップできます。

reply = testClient.GetNextReply<IMessageActivity>();
Assert.Equal("All set, I have booked your flight to Seattle for tomorrow", reply.Text);

応答キューにこれ以上のメッセージがなくなると、GetNextReply<IActivity> は null 値を返します。

アクティビティのアサート

CoreBot サンプルのコードは、単に返されたアクティビティの Text プロパティをアサートします。 より複雑なボットでは、SpeakInputHintChannelData などのような他のプロパティをアサートすることもできます。

Assert.Equal("Sure thing, wait while I finalize your reservation...", reply.Text);
Assert.Equal("One moment please...", reply.Speak);
Assert.Equal(InputHints.IgnoringInput, reply.InputHint);

これは、前述のように各プロパティを個別に確認することで実行できます。アクティビティをアサートするための独自のヘルパー ユーティリティを作成したり、FluentAssertions のような他のフレームワークを使用してカスタム アサーションを作成し、テスト コードを簡素化したりすることもできます。

ダイアログにパラメーターを渡す

DialogTestClient コンストラクターには、ダイアログにパラメーターを渡すために使用できる initialDialogOptions があります。 たとえば、このサンプルの MainDialog では、言語認識の結果から BookingDetails オブジェクトを初期化し、ユーザーの発話から解決するエンティティを使用して、BookingDialog の呼び出しにこのオブジェクトを渡します。

これは、次のようにテストで実装できます。

var inputDialogParams = new BookingDetails()
{
    Destination = "Seattle",
    TravelDate = $"{DateTime.UtcNow.AddDays(1):yyyy-MM-dd}"
};

var sut = new BookingDialog();
var testClient = new DialogTestClient(Channels.Msteams, sut, inputDialogParams);

BookingDialog はこのパラメーターを受け取り、MainDialog から呼び出された場合と同じ方法で、テストでそれにアクセスします。

private async Task<DialogTurnResult> DestinationStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    var bookingDetails = (BookingDetails)stepContext.Options;
    ...
}

ダイアログ ターンの結果のアサート

BookingDialogDateResolverDialog のような一部のダイアログは、呼び出し元のダイアログに値を返します。 DialogTestClient オブジェクトは、ダイアログによって返された結果を分析およびアサートするために使用できる DialogTurnResult プロパティを公開します。

次に例を示します。

var sut = new BookingDialog();
var testClient = new DialogTestClient(Channels.Msteams, sut);

var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
Assert.Equal("Where would you like to travel to?", reply.Text);

...

var bookingResults = (BookingDetails)testClient.DialogTurnResult.Result;
Assert.Equal("New York", bookingResults?.Origin);
Assert.Equal("Seattle", bookingResults?.Destination);
Assert.Equal("2019-06-21", bookingResults?.TravelDate);

DialogTurnResult プロパティを使用して、ウォーターフォールのステップによって返された中間結果を検査およびアサートすることもできます。

テスト出力の分析

場合により、テストをデバッグせずに、単体テストのトランスクリプトを読み取ってテストの実行を分析することが必要になることがあります。

Microsoft.Bot.Builder.Testing パッケージには、ダイアログで送受信されたメッセージをコンソールに記録する XUnitDialogTestLogger が含まれています。

このミドルウェアを使用するためには、テストでは、XUnit テスト ランナーによって提供される ITestOutputHelper オブジェクトを受け取るコンストラクターを公開し、middlewares パラメーターを通じて DialogTestClient に渡される XUnitDialogTestLogger を作成する必要があります。

public class BookingDialogTests
{
    private readonly IMiddleware[] _middlewares;

    public BookingDialogTests(ITestOutputHelper output)
        : base(output)
    {
        _middlewares = new[] { new XUnitDialogTestLogger(output) };
    }

    [Fact]
    public async Task SomeBookingDialogTest()
    {
        // Arrange
        var sut = new BookingDialog();
        var testClient = new DialogTestClient(Channels.Msteams, sut, middlewares: _middlewares);

        ...
    }
}

ここでは、XUnitDialogTestLogger が構成されているときに出力ウィンドウに記録する内容の例を示します。

Example middleware output from XUnit.

XUnit を使用しているときのコンソールへのテスト出力の送信に関する追加情報については、XUnit ドキュメントの「出力のキャプチャ」を参照してください。

この出力は、継続的インテグレーションのビルド中にビルド サーバーにも記録され、ビルドの失敗を分析するのに役立ちます。

データ ドリブン テスト

多くの場合、ダイアログ ロジックは変わらず、会話のさまざまな実行パスはユーザー発話に基づきます。 会話のバリアントごとに 1 つの単体テストを作成するよりも、データ ドリブン テスト (パラメーター化テストとも呼ばれる) を使用した方が簡単です。

たとえば、このドキュメントの概要セクションにあるサンプル テストでは、1 つの実行フローをテストする方法を示していますが、次のような他の実行フローはテストしません。

  • ユーザーが確認に対して [いいえ] を選択するとどうなりますか?
  • 別の日付を使用するとどうなりますか?

データ ドリブン テストでは、テストを書き直さずに、これらのすべての変更をテストできます。

CoreBot サンプルでは、XUnit からの Theory テストを使用してテストをパラメーター化します。

InlineData を使用した理論テスト

次のテストでは、ユーザーが [キャンセル] を選択したときにダイアログがキャンセルされることを確認します。

[Fact]
public async Task ShouldBeAbleToCancel()
{
    var sut = new TestCancelAndHelpDialog();
    var testClient = new DialogTestClient(Channels.Test, sut);

    var reply = await testClient.SendActivityAsync<IMessageActivity>("Hi");
    Assert.Equal("Hi there", reply.Text);
    Assert.Equal(DialogTurnStatus.Waiting, testClient.DialogTurnResult.Status);

    reply = await testClient.SendActivityAsync<IMessageActivity>("cancel");
    Assert.Equal("Cancelling...", reply.Text);
}

ダイアログをキャンセルするには、ユーザーは "quit"、"never mind"、および "stop it" と入力できます。 考えられるすべての単語に対して新しいテスト ケースを作成するのではなく、InlineData 値の一覧を通じてパラメーターを受け入れる 1 つの Theory テスト メソッドを作成して、各テスト ケースのパラメーターを定義します。

[Theory]
[InlineData("cancel")]
[InlineData("quit")]
[InlineData("never mind")]
[InlineData("stop it")]
public async Task ShouldBeAbleToCancel(string cancelUtterance)
{
    var sut = new TestCancelAndHelpDialog();
    var testClient = new DialogTestClient(Channels.Test, sut, middlewares: _middlewares);

    var reply = await testClient.SendActivityAsync<IMessageActivity>("Hi");
    Assert.Equal("Hi there", reply.Text);
    Assert.Equal(DialogTurnStatus.Waiting, testClient.DialogTurnResult.Status);

    reply = await testClient.SendActivityAsync<IMessageActivity>(cancelUtterance);
    Assert.Equal("Cancelling...", reply.Text);
}

新しいテストは、異なるパラメーターを使用して 4 回実行され、各ケースは、Visual Studio テスト エクスプローラーで ShouldBeAbleToCancel テストの下に子項目として表示されます。 以下に示すように、いずれかが失敗した場合は、テスト一式を再実行するのではなく、失敗したシナリオを右クリックしてデバッグできます。

Example test results for in-line data.

MemberData と複合型を使用した理論テスト

InlineData は、単純な値の型のパラメーター (文字列、整数など) を受け取る小規模なデータ ドリブン テストに役立ちます。

BookingDialogBookingDetails オブジェクトを受け取って、新しい BookingDetails オブジェクトを返します。 このダイアログのテストのパラメーター化されていないバージョンは、次のようになります。

[Fact]
public async Task DialogFlow()
{
    // Initial parameters
    var initialBookingDetails = new BookingDetails
    {
        Origin = "Seattle",
        Destination = null,
        TravelDate = null,
    };

    // Expected booking details
    var expectedBookingDetails = new BookingDetails
    {
        Origin = "Seattle",
        Destination = "New York",
        TravelDate = "2019-06-25",
    };

    var sut = new BookingDialog();
    var testClient = new DialogTestClient(Channels.Test, sut, initialBookingDetails);

    // Act/Assert
    var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
    ...

    var bookingResults = (BookingDetails)testClient.DialogTurnResult.Result;
    Assert.Equal(expectedBookingDetails.Origin, bookingResults?.Origin);
    Assert.Equal(expectedBookingDetails.Destination, bookingResults?.Destination);
    Assert.Equal(expectedBookingDetails.TravelDate, bookingResults?.TravelDate);
}

このテストをパラメーター化するために、テスト ケース データを含む BookingDialogTestCase クラスを作成しました。 これには、初期 BookingDetails オブジェクト、予想される BookingDetails、およびユーザーから送信された発話を含む文字列の配列と、ターンごとのダイアログからの予想される応答が含まれています。

public class BookingDialogTestCase
{
    public BookingDetails InitialBookingDetails { get; set; }

    public string[,] UtterancesAndReplies { get; set; }

    public BookingDetails ExpectedBookingDetails { get; set; }
}

また、テストで使用されるテスト ケースのコレクションを返す IEnumerable<object[]> BookingFlows() メソッドを公開するヘルパー BookingDialogTestsDataGenerator クラスも作成しました。

Visual Studio テスト エクスプローラーで各テスト ケースを別々の項目として表示するために、XUnit テスト ランナーでは、BookingDialogTestCase のような複合型が IXunitSerializable を実装することが要求されます。これを簡素化するために、Bot.Builder.Testing フレームワークでは、このインターフェイスを実装し、IXunitSerializable を実装する必要なしにテスト ケース データをラップするために使用できる TestDataObject クラスが提供されています。

ここでは、これらの 2 つのクラスの使用方法を示す IEnumerable<object[]> BookingFlows() のフラグメントを示します。

public static class BookingDialogTestsDataGenerator
{
    public static IEnumerable<object[]> BookingFlows()
    {
        // Create the first test case object
        var testCaseData = new BookingDialogTestCase
        {
            InitialBookingDetails = new BookingDetails(),
            UtterancesAndReplies = new[,]
            {
                { "hi", "Where would you like to travel to?" },
                { "Seattle", "Where are you traveling from?" },
                { "New York", "When would you like to travel?" },
                { "tomorrow", $"Please confirm, I have you traveling to: Seattle from: New York on: {DateTime.Now.AddDays(1):yyyy-MM-dd}. Is this correct? (1) Yes or (2) No" },
                { "yes", null },
            },
            ExpectedBookingDetails = new BookingDetails
            {
                Destination = "Seattle",
                Origin = "New York",
                TravelDate = $"{DateTime.Now.AddDays(1):yyyy-MM-dd}",
            }, 
        };
        // wrap the test case object into TestDataObject and return it.
        yield return new object[] { new TestDataObject(testCaseData) };

        // Create the second test case object
        testCaseData = new BookingDialogTestCase
        {
            InitialBookingDetails = new BookingDetails
            {
                Destination = "Seattle",
                Origin = "New York",
                TravelDate = null,
            },
            UtterancesAndReplies = new[,]
            {
                { "hi", "When would you like to travel?" },
                { "tomorrow", $"Please confirm, I have you traveling to: Seattle from: New York on: {DateTime.Now.AddDays(1):yyyy-MM-dd}. Is this correct? (1) Yes or (2) No" },
                { "yes", null },
            },
            ExpectedBookingDetails = new BookingDetails
            {
                Destination = "Seattle",
                Origin = "New York",
                TravelDate = $"{DateTime.Now.AddDays(1):yyyy-MM-dd}",
            },
        };
        // wrap the test case object into TestDataObject and return it.
        yield return new object[] { new TestDataObject(testCaseData) };
    }
}

テスト データを格納するためのオブジェクトと、テスト ケースのコレクションを公開するクラスを作成したら、InlineData ではなく XUnit の MemberData 属性を使用してデータをテストにフィードします。MemberData の最初のパラメーターは、テスト ケースのコレクションを返す静的関数の名前で、2 番目のパラメーターは、このメソッドを公開するクラスの型です。

[Theory]
[MemberData(nameof(BookingDialogTestsDataGenerator.BookingFlows), MemberType = typeof(BookingDialogTestsDataGenerator))]
public async Task DialogFlowUseCases(TestDataObject testData)
{
    // Get the test data instance from TestDataObject
    var bookingTestData = testData.GetObject<BookingDialogTestCase>();
    var sut = new BookingDialog();
    var testClient = new DialogTestClient(Channels.Test, sut, bookingTestData.InitialBookingDetails);

    // Iterate over the utterances and replies array.
    for (var i = 0; i < bookingTestData.UtterancesAndReplies.GetLength(0); i++)
    {
        var reply = await testClient.SendActivityAsync<IMessageActivity>(bookingTestData.UtterancesAndReplies[i, 0]);
        Assert.Equal(bookingTestData.UtterancesAndReplies[i, 1], reply?.Text);
    }

    // Assert the resulting BookingDetails object
    var bookingResults = (BookingDetails)testClient.DialogTurnResult.Result;
    Assert.Equal(bookingTestData.ExpectedBookingDetails?.Origin, bookingResults?.Origin);
    Assert.Equal(bookingTestData.ExpectedBookingDetails?.Destination, bookingResults?.Destination);
    Assert.Equal(bookingTestData.ExpectedBookingDetails?.TravelDate, bookingResults?.TravelDate);
}

ここでは、DialogFlowUseCases テストが実行されたときの Visual Studio テスト エクスプローラーでのテストの結果の例を示します。

Example results for the booking dialog.

モックの使用

現在テストされていない部分に対しては、モック要素を使用できます。 なお、このレベルは、一般的に単体テストおよび統合テストとして考えることができます。

できるだけ多くの要素のモックを作成すると、テスト対象の各部分をより効果的に分離できます。 モック要素の候補としては、ストレージ、アダプター、ミドルウェア、アクティビティ パイプライン、チャネルなど、お使いのボットに直接含まれないものが挙げられます。 これには、テストしているボットの部分に関係ないミドルウェアなどの特定の側面を一時的に削除して、各部分を分離することが必要になる場合もあります。 ただし、ミドルウェアをテストする場合は、代わりにお使いのボットのモックを作成することもできます。

要素のモック作成では、要素を別の既知のオブジェクトで置き換えたり、最小限の Hello World 機能を実装したりといった、複数の形をとることができます。 要素が不要な場合は、削除したり、その要素が何も行わないよう強制したりすることもできます。

モックを使用すると、ダイアログの依存関係を構成できます。また、テストの実行中に、データベース、言語モデル、またはその他のオブジェクトなどの外部リソースに依存せずに、それらが既知の状態であることを保証できます。

ダイアログのテストを容易にして、外部オブジェクトへの依存関係を減らすために、ダイアログ コンストラクターに外部依存関係を挿入する必要がある場合があります。

たとえば、MainDialogBookingDialog をインスタンス化する代わりに、次のようにします。

public MainDialog()
    : base(nameof(MainDialog))
{
    ...
    AddDialog(new BookingDialog());
    ...
}

BookingDialog のインスタンスをコンストラクター パラメーターとして渡します。

public MainDialog(BookingDialog bookingDialog)
    : base(nameof(MainDialog))
{
    ...
    AddDialog(bookingDialog);
    ...
}

これにより、BookingDialog インスタンスをモック オブジェクトに置き換えて、実際の BookingDialog クラスを呼び出さずに MainDialog の単体テストを作成できます。

// Create the mock object
var mockDialog = new Mock<BookingDialog>();

// Use the mock object to instantiate MainDialog
var sut = new MainDialog(mockDialog.Object);

var testClient = new DialogTestClient(Channels.Test, sut);

ダイアログのモックの作成

前述のように、MainDialogBookingDialog を呼び出して BookingDetails オブジェクトを取得します。 次のように、BookingDialog のモック インスタンスを実装して構成します。

// Create the mock object for BookingDialog.
var mockDialog = new Mock<BookingDialog>();
mockDialog
    .Setup(x => x.BeginDialogAsync(It.IsAny<DialogContext>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
    .Returns(async (DialogContext dialogContext, object options, CancellationToken cancellationToken) =>
    {
        // Send a generic activity so we can assert that the dialog was invoked.
        await dialogContext.Context.SendActivityAsync($"{mockDialogNameTypeName} mock invoked", cancellationToken: cancellationToken);

        // Create the BookingDetails instance we want the mock object to return.
        var expectedBookingDialogResult = new BookingDetails()
        {
            Destination = "Seattle",
            Origin = "New York",
            TravelDate = $"{DateTime.UtcNow.AddDays(1):yyyy-MM-dd}"
        };

        // Return the BookingDetails we need without executing the dialog logic.
        return await dialogContext.EndDialogAsync(expectedBookingDialogResult, cancellationToken);
    });

// Create the sut (System Under Test) using the mock booking dialog.
var sut = new MainDialog(mockDialog.Object);

この例では、Moq を使用してモック ダイアログを作成し、Setup メソッドと Returns メソッドを使用してその動作を構成しました。

LUIS 結果のモックの作成

Note

Language Understanding (LUIS) は、2025 年 10 月 1 日に廃止されます。 2023 年 4 月 1 日以降は、新しい LUIS リソースを作成することはできません。 より新しいバージョンの言語理解が、現在、Azure AI Language の一部として提供されています。

Azure AI Language の機能である会話言語理解 (CLU) は、LUIS の更新バージョンです。 Bot Framework SDK での言語理解のサポートの詳細については、「自然言語の理解」を参照してください。

単純なシナリオでは、次のようにコードを使用してモック LUIS 結果を実装できます。

var mockRecognizer = new Mock<IRecognizer>();
mockRecognizer
    .Setup(x => x.RecognizeAsync<FlightBooking>(It.IsAny<ITurnContext>(), It.IsAny<CancellationToken>()))
    .Returns(() =>
    {
        var luisResult = new FlightBooking
        {
            Intents = new Dictionary<FlightBooking.Intent, IntentScore>
            {
                { FlightBooking.Intent.BookFlight, new IntentScore() { Score = 1 } },
            },
            Entities = new FlightBooking._Entities(),
        };
        return Task.FromResult(luisResult);
    });

LUIS の結果は複雑になる場合があります。 その場合は、JSON ファイルに目的の結果をキャプチャし、それをリソースとしてプロジェクトに追加して、LUIS 結果に逆シリアル化した方が簡単です。 次に例を示します。

var mockRecognizer = new Mock<IRecognizer>();
mockRecognizer
    .Setup(x => x.RecognizeAsync<FlightBooking>(It.IsAny<ITurnContext>(), It.IsAny<CancellationToken>()))
    .Returns(() =>
    {
        // Deserialize the LUIS result from embedded json file in the TestData folder.
        var bookingResult = GetEmbeddedTestData($"{GetType().Namespace}.TestData.FlightToMadrid.json");

        // Return the deserialized LUIS result.
        return Task.FromResult(bookingResult);
    });

追加情報