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 testes CoreBot usado neste tópico faz referência ao pacote Microsoft.Bot.Builder.Testing, XUnit e Moq para criar testes de unidade.
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
, , 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, 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:
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.
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:
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);
});