如何單元測試 Bot

適用于: SDK v4

在本主題中,我們將說明如何:

  • 建立 Bot 的單元測試。
  • 使用判斷提示來檢查對話回合針對預期值傳回的活動。
  • 使用判斷提示來檢查對話方塊傳回的結果。
  • 建立不同類型的資料驅動測試。
  • 為對話方塊的不同相依性建立模擬物件,例如語言辨識器等等。

必要條件

本主題中使用的 CoreBot 測試 範例會參考 Microsoft.Bot.Builder.Testing 套件、 XUnit Moq 來建立單元測試。

核心 Bot 範例會使用 Language Understanding (LUIS) 來識別使用者意圖;不過,識別使用者意圖並不是本文的重點。 如需識別使用者意圖的相關資訊,請參閱 自然語言理解 將自然語言理解新增至 Bot

注意

Language Understanding (LUIS) 將于 2025 年 10 月 1 日淘汰。 從 2023 年 4 月 1 日起,您將無法建立新的 LUIS 資源。 新版的語言理解現在已提供作為 Azure AI 語言的一部分。

對話式語言理解(CLU)是 Azure AI 語言的一項功能,是 LUIS 的更新版本。 如需 Bot Framework SDK 中語言理解支援的詳細資訊,請參閱 自然語言理解

測試對話方塊

在 CoreBot 範例中,對話是透過 DialogTestClient 類別進行單元測試,其會提供一種機制,以隔離 Bot 外部進行測試,而不需要將程式碼部署至 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 是目標通道。 這可讓您根據 Bot 的目標通道來測試不同的轉譯邏輯(Teams、Slack 等等)。 如果您不確定目標通道,您可以使用 EmulatorTest 通道識別碼,但請記住,某些元件的行為可能會因目前的通道而有所不同,例如, ConfirmPrompt 會以不同的方式呈現 和 Emulator 通道的 Test [是/否] 選項。 您也可以使用此參數,根據通道識別碼,在對話方塊中測試條件式轉譯邏輯。

第二個參數是正在測試之對話的實例。 在本文的範例程式碼中, 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);

在某些情況下,Bot 可能會傳送數則訊息以回應單一活動,在這些情況下 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 傳回活動的 屬性。 在更複雜的 Bot 中,您可能會想要判斷提示其他屬性,例如 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 將值傳回給呼叫對話。 物件 DialogTestClientDialogTurnResult 公開屬性,可用來分析和判斷對話所傳回的結果。

例如:

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 會將對話方塊所傳送和接收的訊息記錄到主控台。

若要使用此中介軟體,您的測試必須公開建構函式,該建構函式會接收 ITestOutputHelper XUnit 測試執行器所提供的物件,並建立 XUnitDialogTestLogger 將傳遞至 DialogTestClient 參數的 middlewares

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 檔中的擷取輸出

此輸出也會在持續整合組建期間記錄在組建伺服器上,並協助您分析組建失敗。

資料驅動測試

在大部分情況下,對話邏輯不會變更,而交談中的不同執行路徑會以使用者語句為基礎。 與其在交談中為每個變體撰寫單一單元測試,也比較容易使用資料驅動測試(也稱為參數化測試)。

例如,本檔概觀區段中的範例測試示範如何測試一個執行流程,但不是其他執行流程,例如:

  • 如果使用者對確認說不,會發生什麼事?
  • 如果他們使用不同的日期,該怎麼辦?

資料驅動測試可讓我們測試所有這些排列,而不需要重寫測試。

在 CoreBot 範例中,我們使用 Theory 來自 XUnit 的測試來參數化測試。

使用 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」。 不要為每個可能的單字撰寫新的測試案例,而是撰寫單 Theory 一測試方法,以透過值清單 InlineData 接受參數,以定義每個測試案例的參數:

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

新的測試會以不同的參數執行四次,每個案例都會在 Visual Studio 測試總管中顯示為測試下的 ShouldBeAbleToCancel 子專案。 如果其中任何一個失敗,您可以按一下滑鼠右鍵並偵錯失敗的案例,而不是重新執行整個測試集。

Example test results for in-line data.

使用 MemberData 和複雜類型的理論測試

InlineData 適用于接收簡單實值型別參數的小型資料驅動測試(string、int 等等)。

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

我們也建立了協助程式 BookingDialogTestsDataGenerator 類別,其會公開 IEnumerable<object[]> BookingFlows() 方法,這個方法會傳回測試所要使用的測試案例集合。

為了在 Visual Studio 測試總管中將每個測試案例顯示為個別專案,XUnit 測試執行器需要實作 等複雜類型 BookingDialogTestCaseIXunitSerializable ,以簡化此動作,Bot.Builder.Testing 架構會提供實 TestDataObject 作此介面的類別,而且可以用來包裝測試案例資料,而不需要實 IXunitSerializable 作 。

以下是 的片段 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) };
    }
}

一旦建立 物件來儲存測試資料和公開測試案例集合的類別之後,我們會使用 XUnit MemberData 屬性,而不是 InlineData 將資料摘要至測試中,第一個參數是靜態函式的名稱,它會傳回測試案例的集合,而第二個參數 MemberData 是公開此方法的類別類型。

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

以下是執行測試時 Visual Studio 測試總管中測試結果 DialogFlowUseCases 的範例:

Example results for the booking dialog.

使用模擬

您可以將模擬元素用於目前未測試的專案。 如需參考,此層級通常可視為單元和整合測試。

模擬盡可能多的元素,讓您能夠更好地隔離正在測試的專案。 模擬元素的候選項目包括儲存體、配接器、中介軟體、活動管線、通道,以及不屬於 Bot 的任何其他專案。 這也可能涉及暫時移除某些層面,例如中介軟體未涉及您正在測試的 Bot 部分,以隔離每個部分。 不過,如果您要測試中介軟體,建議您改用模擬 Bot。

模擬元素可以採用少數形式,從以不同的已知物件取代元素,以實作最少的 hello world 功能。 如果不需要,也可以採取移除專案的形式,或強制它不執行任何動作。

模擬可讓我們設定對話的相依性,並確保它們在測試執行期間處於已知狀態,而不需要依賴資料庫、語言模型或其他物件等外部資源。

為了讓對話更容易測試及減少其與外部物件的相依性,您可能需要在對話建構函式中插入外部相依性。

例如,不要在 中 MainDialog 具現化 BookingDialog

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

我們會將 的實例 BookingDialog 當做建構函式參數傳遞:

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

這可讓我們以模擬物件取代 BookingDialog 實例,並撰寫 的單元測試 MainDialog ,而不需要呼叫實際的 BookingDialog 類別。

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

模擬對話方塊

如上所述, MainDialog 叫用 BookingDialog 以取得 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 建立模擬對話方塊和 SetupReturns 方法來設定其行為。

模擬 LUIS 結果

注意

Language Understanding (LUIS) 將于 2025 年 10 月 1 日淘汰。 從 2023 年 4 月 1 日起,您將無法建立新的 LUIS 資源。 新版的語言理解現在已提供作為 Azure AI 語言的一部分。

對話式語言理解(CLU)是 Azure AI 語言的一項功能,是 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);
    });

其他資訊