如何对机器人进行单元测试
适用于:SDK v4
本主题介绍如何执行以下操作:
- 为机器人创建单元测试。
- 使用断言,根据预期的值检查对话轮次返回的活动。
- 使用断言检查对话返回的结果。
- 创建不同类型的数据驱动测试。
- 为对话的不同依赖项(例如语言识别器等)创建 mock 对象。
先决条件
本主题中使用的 CoreBot 测试示例引用 Microsoft.Bot.Builder.Testing 包、XUnit 和 Moq 来创建单元测试。
核心机器人示例使用语言理解 (LUIS) 来辨识用户意图;但是,辨识用户意图不是本文的重点。 有关辨识用户意图的信息,请参阅自然语言理解和向机器人添加自然语言理解。
注意
语言理解 (LUIS) 将于 2025 年 10 月 1 日停用。 从 2023 年 4 月 1 日开始,将无法创建新的 LUIS 资源。 语言理解的较新版本现已作为 Azure AI 语言的一部分提供。
对话语言理解(CLU)是 Azure AI 语言的一项功能,是 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
通道的“是”/“否”选项。 也可使用此参数根据通道 ID 在对话中测试条件呈现逻辑。
第二个参数是正在测试的对话的实例。 在本文中的示例代码中,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);
在某些情况下,机器人可能会发送多条消息来响应单个活动。在这些情况下,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
属性。 在更复杂的机器人中,可能需要断言其他属性,例如 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
,用于将对话发送和接收的消息记录到控制台。
若要使用此中间件,测试需公开一个构造函数来接收 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
记录到输出窗口的内容:
若要进一步了解如何在使用 XUnit 时将测试输出发送到控制台,请参阅 XUnit 文档中的 Capturing Output(捕获输出)。
此输出也会在持续集成生成期间记录到生成服务器,用于分析生成故障。
数据驱动测试
大多数情况下,对话逻辑不会更改,聊天中的不同执行路径基于用户话语。 与其为对话中的每个变体编写单个单元测试,不如使用数据驱动测试(也称为参数化测试)。
例如,本文档概述部分中的示例测试演示如何测试单个执行流,但没有涵盖其他执行流,例如:
- 如果用户对确认说不,会发生什么?
- 如果他们使用不同的日期会怎么样?
数据驱动测试允许我们测试所有这些排列组合,不需重新编写测试代码。
在 CoreBot 示例中,我们使用 XUnit 中的 Theory
测试来参数化测试。
使用 InlineData 的 Theory 测试
以下测试检查当用户说“cancel”时对话是否会取消。
[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);
}
新测试将使用不同的参数执行 4 次,每个用例会在 Visual Studio 测试资源管理器的 ShouldBeAbleToCancel
测试下显示为一个子项。 如果其中某些测试失败(如下所示),则可右键单击并调试失败的方案,不必重新运行整个测试集。
使用 MemberData 和复杂类型的 Theory 测试
InlineData
适用于那些接收简单值类型参数(字符串、整数等)的小型数据驱动测试。
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
测试时的结果:
使用模拟
可以对当前不测试的项目使用模拟元素。 通常可以将此级别视为单元和集成测试(供参考)。
尽可能多模拟一些元素,这样可以更好地隔离要测试的组件。 模拟元素的候选对象包括存储、适配器、中间件、活动管道、通道,以及任何其他不直接归属于机器人的部件。 这种测试也可能会临时去除某些方面的内容(例如,不让中间件加入要测试的机器人),以便隔离每个组件。 但是,若要测试中间件,则可改为对机器人进行模拟。
模拟元素可以采用多种形式,例如使用另一已知对象来替换某个元素,或者实现最小的 hello world 功能。 也可直接删除该元素(如果不是必需的元素),或者强制它不执行任何操作。
可以通过模拟配置对话的依赖项,确保它们在测试执行过程中处于已知状态,不需依赖于外部资源(例如数据库、语言模型或其他对象)。
为了使对话更容易测试并减少其对外部对象的依赖,可能需要将外部依赖项注入对话构造函数中。
例如,我们不需要在 MainDialog
中实例化 BookingDialog
:
public MainDialog()
: base(nameof(MainDialog))
{
...
AddDialog(new BookingDialog());
...
}
只需将 BookingDialog
实例作为构造函数参数传递即可:
public MainDialog(BookingDialog bookingDialog)
: base(nameof(MainDialog))
{
...
AddDialog(bookingDialog);
...
}
这样我们就可以将 BookingDialog
实例替换为 mock 对象并为 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 结果
注意
语言理解 (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);
});