Come eseguire unit test sui bot

SI APPLICA A: SDK v4

In questo argomento si apprenderà come:

  • Creare unit test per i bot.
  • Usare assert per verificare la presenza di attività restituite da un turno di dialogo rispetto ai valori previsti.
  • Usare assert per controllare i risultati restituiti da una finestra di dialogo.
  • Creare diversi tipi di test basati sui dati.
  • Creare oggetti fittizi per le diverse dipendenze di un dialogo, ad esempio i riconoscitori della lingua e così via.

Prerequisiti

L'esempio CoreBot Tests usato in questo argomento fa riferimento al pacchetto Microsoft.Bot.Builder.Testing, a XUnit e a Moq per la creazione di unit test.

L'esempio di bot di base usa Language Understanding (LUIS) per identificare le finalità dell'utente; Tuttavia, l'identificazione della finalità dell'utente non è l'obiettivo di questo articolo. Per informazioni sull'identificazione delle finalità utente, vedere Comprensione del linguaggio naturale e Aggiungere la comprensione del linguaggio naturale al bot.

Nota

Language Understanding (LUIS) verrà ritirato il 1° ottobre 2025. A partire dal 1° aprile 2023, non sarà possibile creare nuove risorse LUIS. Una versione più recente di Language Understanding è ora disponibile come parte del linguaggio di intelligenza artificiale di Azure.

CLU (Conversational Language Understanding), una funzionalità del linguaggio di intelligenza artificiale di Azure, è la versione aggiornata di LUIS. Per altre informazioni sul supporto per la comprensione del linguaggio in Bot Framework SDK, vedere Comprensione del linguaggio naturale.

Test di finestre di dialogo

Nell'esempio CoreBot le finestre di dialogo vengono testate tramite la DialogTestClient classe , che fornisce un meccanismo per testarle in isolamento all'esterno di un bot e senza dover distribuire il codice in un servizio Web.

Tramite questa classe è possibile scrivere unit test che convalidano le risposte delle finestre di dialogo un ciclo alla volta. Gli unit test che usano la classe DialogTestClient dovrebbero funzionare con le altre finestre di dialogo compilate usando la libreria di finestre di dialogo botbuilder.

L'esempio seguente illustra i test derivati da 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 classe DialogTestClient è definita nello spazio dei nomi Microsoft.Bot.Builder.Testing e inclusa nel pacchetto NuGet Microsoft.Bot.Builder.Testing.

DialogTestClient

Il primo parametro di DialogTestClient è il canale di destinazione. In questo modo è possibile testare logica di rendering diversa in base al canale di destinazione per il bot (Teams, Slack e così via). Se non si è certi del canale di destinazione, è possibile usare gli Emulator ID canale o Test , ma tenere presente che alcuni componenti possono comportarsi in modo diverso a seconda del canale corrente, ad esempio, ConfirmPrompt esegue il rendering delle opzioni Sì/No in modo diverso per i Test canali e Emulator . È anche possibile usare questo parametro per testare la logica di rendering condizionale nella finestra di dialogo in base all'ID del canale.

Il secondo parametro è un'istanza del dialogo sottoposto a test. Nel codice di esempio di questo articolo rappresenta sut il sistema sottoposto a test.

Il DialogTestClient costruttore fornisce parametri aggiuntivi che consentono di personalizzare ulteriormente il comportamento del client o di passare parametri al dialogo sottoposto a test, se necessario. È possibile passare i dati di inizializzazione per la finestra di dialogo, aggiungere middleware personalizzato o usare il proprio TestAdapter e la propria istanza di ConversationState.

Invio e ricezione di messaggi

Il metodo SendActivityAsync<IActivity> consente di inviare un'espressione di testo o un elemento IActivity alla finestra di dialogo e restituisce il primo messaggio ricevuto. Il parametro <T> viene usato per restituire un'istanza fortemente tipizzata della risposta, in modo da poterne eseguire l'asserzione senza che sia necessario eseguirne il cast.

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

In alcuni scenari il bot può inviare diversi messaggi in risposta a una singola attività. In questi casi DialogTestClient accoda le risposte ed è possibile usare il metodo GetNextReply<IActivity> per estrarre il messaggio successivo dalla coda di risposta.

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

GetNextReply<IActivity> restituisce null se non sono presenti altri messaggi nella coda di risposta.

Asserzione di attività

Il codice nell'esempio CoreBot asserisce solo la proprietà Text delle attività restituite. In bot più complessi è possibile asserire altre proprietà, ad Speakesempio , InputHint, ChannelDatae così via.

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

