如何对机器人进行单元测试

适用于:SDK v4

本主题介绍如何执行以下操作:

  • 为机器人创建单元测试。
  • 使用断言,根据预期的值检查对话轮次返回的活动。
  • 使用断言检查对话返回的结果。
  • 创建不同类型的数据驱动测试。
  • 为对话的不同依赖项(例如语言识别器等)创建 mock 对象。

先决条件

本主题中使用的 CoreBot 测试示例引用 Microsoft.Bot.Builder.Testing 包、XUnitMoq 来创建单元测试。

核心机器人示例使用语言理解 (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 等)测试不同的呈现逻辑。 如果不确定自己使用的目标通道,则可使用 EmulatorTest 通道 ID,但请注意,某些组件的行为可能因当前通道而异,例如,ConfirmPrompt 以不同方式呈现 TestEmulator 通道的“是”/“否”选项。 也可使用此参数根据通道 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 属性。 在更复杂的机器人中,可能需要断言其他属性,例如 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 参数传递给 DialogTestClientXUnitDialogTestLogger

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 时将测试输出发送到控制台,请参阅 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 来创建模拟对话,并使用了 SetupReturns 方法来配置其行为。

模拟 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);
    });

其他信息