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.
  • Crear objetos ficticios para las diferentes dependencias de un diálogo (es decir, reconocedores de LUIS, etc.).

Prerrequisitos

El ejemplo CoreBot Tests que se usa en este tema utiliza el paquete Microsoft.Bot.Builder.Testing, XUnit y Moq para crear pruebas unitarias.

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 basada en el canal de destino del bot (Teams, Slack, etc.). Si no está seguro sobre el canal de destino, puede usar los Emulator identificadores de canal o Test pero tenga en cuenta que algunos componentes pueden comportarse de forma diferente en función del canal actual, por ejemplo, ConfirmPrompt representa las opciones Sí/No de forma diferente para los Test canales 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 diálogo que se está probando. (Nota: "sut" significa "sistema en pruebas"; usamos este acrónimo en los fragmentos de código de este artículo).

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, por ejemplo.

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 de LUIS 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:

Middleware output from XUnit

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).

En la prueba de ejemplo de la sección Información general de este documento se muestra cómo probar un flujo de ejecución, pero ¿qué sucede si el usuario responde no a la confirmación? o ¿qué ocurre si usan una fecha diferente?, por ejemplo.

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.

Test results for in-line data

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:

Example results for the booking dialog

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.

Simulación de tantos elementos como pueda permitir 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 básica de Hola mundo. Esto también podría adoptar la forma de simplemente quitar el elemento, si no es necesario, o simplemente 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 LUIS 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

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

Pero los resultados de LUIS pueden ser complejos y, cuando lo son, es más fácil capturar el resultado en un archivo JSON, agregarlo como un recurso al proyecto y deserializarlo en un resultado de 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);
    });

Información adicional