Como realizar testes de unidade em bots
APLICA-SE A: SDK v4
Neste tópico, mostraremos como:
- Crie testes de unidade para bots.
- Use o assert para verificar se as atividades retornadas por um diálogo correspondem aos valores esperados.
- Use o assert para verificar os resultados retornados por um diálogo.
- Crie diferentes tipos de testes orientados por dados.
- Crie objetos fictícios para as diferentes dependências de um diálogo, como reconhecedores de idioma e assim por diante.
Pré-requisitos
O exemplo de Testes de CoreBot usado neste tópico faz referência ao pacote Microsoft.Bot.Builder.Testing, ao XUnit e ao Moq para criar testes de unidade.
O exemplo do bot principal usa o reconhecimento de linguagem (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, confira Reconhecimento de linguagem natural e Adicionar reconhecimento de linguagem natural ao seu bot.
Observação
O reconhecimento de linguagem (LUIS) será desativado em 1.º de outubro de 2025. A partir de 1.º de abril de 2023, não será possível criar novos recursos do LUIS. Uma versão mais recente de reconhecimento de linguagem já está disponível como parte da Linguagem de IA do Azure.
A compreensão da linguagem coloquial (CLU), um recurso da Linguagem de IA do Azure, é a versão atualizada do LUIS. Para obter mais informações sobre o suporte ao reconhecimento de linguagem no SDK do Bot Framework, confira Reconhecimento de linguagem natural.
Como testar caixas de diálogo
No exemplo do CoreBot, as caixas de diálogo são testadas na unidade por meio da classe DialogTestClient
, o que fornece um mecanismo para testá-las isoladamente fora de um bot e sem precisar implantar o código em um serviço Web.
Usando essa classe, você pode escrever testes de unidade que validam as respostas das caixas de diálogo, turno a turno. Os testes de unidade usando a classe DialogTestClient
devem funcionar com outras caixas de diálogo criadas usando a biblioteca de caixas de diálogo botbuilder.
O exemplo a seguir demonstra os 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 classe DialogTestClient
é definida no namespace Microsoft.Bot.Builder.Testing
e incluída no pacote NuGet Microsoft.Bot.Builder.Testing.
DialogTestClient
O primeiro parâmetro de DialogTestClient
é o canal de destino. Isso permite que você teste diferentes lógicas de renderização com base no canal de destino do seu bot (Teams, Slack e assim por diante). Se você não tiver certeza sobre o canal de destino, poderá usar os IDs de canal Emulator
ou Test
, mas lembre-se de que alguns componentes podem se comportar de forma diferente dependendo do canal atual, por exemplo, o ConfirmPrompt
renderiza as opções Sim/Não de forma diferente para os canais Test
e Emulator
. Você também pode usar esse parâmetro para testar a lógica de renderização condicional na caixa de diálogo com base na ID do canal.
O segundo parâmetro é uma instância de diálogo que está sendo testada. No código de exemplo deste artigo, sut
representa o sistema em teste.
O construtor DialogTestClient
fornece parâmetros adicionais que permitem personalizar ainda mais o comportamento do cliente ou transmitir parâmetros para o diálogo que está sendo testado, se necessário. Você pode passar dados de inicialização para a caixa de diálogo, adicionar um middleware personalizado ou usar seu próprio TestAdapter e instância de ConversationState
.
Enviar e receber mensagens
O método SendActivityAsync<IActivity>
permite que você envie um enunciado de texto ou IActivity
um para a caixa de diálogo e retorna a primeira mensagem recebida. O parâmetro <T>
é usado para retornar uma instância fortemente tipada da resposta para que você possa declará-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, o bot pode enviar várias mensagens em resposta a uma única atividade. Nesses casos, DialogTestClient
enfileirará as respostas e você poderá usar o método GetNextReply<IActivity>
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.
Declaração de atividades
O código no exemplo CoreBot declara apenas a propriedade Text
das atividades retornadas. Em bots mais complexos, você pode querer afirmar outras propriedades como Speak
, InputHint
, ChannelData
, 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 conforme mostrado acima, pode escrever seus próprios utilitários auxiliares para declaração de atividades ou pode usar outras estruturas como FluentAssertions para escrever declarações personalizadas e simplificar seu código de teste.
Passando parâmetros para a caixa de diálogo
O construtor DialogTestClient
tem um initialDialogOptions
que pode ser usado para passar parâmetros para a caixa de diálogo. Por exemplo, neste exemplo, o MainDialog
inicializa um objeto BookingDetails
dos resultados de reconhecimento de linguagem, com as entidades que ele resolve do enunciado do usuário, e transmite 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 maneira que ocorreria quando invocado de MainDialog
.
private async Task<DialogTurnResult> DestinationStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
var bookingDetails = (BookingDetails)stepContext.Options;
...
}
Declarando resultados em turnos de caixa de diálogo
Algumas caixas de diálogo como BookingDialog
ou DateResolverDialog
retornam um valor para a caixa de diálogo que faz a chamada. O objeto DialogTestClient
expõe uma propriedade DialogTurnResult
que pode ser usada para analisar e declarar 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 propriedade DialogTurnResult
também pode ser usada para inspecionar e declarar resultados intermediários retornados pelas etapas em uma cascata.
Analisando a saída de teste
Às vezes, é necessário ler a transcrição de um teste de unidade para analisar a execução do teste sem precisar depurá-lo.
O pacote Microsoft.Bot.Builder.Testing inclui um XUnitDialogTestLogger
que registra em log 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 ITestOutputHelper
que é fornecido pelo executor de teste do xUnit e criar um XUnitDialogTestLogger
que será passado para DialogTestClient
por meio do 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);
...
}
}
Aqui está um exemplo do que os logs XUnitDialogTestLogger
registram na janela de saída quando ele está configurado:
Para obter informações adicionais sobre como enviar a saída de teste para o console ao usar o XUnit, confira Capturar a saída na documentação do XUnit.
Essa saída também será registrada no servidor de build durante os builds de integração contínua e ajudará você a analisar falhas de build.
Testes controlados por dados
Na maioria dos casos, a lógica da caixa de diálogo não é alterada e os caminhos de execução diferentes em uma conversa são baseados nos enunciados 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 conhecidos como testes parametrizados).
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 acontecerá se o usuário disser não à confirmação?
- E se eles usarem uma data diferente?
Os testes controlados por dados nos permitem testar todas essas permutações sem precisar reescrever os testes.
No exemplo de CoreBot, usamos testes Theory
do XUnit para parametrizar testes.
Testes de teoria usando InlineData
O teste a seguir verifica se um diálogo é cancelado 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 "sair", "desistir" e "parar". Em vez de escrever um novo caso de teste para cada palavra possível, escreva um único método de teste Theory
que aceite parâmetros por meio de uma lista de valores InlineData
para definir os parâmetros de cada caso de 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á exibido como um item filho sob o teste ShouldBeAbleToCancel
no Gerenciador de Testes do Visual Studio. Se algum deles falhar, como mostrado abaixo, você poderá 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.
Testes de teoria usando MemberData e tipos complexos
InlineData
é útil para pequenos testes orientados por dados que recebem parâmetros de tipo de valor simples (sequência, int e assim por diante).
O BookingDialog
recebe um objeto BookingDetails
e retorna um novo objeto BookingDetails
. 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 classe BookingDialogTestCase
que contém os dados do nosso caso de teste. Ele contém o objeto inicial BookingDetails
, o BookingDetails
esperado 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 método IEnumerable<object[]> BookingFlows()
, 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 Gerenciador de Testes do Visual Studio, o executor de teste XUnit requer que tipos complexos, tais como BookingDialogTestCase
, implementem IXunitSerializable
. Para simplificar isso, a estrutura Bot.Builder.Testing fornece uma classe TestDataObject
que implementa essa interface e pode ser usada para encapsular os dados do caso de teste sem necessidade de implementar IXunitSerializable
.
Aqui está um fragmento do IEnumerable<object[]> BookingFlows()
que 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 MemberData
do XUnit em vez de InlineData
para alimentar os dados no teste, o primeiro parâmetro MemberData
para é o nome da função estática que retorna a coleção de casos de 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 dos testes DialogFlowUseCases
no Gerenciador de Testes do Visual Studio quando o teste é executado:
Usando elementos fictícios
Você pode usar elementos fictícios para as coisas que não estão sendo testadas no momento. Para referência, esse nível pode geralmente ser considerado como testes de unidade e de integração.
A simulação do maior número possível de elementos permite um melhor isolamento da parte que está sendo testada. Os candidatos a elementos fictícios incluem o armazenamento, o adaptador, o middleware, o pipeline de atividades, os 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 determinados aspectos, como middleware não envolvido na parte do bot que está sendo testada, para isolar cada parte. No entanto, se estiver testando o middleware, talvez seja melhor simular o bot.
Os elementos fictícios podem assumir várias formas, desde a substituição de um elemento por um objeto conhecido diferente até a implementação de uma funcionalidade mínima de olá, mundo. Isso também pode ser feito removendo o elemento, se não for necessário, ou forçando-o a não executar nenhuma ação.
As simulações nos permitem configurar as dependências de um diálogo e garantir que elas estejam em um estado conhecido durante a execução do teste, sem precisar depender de recursos externos, como bancos de dados, modelos de linguagem ou outros objetos.
Para tornar sua caixa de diálogo mais fácil de testar e reduzir suas dependências de 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 BookingDialog
como um parâmetro do construtor:
public MainDialog(BookingDialog bookingDialog)
: base(nameof(MainDialog))
{
...
AddDialog(bookingDialog);
...
}
Isso nos permite substituir a instância de BookingDialog
por um objeto fictício e escrever testes de unidade para MainDialog
sem precisar chamar a verdadeira classe 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);
Caixas de diálogo fictícias
Conforme descrito acima, MainDialog
invoca BookingDialog
para obter o objeto BookingDetails
. Implementamos e configuramos uma instância fictícia de BookingDialog
da seguinte maneira:
// 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 fictícia e os métodos Setup
e Returns
para configurar o comportamento dela.
Simulando resultados do LUIS
Observação
O reconhecimento de linguagem (LUIS) será desativado em 1.º de outubro de 2025. A partir de 1.º de abril de 2023, não será possível criar novos recursos do LUIS. Uma versão mais recente de reconhecimento de linguagem já está disponível como parte da Linguagem de IA do Azure.
A compreensão da linguagem coloquial (CLU), um recurso da Linguagem de IA do Azure, é a versão atualizada do LUIS. Para obter mais informações sobre o suporte ao reconhecimento de linguagem no SDK do Bot Framework, confira Reconhecimento de linguagem natural.
Em cenários simples é possível implementar resultados do LUIS fictícios por meio de 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 isso acontece, é mais simples capturar o resultado desejado em um arquivo JSON, adicioná-lo como um recurso ao seu projeto e desserializá-lo em um resultado do LUIS. Aqui está 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);
});