Partilhar via


Como testar bots de unidade

APLICA-SE A: SDK v4

Neste tópico, mostraremos como:

  • Crie testes de unidade para bots.
  • Use assert para verificar se há atividades retornadas por uma caixa de diálogo em relação aos valores esperados.
  • Use assert para verificar os resultados retornados por uma caixa de diálogo.
  • Crie diferentes tipos de testes orientados por dados.
  • Crie objetos fictícios para as diferentes dependências de uma caixa de diálogo, como reconhecedores de idioma e assim por diante.

Pré-requisitos

O exemplo de bot principal usa Language Understanding (LUIS) para identificar as intenções do usuário; no entanto, identificar a intenção do usuário não é o foco deste artigo. Para obter informações sobre como identificar as intenções do usuário, consulte Compreensão de linguagem natural e Adicionar compreensão de linguagem natural ao seu bot.

Nota

O Language Understanding (LUIS) será aposentado em 1 de outubro de 2025. A partir de 1 de abril de 2023, não será possível criar novos recursos LUIS. Uma versão mais recente do entendimento de idiomas agora está disponível como parte do Azure AI Language.

O entendimento de linguagem conversacional (CLU), um recurso do Azure AI Language, é a versão atualizada do LUIS. Para obter mais informações sobre o suporte à compreensão de linguagem no SDK do Bot Framework, consulte Compreensão de linguagem natural.

Caixas de diálogo de teste

No exemplo CoreBot, as caixas de diálogo são testadas por unidade por meio da DialogTestClient classe, que fornece um mecanismo para testá-las isoladamente fora de um bot e sem ter que implantar seu código em um serviço Web.

Usando essa classe, você pode escrever testes de unidade que validam respostas de diálogos passo a passo. Os testes de unidade usando classe devem funcionar com outras caixas de diálogo criadas usando DialogTestClient a biblioteca de diálogos do botbuilder.

O exemplo a seguir demonstra testes derivados 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);

A DialogTestClient classe é definida no namespace e incluída no Microsoft.Bot.Builder.Testing pacote NuGet Microsoft.Bot.Builder.Testing .

DialogTestClient

O primeiro parâmetro de é o canal de DialogTestClient destino. Isso permite que você teste diferentes lógicas de renderização com base no canal de destino para seu bot (Teams, Slack e assim por diante). Se você não tiver certeza sobre seu canal de destino, poderá usar os IDs do canal ouTest, mas lembre-se de que alguns componentes podem se comportar de forma diferente dependendo do canal atual, por exemplo, ConfirmPrompt renderiza as opções Sim/Não de forma diferente para os Emulator Test canais e Emulator . Você também pode usar esse parâmetro para testar a lógica de renderização condicional em sua caixa de diálogo com base no ID do canal.

O segundo parâmetro é uma instância da caixa de diálogo que está sendo testada. No código de exemplo neste artigo, sut representa o sistema em teste.

O DialogTestClient construtor fornece parâmetros adicionais que permitem personalizar ainda mais o comportamento do cliente ou passar parâmetros para a caixa de diálogo que está sendo testada, se necessário. Você pode passar dados de inicialização para a caixa de diálogo, adicionar middleware personalizado ou usar seu próprio TestAdapter e ConversationState instância.

Enviar e receber mensagens

O SendActivityAsync<IActivity> método permite que você envie uma emissão de texto ou um IActivity para sua caixa de diálogo e retorna a primeira mensagem que recebe. O <T> parâmetro é usado para retornar uma instância digitada forte da resposta para que você possa afirmá-la sem precisar convertê-la.

var reply = await testClient.SendActivityAsync<IMessageActivity>("hi");
Assert.Equal("Where would you like to travel to?", reply.Text);

Em alguns cenários, seu bot pode enviar várias mensagens em resposta a uma única atividade, nesses casos DialogTestClient enfileirará as respostas e você poderá usar o GetNextReply<IActivity> método para exibir a próxima mensagem da fila de respostas.

reply = testClient.GetNextReply<IMessageActivity>();
Assert.Equal("All set, I have booked your flight to Seattle for tomorrow", reply.Text);

GetNextReply<IActivity> retornará null se não houver mais mensagens na fila de resposta.

Atividades de afirmação

O código no exemplo CoreBot afirma apenas a Text propriedade das atividades retornadas. Em bots mais complexos, você pode querer afirmar outras propriedades como Speak, , InputHintChannelData, e assim por diante.

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

Você pode fazer isso verificando cada propriedade individualmente, como mostrado acima, você pode escrever seus próprios utilitários auxiliares para asserção de atividades ou você pode usar outras estruturas como FluentAssertions para escrever asserções personalizadas e simplificar seu código de teste.

