如何对机器人进行单元测试
先决条件
本主题中使用的 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 记录到输出窗口的内容:

Example middleware output from XUnit.
若要进一步了解如何在使用 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); -
});
感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)