如何單元測試 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 等等)。 如果您不確定目標通道,您可以使用 Emulator
或 Test
通道識別碼,但請記住,某些元件的行為可能會因目前的通道而有所不同,例如, 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 中,您可能會想要判斷提示其他屬性,例如 Speak
、 InputHint
、 ChannelData
等等。
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;
...
}
判斷提示對話方塊回合結果
某些對話方塊,例如 BookingDialog
或 DateResolverDialog
將值傳回給呼叫對話。 物件 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
會將對話方塊所傳送和接收的訊息記錄到主控台。
若要使用此中介軟體,您的測試必須公開建構函式,該建構函式會接收 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
輸出視窗的範例:
如需在使用 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
子專案。 如果其中任何一個失敗,您可以按一下滑鼠右鍵並偵錯失敗的案例,而不是重新執行整個測試集。
使用 MemberData 和複雜類型的理論測試
InlineData
適用于接收簡單實值型別參數的小型資料驅動測試(string、int 等等)。
BookingDialog
會 BookingDetails
接收 物件並傳回新的 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 測試執行器需要實作 等複雜類型 BookingDialogTestCase
IXunitSerializable
,以簡化此動作,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
的範例:
使用模擬
您可以將模擬元素用於目前未測試的專案。 如需參考,此層級通常可視為單元和整合測試。
模擬盡可能多的元素,讓您能夠更好地隔離正在測試的專案。 模擬元素的候選項目包括儲存體、配接器、中介軟體、活動管線、通道,以及不屬於 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 建立模擬對話方塊和 Setup
和 Returns
方法來設定其行為。
模擬 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);
});