Passando parâmetros para suas caixas de diálogo

O DialogTestClient construtor tem um initialDialogOptions que pode ser usado para passar parâmetros para sua caixa de diálogo. Por exemplo, o MainDialog neste exemplo, inicializa um BookingDetails objeto a partir dos resultados de reconhecimento de idioma, com as entidades que ele resolve a partir da emissão do usuário, e passa esse objeto na chamada para invocar BookingDialog.

Você pode implementar isso em um teste da seguinte maneira:

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 recebe esse parâmetro e o acessa no teste da mesma forma que seria quando invocado do MainDialog.

private async Task<DialogTurnResult> DestinationStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    var bookingDetails = (BookingDetails)stepContext.Options;
    ...
}

Declarando resultados de virada de caixa de diálogo

Algumas caixas de diálogo gostam BookingDialog ou DateResolverDialog retornam um valor para a caixa de diálogo de chamada. O DialogTestClient objeto expõe uma DialogTurnResult propriedade que pode ser usada para analisar e afirmar os resultados retornados pela caixa de diálogo.

Por exemplo:

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

A DialogTurnResult propriedade também pode ser usada para inspecionar e afirmar resultados intermediários retornados pelas etapas em uma cachoeira.

Analisando a saída do teste

Às vezes, é necessário ler uma transcrição de teste de unidade para analisar a execução do teste sem ter que depurar o teste.

O pacote Microsoft.Bot.Builder.Testing inclui um XUnitDialogTestLogger que registra as mensagens enviadas e recebidas pela caixa de diálogo no console.

Para usar esse middleware, seu teste precisa expor um construtor que recebe um objeto que é fornecido pelo executor de teste XUnit e criar um ITestOutputHelper XUnitDialogTestLogger que será passado para DialogTestClient através do middlewares parâmetro.

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

        ...
    }
}

Aqui está um exemplo do que os XUnitDialogTestLogger logs para a janela de saída quando ela é configurada:

Example middleware output from XUnit.

Para obter informações adicionais sobre como enviar saída de teste para o console ao usar o XUnit, consulte Capturando saída na documentação do XUnit.

Essa saída também será registrada no servidor de compilação durante as compilações de integração contínua e ajuda você a analisar falhas de compilação.

Testes orientados por dados

Na maioria dos casos, a lógica de diálogo não muda e os diferentes caminhos de execução em uma conversa são baseados nas declarações do usuário. Em vez de escrever um único teste de unidade para cada variante na conversa, é mais fácil usar testes orientados por dados (também conhecido como teste parametrizado).

Por exemplo, o teste de exemplo na seção de visão geral deste documento mostra como testar um fluxo de execução, mas não outros, como:

  • O que acontece se o utilizador disser não à confirmação?
  • E se usarem uma data diferente?

Os testes orientados por dados nos permitem testar todas essas permutações sem ter que reescrever os testes.

No exemplo CoreBot, usamos Theory testes do XUnit para parametrizar testes.

Testes teóricos usando InlineData

O teste a seguir verifica se uma caixa de diálogo é cancelada quando o usuário diz "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 uma caixa de diálogo, os usuários podem digitar "quit", "never mind" e "stop it". Em vez de escrever um novo caso de teste para cada palavra possível, escreva um único Theory método de teste que aceite parâmetros por meio de uma lista de valores para definir os parâmetros para cada caso de InlineData teste:

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

O novo teste será executado quatro vezes com os diferentes parâmetros e cada caso será mostrado como um item filho sob o ShouldBeAbleToCancel teste no Visual Studio Test Explorer. Se algum deles falhar, conforme mostrado abaixo, você pode clicar com o botão direito do mouse e depurar o cenário que falhou em vez de executar novamente todo o conjunto de testes.

Example test results for in-line data.

Testes teóricos usando MemberData e tipos complexos

InlineData é útil para pequenos testes orientados por dados que recebem parâmetros de tipo de valor simples (string, int e assim por diante).

O BookingDialog recebe um objeto e retorna um BookingDetails novo BookingDetails objeto. Uma versão não parametrizada de um teste para esta caixa de diálogo teria a seguinte aparência:

