Cómo hacer pruebas unitarias de bots
SE APLICA A: SDK v4
En este tema se mostrará cómo:
- Crear pruebas unitarias para bots.
- Usar aserciones para comprobar si las actividades devueltas por un diálogo se encuentran dentro de los valores esperados.
- Usar aserciones para comprobar los resultados devueltos por un diálogo.
- Crear diferentes tipos de pruebas controladas por datos.
- Cree objetos simulados para las distintas dependencias de un diálogo, como los reconocedores de idiomas, etc.
Requisitos previos
El ejemplo CoreBot Tests que se usa en este tema utiliza el paquete Microsoft.Bot.Builder.Testing, XUnit y Moq para crear pruebas unitarias.
El ejemplo de bot principal usa Language Understanding (LUIS) para identificar las intenciones del usuario; sin embargo, la identificación de la intención del usuario no es el foco de este artículo. Para obtener información sobre la identificación de intenciones de usuario, consulte Comprensión del lenguaje natural y Adición de reconocimiento del lenguaje natural al bot.
Nota:
Reconocimiento del lenguaje (LUIS) se retirará el 1 de octubre de 2025. A partir del 1 de abril de 2023, no podrá crear nuevos recursos de LUIS. Hay disponible una versión más reciente de reconocimiento del lenguaje como parte del Lenguaje Azure AI.
Reconocimiento del lenguaje conversacional (CLU), una característica del lenguaje de Azure AI, es la versión actualizada de LUIS. Para obtener más información sobre la compatibilidad con reconocimiento del lenguaje en el SDK de Bot Framework, consulte Reconocimiento del lenguaje natural.
Pruebas de diálogos
En el ejemplo CoreBot, las pruebas unitarias de los diálogos se realizan con la clase DialogTestClient
, que proporciona un mecanismo para probarlos de forma aislada fuera de un bot y sin tener que implementar el código en un servicio web.
Con esta clase, puede escribir pruebas unitarias que validen las respuestas de los diálogos por turnos. Las pruebas unitarias que usan la clase DialogTestClient
deben funcionar con otros diálogos creados con la biblioteca de diálogos de botbuilder.
En el ejemplo siguiente se muestran las pruebas derivadas de 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);
La clase DialogTestClient
se define en el espacio de nombres Microsoft.Bot.Builder.Testing
y se incluye en el paquete de NuGet Microsoft.Bot.Builder.Testing.
DialogTestClient
El primer parámetro de DialogTestClient
es el canal de destino. Esto le permite probar una lógica de representación diferente en función del canal de destino del bot (Teams, Slack, etc.). Si no está seguro de cuál es su canal de destino, puede usar los identificadores de canal Emulator
o Test
, pero tenga en cuenta que algunos componentes se comportarán de forma diferente en función del canal actual; por ejemplo, ConfirmPrompt
representa las opciones sí/no de forma diferente para los canales Test
y Emulator
. También puede usar este parámetro para probar la lógica de representación condicional en el diálogo en función del identificador de canal.
El segundo parámetro es una instancia del cuadro de diálogo que se está probando. En el código de ejemplo de este artículo, sut
representa el sistema a prueba.
El constructor DialogTestClient
proporciona parámetros adicionales que le permiten personalizar aún más el comportamiento del cliente o pasar parámetros al diálogo que se está probando, si es necesario. Puede pasar los datos de inicialización del diálogo, agregar middleware personalizado o usar su propio TestAdapter y su propia instancia de ConversationState
.
Envío y recepción de mensajes
El método SendActivityAsync<IActivity>
permite enviar una expresión de texto o un IActivity
al diálogo y devuelve el primer mensaje que recibe. El parámetro <T>
se usa para devolver una instancia fuertemente tipada de la respuesta para que pueda validarla sin tener que convertirla.
var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
Assert.Equal("Where would you like to travel to?", reply.Text);
En algunos escenarios, el bot puede enviar varios mensajes en respuesta a una sola actividad; en estos casos, DialogTestClient
pondrá en cola las respuestas y puede usar el método GetNextReply<IActivity>
para extraer el siguiente mensaje de la cola de respuesta.
reply = testClient.GetNextReply<IMessageActivity>();
Assert.Equal("All set, I have booked your flight to Seattle for tomorrow", reply.Text);
GetNextReply<IActivity>
devolverá null si no hay más mensajes en la cola de respuesta.
Aserción de actividades
El código del ejemplo CoreBot solo valida la propiedad Text
de las actividades devueltas. En bots más complejos, es posible que desee validar otras propiedades, como Speak
, InputHint
o ChannelData
, etc.
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);
Para ello, puede comprobar cada propiedad individualmente como se muestra arriba, puede escribir sus propias utilidades auxiliares para la aserción de actividades o puede usar otras plataformas, como FluentAssertions, para escribir aserciones personalizadas y simplificar el código de prueba.
Paso de parámetros a los diálogos
El constructor DialogTestClient
tiene initialDialogOptions
que se puede usar para pasar parámetros al diálogo. Por ejemplo, en este ejemplo, MainDialog
inicializa un objeto BookingDetails
a partir de los resultados del reconocimiento lingüístico con las entidades que resuelve a partir de la expresión del usuario, y pasa este objeto en la llamada a BookingDialog
.
Puede implementarlo en una prueba de la siguiente manera:
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
recibe este parámetro y accede a él en la prueba de la misma manera que si se hubiera invocado desde MainDialog
.
private async Task<DialogTurnResult> DestinationStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
var bookingDetails = (BookingDetails)stepContext.Options;
...
}
Aserción de los resultados del turno de diálogo
Algunos diálogos, como BookingDialog
o DateResolverDialog
, devuelven un valor al diálogo que realiza la llamada. El objeto DialogTestClient
expone una propiedad DialogTurnResult
que se puede utilizar para analizar y validar los resultados devueltos por el diálogo.
Por ejemplo:
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);
La propiedad DialogTurnResult
también se puede utilizar para inspeccionar y validar los resultados intermedios devueltos por los pasos de una cascada.
Análisis de la salida de la prueba
A veces es necesario leer una transcripción de la prueba unitaria para analizar la ejecución sin tener que depurar la prueba.
El paquete Microsoft.Bot.Builder.Testing incluye XUnitDialogTestLogger
, que registra los mensajes enviados y recibidos por el diálogo en la consola.
Para usar este middleware, la prueba debe exponer un constructor que recibe un objeto ITestOutputHelper
proporcionado por el ejecutor de pruebas XUnit, y crear un XUnitDialogTestLogger
que se pasará a DialogTestClient
en el parámetro 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);
...
}
}
Este es un ejemplo de lo que XUnitDialogTestLogger
registra en la ventana de salida cuando se configura:
Para más información sobre cómo enviar la salida de la prueba a la consola cuando se usa XUnit, consulte Captura de la salida en la documentación de XUnit.
Esta salida también se registrará en el servidor de compilación durante las compilaciones de integración continua, y le ayudará a analizar los errores de compilación.
Pruebas controladas por datos
En la mayoría de los casos, la lógica de los diálogos no cambia y las distintas rutas de ejecución de una conversación se basan en expresiones del usuario. En lugar de escribir una prueba unitaria única para cada variante de la conversación, es más fácil usar pruebas controladas por datos (también conocidas como pruebas parametrizadas).
Por ejemplo, la prueba de ejemplo de la sección de información general de este documento muestra cómo probar un flujo de ejecución, pero no otros, como:
- ¿Qué ocurre si el usuario dice no a la confirmación?
- ¿Qué ocurre si usan una fecha diferente?
Las pruebas controladas por datos nos permiten probar todas estas permutaciones sin tener que volver a escribir las pruebas.
En el ejemplo CoreBot, usamos pruebas Theory
de XUnit para parametrizar las pruebas.
Pruebas teóricas mediante InlineData
La prueba siguiente comprueba que un diálogo se cancela cuando el usuario dice "Cancelar".
[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);
}
Para cancelar un diálogo, los usuarios pueden escribir "salir", "déjalo" y "para". En lugar de escribir un nuevo caso de prueba para cada palabra posible, escriba un único método de prueba Theory
que acepte parámetros de una lista de valores InlineData
para definir los parámetros para cada caso de prueba:
[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);
}
La nueva prueba se ejecutará cuatro veces con los distintos parámetros y cada caso se mostrará como un elemento secundario en la prueba ShouldBeAbleToCancel
, en el explorador de pruebas de Visual Studio. Si se produce un error en cualquiera de ellos, tal y como se muestra a continuación, puede hacer clic con el botón derecho y depurar el escenario en el que se produjo un error en lugar de volver a ejecutar todo el conjunto de pruebas.
Pruebas teóricas mediante MemberData y tipos complejos
InlineData
es útil para realizar pruebas pequeñas controladas por datos que reciben parámetros de tipo de valor simple (String, int, etc.).
BookingDialog
recibe un objeto BookingDetails
y devuelve un nuevo objeto BookingDetails
. La versión sin parámetros de una prueba para este diálogo tendría el siguiente aspecto:
[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);
}
Para parametrizar esta prueba, creamos una clase BookingDialogTestCase
que contiene los datos de los casos de prueba. Contiene el objeto BookingDetails
inicial, el BookingDetails
esperado y una matriz de cadenas que contienen las expresiones enviadas por el usuario y las respuestas que se espera del diálogo en cada turno.
public class BookingDialogTestCase
{
public BookingDetails InitialBookingDetails { get; set; }
public string[,] UtterancesAndReplies { get; set; }
public BookingDetails ExpectedBookingDetails { get; set; }
}
También hemos creado una clase auxiliar BookingDialogTestsDataGenerator
que expone un método IEnumerable<object[]> BookingFlows()
que devuelve una colección de los casos que se van a usar en la prueba.
Para mostrar cada caso de prueba como un elemento independiente en el explorador de pruebas de Visual Studio, el ejecutor de pruebas XUnit requiere que los tipos complejos como BookingDialogTestCase
implementen IXunitSerializable
. Para simplificar esto, la plataforma Bot.Builder.Testing proporciona una clase TestDataObject
que implementa esta interfaz y que se puede usar para encapsular los datos del caso sin tener que implementar IXunitSerializable
.
Este es un fragmento de IEnumerable<object[]> BookingFlows()
que muestra cómo se usan las dos clases:
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) };
}
}
Una vez que se crea un objeto para almacenar los datos de prueba y una clase que expone una colección de casos de prueba, usamos el atributo MemberData
de XUnit en lugar de InlineData
para insertar los datos en la prueba. El primer parámetro de MemberData
es el nombre de la función estática que devuelve la colección de casos de prueba, y el segundo parámetro es el tipo de la clase que expone este método.
[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);
}
Este es un ejemplo de los resultados de las pruebas DialogFlowUseCases
en el explorador de pruebas de Visual Studio cuando se ejecuta la prueba:
Uso de elementos ficticios
Puede usar elementos ficticios para los aspectos que no está probando actualmente. Como referencia, este nivel puede considerarse generalmente como unidad y prueba de integración.
La simulación de tantos elementos como pueda permite un mejor aislamiento de la pieza que está probando. Los candidatos para los elementos ficticios incluyen el almacenamiento, el adaptador, el software intermedio, la canalización de actividades, los canales y cualquier otra cosa que no forme parte del bot directamente. También se podrían quitar ciertos aspectos temporalmente, como el software intermedio no implicado en la parte del bot que está probando, para aislar cada fragmento. Sin embargo, si va a probar su software intermedio, es posible que quiera simular el bot en su lugar.
La simulación de elementos puede asumir formas diferentes, desde el reemplazo de un elemento por otro objeto conocido a la implementación de una funcionalidad mínima de Hola mundo. También puede consistir en la eliminación del elemento, en caso de que no sea necesario, así como en forzarlo a no hacer nada.
Los elementos ficticios nos permiten configurar las dependencias de un diálogo y asegurarnos de que su estado es conocido durante la ejecución de la prueba sin tener que depender de recursos externos como bases de datos, modelos de lenguaje u otros objetos.
Para facilitar la prueba del diálogo y reducir las dependencias de objetos externos, es posible que tenga que insertar las dependencias externas en el constructor de diálogos.
Por ejemplo, en lugar de crear instancias de BookingDialog
en MainDialog
:
public MainDialog()
: base(nameof(MainDialog))
{
...
AddDialog(new BookingDialog());
...
}
Pasamos una instancia de BookingDialog
como un parámetro de constructor:
public MainDialog(BookingDialog bookingDialog)
: base(nameof(MainDialog))
{
...
AddDialog(bookingDialog);
...
}
Esto nos permite reemplazar la instancia BookingDialog
con un objeto ficticio y escribir pruebas unitarias para MainDialog
sin tener que llamar a la clase BookingDialog
real.
// 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);
Diálogos ficticios
Como se describió anteriormente, MainDialog
invoca a BookingDialog
para obtener el objeto BookingDetails
. Implementamos y configuramos una instancia ficticia de BookingDialog
de la siguiente manera:
// 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);
En este ejemplo, usamos Moq para crear el diálogo ficticio, y los métodos Setup
y Returns
para configurar su comportamiento.
Resultados de LUIS ficticios
Nota:
Reconocimiento del lenguaje (LUIS) se retirará el 1 de octubre de 2025. A partir del 1 de abril de 2023, no podrá crear nuevos recursos de LUIS. Hay disponible una versión más reciente de reconocimiento del lenguaje como parte del Lenguaje Azure AI.
Reconocimiento del lenguaje conversacional (CLU), una característica del lenguaje de Azure AI, es la versión actualizada de LUIS. Para obtener más información sobre la compatibilidad con reconocimiento del lenguaje en el SDK de Bot Framework, consulte Reconocimiento del lenguaje natural.
En escenarios sencillos, puede implementar resultados de LUIS ficticios mediante código de la siguiente manera:
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);
});
Los resultados de LUIS pueden ser complejos. Cuando lo son, es más sencillo capturar el resultado deseado en un archivo JSON, añadirlo como recurso a su proyecto y deserializarlo en un resultado LUIS. Este es un ejemplo:
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);
});