È possibile eseguire questa operazione controllando singolarmente ogni proprietà, come illustrato in precedenza, oppure è possibile scrivere utilità helper personalizzate per l'asserzione di attività o ancora è possibile usare altri framework come FluentAssertions per scrivere asserzioni personalizzate e semplificare il codice di test.

Passaggio di parametri alle finestre di dialogo

Il costruttore DialogTestClient dispone di un elemento initialDialogOptions che può essere usato per passare parametri alla finestra di dialogo. Ad esempio, MainDialog in questo esempio inizializza un BookingDetails oggetto dai risultati del riconoscimento della lingua, con le entità risolte dall'espressione dell'utente e passa questo oggetto nella chiamata per richiamare BookingDialog.

Questa procedura può essere implementata in un test come indicato di seguito:

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 riceve questo parametro e vi accede nel test come avrebbe fatto se fosse stato richiamato da MainDialog.

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

Asserzione dei risultati del ciclo della finestra di dialogo

Alcune finestre di dialogo come BookingDialog o DateResolverDialog restituiscono un valore alla finestra di dialogo chiamante. L'oggetto DialogTestClient visualizza una proprietà DialogTurnResult che può essere usata per analizzare e asserire i risultati restituiti dalla finestra di dialogo.

Ad esempio:

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 proprietà DialogTurnResult può essere usata anche per ispezionare e asserire i risultati intermedi restituiti dai passaggi in un'elaborazione a cascata.

Analisi dell'output del test

A volte è necessario leggere una trascrizione di unit test per analizzare l'esecuzione del test senza dover eseguire il debug del test.

Il pacchetto Microsoft.Bot.Builder.Testing include un elemento XUnitDialogTestLogger che registra i messaggi inviati e ricevuti tra la finestra di dialogo e la console.

Per usare questo middleware il test deve esporre un costruttore che riceve un oggetto ITestOutputHelper specificato dal XUnit Test Runner e creare un oggetto XUnitDialogTestLogger che verrà passato a DialogTestClient tramite il parametro 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);

        ...
    }
}

Di seguito è riportato un esempio dei log nella finestra di XUnitDialogTestLogger output quando è configurata:

Example middleware output from XUnit.

Per altre informazioni sull'invio dell'output del test alla console quando si usa XUnit, vedere Capturing Output (Acquisizione dell'output) nella documentazione di XUnit.

Questo output viene anche registrato nel server di compilazione durante le compilazioni di integrazione continua e consente di analizzare gli errori di compilazione.

Test basati sui dati

Nella maggior parte dei casi la logica della finestra di dialogo non cambia e i diversi percorsi di esecuzione in una conversazione sono basati sulle espressioni dell'utente. Invece di scrivere un singolo unit test per ogni variante nella conversazione, è più facile usare test basati sui dati (noto anche come test con parametri).

Ad esempio, il test di esempio nella sezione panoramica di questo documento illustra come testare un flusso di esecuzione, ma non altri, ad esempio:

  • Cosa accade se l'utente dice no alla conferma?
  • Cosa succede se usano una data diversa?

I test basati sui dati consentono di eseguire test di tutte le permutazioni senza dover riscrivere i test stessi.

Nell'esempio CoreBot si usano test Theory di XUnit per parametrizzare i test.

Test di teoria con InlineData

Il test seguente verifica che una finestra di dialogo venga annullata quando l'utente indica "annulla".

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

Per annullare una finestra di dialogo, gli utenti possono digitare "quit" (esci), "never mind" (ignora) e "stop it" (arresta). Anziché scrivere un nuovo test case per ogni parola possibile, scrivere un singolo Theory metodo di test che accetta parametri tramite un elenco di InlineData valori per definire i parametri per ogni test case:

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

Il nuovo test verrà eseguito quattro volte con i diversi parametri e ogni caso verrà visualizzato come elemento figlio nel ShouldBeAbleToCancel test in Esplora test di Visual Studio. Se uno di essi ha esito negativo, come illustrato di seguito, è possibile fare clic con il pulsante destro del mouse ed eseguire il debug dello scenario non riuscito anziché riesecure l'intero set di test.

Example test results for in-line data.

Test di teoria con MemberData e tipi complessi

InlineData è utile per i test basati sui dati di piccole dimensioni che ricevono parametri di tipo valore semplice (string, int e così via).

BookingDialog riceve un oggetto BookingDetails e restituisce un nuovo oggetto BookingDetails. Una versione senza parametri di un test per questa finestra di dialogo avrà un aspetto simile al seguente:

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

Per parametrizzare questo test è stata creata una classe BookingDialogTestCase che contiene i dati del test case. Contiene l'oggetto BookingDetails iniziale, l'elemento BookingDetails previsto e una matrice di stringhe contenenti le espressioni inviate dall'utente e le risposte previste dalla finestra di dialogo per ogni ciclo.

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

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

    public BookingDetails ExpectedBookingDetails { get; set; }
}