[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 esse teste, criamos uma BookingDialogTestCase classe que contém nossos dados de caso de teste. Ele contém o objeto inicial BookingDetails , o esperado BookingDetails e uma matriz de cadeias de caracteres contendo os enunciados enviados pelo usuário e as respostas esperadas da caixa de diálogo para cada turno.

public class BookingDialogTestCase
{
    public BookingDetails InitialBookingDetails { get; set; }

    public string[,] UtterancesAndReplies { get; set; }

    public BookingDetails ExpectedBookingDetails { get; set; }
}

Também criamos uma classe auxiliar BookingDialogTestsDataGenerator que expõe um IEnumerable<object[]> BookingFlows() método que retorna uma coleção dos casos de teste a serem usados pelo teste.

Para exibir cada caso de teste como um item separado no Visual Studio Test Explorer, o executor de teste XUnit requer que tipos complexos como BookingDialogTestCase implement IXunitSerializable, para simplificar isso, a estrutura Bot.Builder.Testing fornece uma TestDataObject classe que implementa essa interface e pode ser usada para encapsular os dados do caso de teste sem ter que implementar IXunitSerializable.

Aqui está um fragmento que IEnumerable<object[]> BookingFlows() mostra como as duas classes são usadas:

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

Depois de criarmos um objeto para armazenar os dados de teste e uma classe que expõe uma coleção de casos de teste, usamos o atributo XUnit MemberData em vez de alimentar os dados no teste, o primeiro parâmetro para MemberData é o nome da função estática que retorna a coleção de casos de InlineData teste e o segundo parâmetro é o tipo da classe que expõe esse 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);
}

Aqui está um exemplo dos resultados para os DialogFlowUseCases testes no Visual Studio Test Explorer quando o teste é executado:

Example results for the booking dialog.

Usando simulações

Você pode usar elementos fictícios para as coisas que não foram testadas no momento. Para referência, este nível geralmente pode ser considerado como teste de unidade e integração.

Zombar do maior número possível de elementos permite um melhor isolamento da peça que está a testar. Os candidatos a elementos fictícios incluem armazenamento, adaptador, middleware, pipeline de atividades, canais e qualquer outra coisa que não faça parte diretamente do seu bot. Isso também pode envolver a remoção temporária de certos aspetos, como middleware não envolvido na parte do bot que você está testando, para isolar cada peça. No entanto, se você estiver testando seu middleware, talvez queira zombar do seu bot.

Os elementos zombeteiros podem assumir várias formas, desde a substituição de um elemento por um objeto conhecido diferente até a implementação da funcionalidade mínima do hello world. Isso também pode assumir a forma de remover o elemento, se não for necessário, ou forçá-lo a não fazer nada.

As simulações nos permitem configurar as dependências de uma caixa de diálogo e garantir que elas estejam em um estado conhecido durante a execução do teste sem ter que depender de recursos externos, como bancos de dados, modelos de linguagem ou outros objetos.

Para facilitar o teste da caixa de diálogo e reduzir suas dependências em objetos externos, talvez seja necessário injetar as dependências externas no construtor da caixa de diálogo.

Por exemplo, em vez de instanciar BookingDialog em MainDialog:

public MainDialog()
    : base(nameof(MainDialog))
{
    ...
    AddDialog(new BookingDialog());
    ...
}

Passamos uma instância de como um parâmetro do BookingDialog construtor:

public MainDialog(BookingDialog bookingDialog)
    : base(nameof(MainDialog))
{
    ...
    AddDialog(bookingDialog);
    ...
}

Isso nos permite substituir a instância por um objeto simulado e escrever testes de unidade para MainDialog sem ter que chamar a BookingDialog classe realBookingDialog.

// 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 simulados

Conforme descrito acima, MainDialog invoca BookingDialog para obter o BookingDetails objeto. Implementamos e configuramos uma instância simulada da BookingDialog seguinte forma:

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

Neste exemplo, usamos o Moq para criar a caixa de diálogo simulada e os Setup métodos e Returns para configurar seu comportamento.

Zombando dos resultados do LUIS

Nota

O Language Understanding (LUIS) será aposentado em 1 de outubro de 2025. A partir de 1 de abril de 2023, não será possível criar novos recursos LUIS. Uma versão mais recente do entendimento de idiomas agora está disponível como parte do Azure AI Language.

O entendimento de linguagem conversacional (CLU), um recurso do Azure AI Language, é a versão atualizada do LUIS. Para obter mais informações sobre o suporte à compreensão de linguagem no SDK do Bot Framework, consulte Compreensão de linguagem natural.

Em cenários simples, você pode implementar resultados LUIS simulados através do código da seguinte maneira:

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

Os resultados do LUIS podem ser complexos. Quando são, é mais simples capturar o resultado desejado em um arquivo JSON, adicioná-lo como um recurso ao seu projeto e desserializá-lo em um resultado LUIS. Eis um exemplo:

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

Informações adicionais