Как выполнять модульное тестирование ботов
ОБЛАСТЬ ПРИМЕНЕНИЯ: ПАКЕТ SDK версии 4
В этой статье описаны следующие задачи:
- Создание модульных тестов для ботов.
- Используйте утверждение, чтобы проверка для действий, возвращаемых диалоговым окном, против ожидаемых значений.
- Используйте утверждение для проверка результатов, возвращаемых диалогом.
- Создание различных типов управляемых данными тестов.
- Создание макетных объектов для различных зависимостей диалогового окна, таких как распознаватель языков и т. д.
Необходимые компоненты
Пример CoreBot Tests ссылается на пакет Microsoft.Bot.Builder.Testing, XUnit и Moq для создания модульных тестов.
В примере основного бота используется Распознавание речи (LUIS) для выявления намерений пользователей. Однако определение намерения пользователя не является фокусом этой статьи. Сведения о выявлении намерений пользователей см. в разделе " Распознавание естественного языка" и "Добавление распознавания естественного языка" в бот.
Примечание.
Распознавание речи (LUIS) будет прекращен 1 октября 2025 года. Начиная с 1 апреля 2023 года вы не сможете создавать новые ресурсы LUIS. Новая версия распознавания речи теперь доступна как часть языка ИИ Azure.
Распознавание речи (CLU) — это обновленная версия LUIS. Дополнительные сведения о поддержке распознавания речи в пакете SDK Bot Framework см. в разделе "Распознавание естественного языка".
Тестирование диалогов
В примере CoreBot диалоги тестируются с помощью DialogTestClient
класса, который предоставляет механизм для их тестирования в изоляции за пределами бота и без необходимости развертывать код в веб-службе.
С помощью этого класса можно создавать модульные тесты, которые проверяют ответы в диалогах на основе смены шагов. Модульные тесты с использованием класса DialogTestClient
должны поддерживать другие диалоги, созданные с помощью библиотеки диалогов Bot Builder.
В следующем примере показаны тесты с использованием 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
. Он включен в пакет NuGet Microsoft.Bot.Builder.Testing.
DialogTestClient
Первый параметр DialogTestClient
— это целевой канал. Это позволяет протестировать другую логику отрисовки на основе целевого канала для бота (Teams, Slack и т. д.). Если вы не уверены в целевом канале, вы можете использовать Emulator
идентификаторы или Test
идентификаторы каналов, но помните, что некоторые компоненты могут вести себя по-разному в зависимости от текущего канала, например, ConfirmPrompt
отрисовывает параметры "Да/нет" по-разному для Test
каналов и Emulator
каналов. Этот параметр также можно использовать для проверки условной логики визуализации в диалоге на основе идентификатора канала.
Второй параметр — это экземпляр проверяемого диалогового окна. В примере кода в этой статье 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
для записи в консоль сообщений, отправляемых и получаемых диалогом.
Чтобы использовать это ПО промежуточного слоя, тест должен использовать конструктор, который получает объект 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);
}
Новый тест будет выполнен четыре раза с разными параметрами, и каждый случай будет отображаться как дочерний элемент в тесте ShouldBeAbleToCancel
в visual Studio Test Обозреватель. Если любой из них завершится ошибкой, как показано ниже, щелкните правой кнопкой мыши и отладите сценарий, который завершился сбоем, а не повторно запустить весь набор тестов.
Тестирование с использованием MemberData и сложных типов
InlineData
полезно для небольших тестов, управляемых данными, которые получают простые параметры типа значений (строка, 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) };
}
}
Создав объект для хранения тестовых данных и предоставляющий коллекцию тестовых случаев класс, мы воспользуемся атрибутом MemberData
XUnit вместо 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);
}
Ниже приведен пример результатов DialogFlowUseCases
для тестов в Visual Studio Test Обозреватель при выполнении теста:
Использование макетов
Вы можете использовать макетные элементы для вещей, которые в настоящее время не протестированы. В справочных целях этот уровень можно рассматривать как модульное и интеграционное тестирование.
Насмешка на столько элементов, сколько можно сделать для лучшей изоляции части, которую вы тестируете. Кандидаты на элементы макета включают хранилище, адаптер, ПО промежуточного слоя, конвейер действий, каналы и другие элементы, которые не являются непосредственно частью бота. Это также может включать временное удаление определенных аспектов, таких как ПО промежуточного слоя, не связанное с частью бота, который вы тестируете, чтобы изолировать каждую часть. Однако если вы тестируете ПО промежуточного слоя, вместо этого может потребоваться вымахивать бота.
Макетирование элементов может принимать несколько форм, от замены элемента другим известным объектом до реализации минимальных функциональных возможностей hello world. Это также может быть связано с удалением элемента, если это не нужно, или принудительное выполнение этого действия.
Макеты позволяют настроить зависимости диалогового окна и убедиться, что они имеют известное состояние во время выполнения теста без необходимости полагаться на внешние ресурсы, такие как базы данных, языковые модели или другие объекты.
Чтобы облегчить тестирование диалогов и сократить зависимости от внешних объектов, вам может потребоваться внедрить внешние зависимости в конструктор диалогов.
Например, вместо создания экземпляра BookingDialog
в MainDialog
сделайте следующее:
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
Примечание.
Распознавание речи (LUIS) будет прекращен 1 октября 2025 года. Начиная с 1 апреля 2023 года вы не сможете создавать новые ресурсы LUIS. Новая версия распознавания речи теперь доступна как часть языка ИИ Azure.
Распознавание речи (CLU) — это обновленная версия LUIS. Дополнительные сведения о поддержке распознавания речи в пакете SDK Bot Framework см. в разделе "Распознавание естественного языка".
В простых сценариях можно реализовать результаты макета 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);
});