È stata creata anche una classe helper BookingDialogTestsDataGenerator la quale espone un metodo IEnumerable<object[]> BookingFlows() che restituisce una raccolta dei test case che vengono usati dal test.

Per visualizzare ogni test case come elemento separato in Esplora test di Visual Studio, XUnit Test Runner richiede che i tipi complessi come BookingDialogTestCase implementino IXunitSerializable. Per semplificare questa operazione, il framework Bot.Builder.Testing include una classe TestDataObject che implementa questa interfaccia e può essere usata per eseguire il wrapping dei dati del test case senza dover implementare IXunitSerializable.

Ecco un frammento di IEnumerable<object[]> BookingFlows() che mostra come vengono usate le due classi:

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

Dopo aver creato un oggetto per archiviare i dati di test e una classe che espone una raccolta di test case, viene usato l'attributo XUnit MemberData anziché InlineData per inserire i dati nel test. Il primo parametro per MemberData è il nome della funzione statica che restituisce la raccolta di test case e il secondo parametro è il tipo della classe che espone questo metodo.

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

Ecco un esempio dei risultati per i DialogFlowUseCases test in Esplora test di Visual Studio quando viene eseguito il test:

Example results for the booking dialog.

Uso di elementi fittizi

È possibile usare elementi fittizi per gli elementi attualmente non testati. Per riferimento, questo livello può in genere essere considerato come test di unità e di integrazione.

Simulazione di tutti gli elementi che è possibile consentire un migliore isolamento del pezzo che si sta testando. I candidati per gli elementi fittizi includono l'archiviazione, l'adattatore, il middleware, la pipeline di attività, i canali e qualsiasi altro elemento che non fa parte direttamente del bot. Questo potrebbe anche comportare la rimozione temporanea di determinati aspetti, ad esempio il middleware non coinvolto nella parte del bot che si sta testando, per isolare ogni parte. Tuttavia, se si sta testando il middleware, è possibile simulare il bot.

Gli elementi fittizi possono assumere una manciata di forme, dalla sostituzione di un elemento con un oggetto noto diverso all'implementazione di funzionalità hello world minime. Questo potrebbe anche assumere la forma di rimozione dell'elemento, se non è necessario, o forzarlo a non eseguire alcuna operazione.

Le simulazioni consentono di configurare le dipendenze di una finestra di dialogo e assicurarsi che si trovano in uno stato noto durante l'esecuzione del test senza dover fare affidamento su risorse esterne come database, modelli linguistici o altri oggetti.

Per semplificare il test della finestra di dialogo e ridurne le dipendenze dagli oggetti esterni, può essere necessario inserire le dipendenze esterne nel costruttore della finestra di dialogo.

Ad esempio, anziché creare un'istanza di BookingDialog in MainDialog:

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

Si passa un'istanza di BookingDialog come parametro del costruttore:

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

In questo modo è possibile sostituire l'istanza BookingDialog con un oggetto fittizio e scrivere unit test per MainDialog senza dover chiamare la classe BookingDialog reale.

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

Finestre di dialogo di simulazione

Come descritto in precedenza MainDialog, richiama BookingDialog per ottenere l'oggetto BookingDetails. Viene implementata e configurata un' istanza fittizia di BookingDialog come indicato di seguito:

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

In questo esempio è stato usato Moq per creare la finestra di dialogo fittizia e i metodi Returns e Setup per configurarne il comportamento.

Simulazione dei risultati LUIS

Nota

Language Understanding (LUIS) verrà ritirato il 1° ottobre 2025. A partire dal 1° aprile 2023, non sarà possibile creare nuove risorse LUIS. Una versione più recente di Language Understanding è ora disponibile come parte del linguaggio di intelligenza artificiale di Azure.

CLU (Conversational Language Understanding), una funzionalità del linguaggio di intelligenza artificiale di Azure, è la versione aggiornata di LUIS. Per altre informazioni sul supporto per la comprensione del linguaggio in Bot Framework SDK, vedere Comprensione del linguaggio naturale.

Negli scenari semplici è possibile implementare i risultati LUIS fittizi tramite il codice nel modo seguente:

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

I risultati di LUIS possono essere complessi. Quando sono, è più semplice acquisire il risultato desiderato in un file JSON, aggiungerlo come risorsa al progetto e deserializzarlo in un risultato LUIS. Ecco un esempio:

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

Informazioni aggiuntive