Eenheidstest uitvoeren voor uw agents in .NET

Met unit tests kunt u het gedrag van uw agent stap voor stap verifiëren zonder deze naar een kanaal te implementeren, verbinding te maken met Azure AI Bot Service of daadwerkelijk HTTP-verkeer te verzenden. Deze handleiding loopt van eenvoudige echotests tot en met multurn-stromen, het mocken van afhankelijkheden en door AI ondersteunde semantische validatie.

Prerequisites

Voeg het NuGet-pakket Microsoft.Agents.Builder.Testing toe aan uw testproject. Het bevat AgentTestHost, TestAdapteren TestFlow - de drie onderdelen die u in elke test gebruikt.

<PackageReference Include="Microsoft.Agents.Builder.Testing" Version="*" />

Tests in deze handleiding maken gebruik van xUnit en Moq, maar de testbibliotheek is niet afhankelijk van een specifiek testframework.

Basisconcepten

TestAdapter

TestAdapter is de tijdelijke vervanger voor een echte kanaaladapter. In plaats van activiteiten via een netwerk te verzenden, worden agentreacties in een ActiveQueue in de wachtrij geplaatst waaruit uw asserties lezen. Het bevat ook een MockUserTokenClient voor het simuleren van OAuth-stromen.

TestFlow

TestFlow is een fluent API voor het modelleren van een gesprek als een reeks verzend-/assertbewerkingen. De keten wordt traag geëvalueerd. Niets wordt uitgevoerd totdat u StartTestAsync() aanroept.

await new TestFlow(adapter, myBotCallback)
    .Send("hello")
    .AssertReply("Hello back!")
    .StartTestAsync();

AgentTestHost

AgentTestHost omsluit een IHost die op dezelfde manier is geconfigureerd als uw Program.cs voor productie. Hiermee registreer je TestAdapter vooraf als IChannelAdapter en stel je CreateTestFlow() beschikbaar om testgesprekken te starten.

await using var host = AgentTestHost.Create(builder =>
{
    builder.Services.AddSingleton<IStorage, MemoryStorage>();
    builder.Services.AddTransient<IAgent, MyAgent>();
});

await host.CreateTestFlow()
    .Send("hello")
    .AssertReply("Hello back!")
    .StartTestAsync();

Belangrijk: Roep AddAgent<T>() niet aan in AgentTestHost.Create(). Deze methode registreert CloudAdapter, wat strijdig is met de vooraf geregistreerde TestAdapter. Registreer IAgent rechtstreeks met AddTransient<IAgent, T>() of een factory-delegate.

Uw eerste test

Gegeven een agent die gebruikersberichten herhaalt:

public class EchoAgent : AgentApplication
{
    public EchoAgent(AgentApplicationOptions options) : base(options)
    {
        OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);
    }

    private async Task OnMessageAsync(ITurnContext tc, ITurnState ts, CancellationToken ct)
    {
        await tc.SendActivityAsync($"You said: {tc.Activity.Text}", cancellationToken: ct);
    }
}

De test:

[Fact]
public async Task Echo_ReturnsUserText()
{
    await using var host = AgentTestHost.Create(builder =>
    {
        builder.Services.AddSingleton<IStorage, MemoryStorage>();
        builder.Services.AddTransient<IAgent>(sp =>
            new EchoAgent(new AgentApplicationOptions(sp.GetRequiredService<IStorage>())));
    });

    await host.CreateTestFlow()
        .Send("hello")
        .AssertReply("You said: hello")
        .StartTestAsync();
}

Wat elke stap doet

Stap Description
AgentTestHost.Create(...) Bouwt en start een in-process host met TestAdapter vooraf geconfigureerd
CreateTestFlow() Haalt IAgent op uit DI en verpakt het in een TestFlow
.Send("hello") Hiermee maakt u een Message activiteit en routeert u deze via de agent
.AssertReply("You said: hello") Het volgende antwoord wordt uit de wachtrij gehaald en de tekst ervan wordt bevestigd (ingekort, exacte overeenkomst)
.StartTestAsync() Voert de hele keten uit

Assertievarianten

TestFlow biedt verschillende assertiemethoden, afhankelijk van hoe nauwkeurig uw controle moet zijn.

Exacte overeenkomst met tekst

.AssertReply("You said: hello")

Overeenkomsten in subtekenreeksen

.AssertReplyContains("hello")

Een van de acceptabele antwoorden

.AssertReplyOneOf(new[] { "Hi!", "Hello!", "Hey!" })

Aangepaste validatie met een gemachtigde

.AssertReply(activity =>
{
    Assert.Equal(ActivityTypes.Message, activity.Type);
    Assert.Contains("hello", activity.Text);
})

Asynchrone validatiedelegatie

.AssertReplySatisfies(async activity =>
{
    var data = await LoadExpectedDataAsync();
    Assert.Equal(data.Expected, activity.Text);
})

Controleer of er geen verdere antwoorden zijn

Nadat u alle verwachte antwoorden hebt gecontroleerd, moet u bedacht zijn op onverwachte extra antwoorden:

.AssertReply("done")
.AssertNoMoreReplies()

AssertNoMoreReplies() wacht 1 seconde en mislukt als er een activiteit binnenkomt. Gebruik de beschrijvingsparameter om de assertie een naam te geven in de foutuitvoer:

.AssertNoMoreReplies("only one reply expected per turn")

Verzending en assertie combineren met Test()

Voor paren met één beurt combineert .Test().Send() en .AssertReply():

await host.CreateTestFlow()
    .Test("ping", "pong")
    .Test("foo",  "You said: foo")
    .StartTestAsync();

Gesprekken met meerdere beurten

Koppel meerdere send/assert-paren om volledige gespreksstromen te testen:

await host.CreateTestFlow()
    .Send("start")
    .AssertReply("What is your name?")
    .Send("Alice")
    .AssertReply("Hello, Alice!")
    .Send("what can you do?")
    .AssertReply(activity => Assert.NotEmpty(activity.Text))
    .StartTestAsync();

Meerdere antwoorden per beurt testen

Een agent kan verschillende activiteiten verzenden als reactie op één gebruikersbericht. Elke .AssertReply() aanroep verwijdert één activiteit uit de wachtrij:

await host.CreateTestFlow()
    .Send("tell me three things")
    .AssertReply("First thing.")
    .AssertReply("Second thing.")
    .AssertReply("Third thing.")
    .AssertNoMoreReplies()
    .StartTestAsync();

Gespreksupdates testen

Kanalen gebruiken ConversationUpdate activiteiten om agenten op de hoogte te stellen dat leden deelnemen of verlaten. Gebruik SendConversationUpdate() dit om de joingebeurtenis te simuleren waarmee een welkomstbericht wordt geactiveerd.

Testen met de standaardgebruiker

Als u geen argumenten doorgeeft, voegt de adapter de standaardgebruiker (user1) toe aan MembersAdded:

await host.CreateTestFlow()
    .SendConversationUpdate()
    .AssertReply("Hello and Welcome!")
    .StartTestAsync();

Testen met specifieke leden

await host.CreateTestFlow()
    .SendConversationUpdate(new[]
    {
        new ChannelAccount { Id = "alice", Name = "Alice" },
        new ChannelAccount { Id = "bob",   Name = "Bob"   }
    })
    .AssertReply("Hello and Welcome!")
    .StartTestAsync();

Volledige welkomst- en berichtstroom

await host.CreateTestFlow()
    .SendConversationUpdate()
    .AssertReply("Hello and Welcome!")
    .Send("hello")
    .AssertReply("You said: hello")
    .AssertNoMoreReplies()
    .StartTestAsync();

De volledige activiteit controleren

Wanneer u eigenschappen naast Text moet controleren, zoals Speak, InputHint, SuggestedActions of bijlagen, gebruikt u een gedelegeerde assertie:

.AssertReply(activity =>
{
    Assert.Equal("Sure, one moment…", activity.Text);
    Assert.Equal("Sure, one moment…", activity.Speak);
    Assert.Equal(InputHints.IgnoringInput, activity.InputHint);
})

Voor bijlagen van adaptieve kaarten voert TestAdapter de serialisatie in beide richtingen uit, zodat Attachment.Content aankomt als een JsonElement. Gebruik .ToString() om de tekenreekswaarde ervan te extraheren:

.AssertReply(activity =>
{
    var card = activity.Attachments
        .First(a => a.ContentType == ContentTypes.AdaptiveCard);

    // Correct: Content is JsonElement after TestAdapter serialization
    string json = card.Content.ToString()!;
    Assert.Contains("Seattle", json);
})

Typeaanduidingen

Als uw agent vóór het daadwerkelijke antwoord een Typing-activiteit verzendt, controleer dit dan expliciet:

.Send("hello")
.AssertTypingIndicator()
.AssertReply("Here is your answer…")

Activiteitstype bevestigen

.AssertReply(activity =>
{
    Assert.Equal(ActivityTypes.Message, activity.Type);
})

Gegevensgestuurde tests

In plaats van voor elke invoervariant een afzonderlijke test te schrijven, gebruikt u xUnit-tests met Theory om te parametriseren met [InlineData]:

[Theory]
[InlineData("hi",    "You said: hi")]
[InlineData("hello", "You said: hello")]
[InlineData("bye",   "You said: bye")]
public async Task Echo_VariousInputs(string input, string expected)
{
    await using var host = AgentTestHost.Create(builder =>
    {
        builder.Services.AddSingleton<IStorage, MemoryStorage>();
        builder.Services.AddTransient<IAgent>(sp =>
            new EchoAgent(new AgentApplicationOptions(sp.GetRequiredService<IStorage>())));
    });

    await host.CreateTestFlow()
        .Test(input, expected)
        .StartTestAsync();
}

Voor complexe testcases, inclusief meerdere bochten en verschillende gespreksstatussen, gebruikt u [MemberData]:

public static IEnumerable<object[]> ConversationFlows()
{
    yield return new object[]
    {
        new string[] { "start", "Alice", "done" },
        new string[] { "What is your name?", "Hello, Alice!", "Goodbye, Alice!" }
    };
    yield return new object[]
    {
        new string[] { "start", "Bob", "done" },
        new string[] { "What is your name?", "Hello, Bob!", "Goodbye, Bob!" }
    };
}

[Theory]
[MemberData(nameof(ConversationFlows))]
public async Task MultiTurn_Flow(string[] inputs, string[] expected)
{
    await using var host = AgentTestHost.Create(builder =>
    {
        builder.Services.AddSingleton<IStorage, MemoryStorage>();
        builder.Services.AddTransient<IAgent, GreetingAgent>();
    });

    var flow = host.CreateTestFlow();
    for (int i = 0; i < inputs.Length; i++)
    {
        flow = flow.Test(inputs[i], expected[i]);
    }
    await flow.StartTestAsync();
}

Simulaties gebruiken

Eenheidstests moeten het onderdeel onder test isoleren. Gebruik Moq om externe afhankelijkheden, zoals AI-services, databases en recognizers, te vervangen, zodat tests deterministisch en snel blijven.

Gesimuleerde afhankelijkheden injecteren

Ontwerp uw agent om afhankelijkheden te ontvangen via de constructor in plaats van deze intern te maken:

// Harder to test — agent owns the recognizer
public class WeatherAgent : AgentApplication
{
    private readonly WeatherService _weather = new WeatherService();
}

// Easier to test — dependency injected
public class WeatherAgent : AgentApplication
{
    private readonly IWeatherService _weather;

    public WeatherAgent(AgentApplicationOptions options, IWeatherService weather)
        : base(options)
    {
        _weather = weather;
    }
}

Een service vervangen in tests

[Fact]
public async Task Weather_ReturnsFormattedForecast()
{
    var mockWeather = new Mock<IWeatherService>();
    mockWeather
        .Setup(s => s.GetForecastAsync("Seattle", It.IsAny<CancellationToken>()))
        .ReturnsAsync(new Forecast { TemperatureC = 18, Summary = "Partly cloudy" });

    await using var host = AgentTestHost.Create(builder =>
    {
        builder.Services.AddSingleton<IStorage, MemoryStorage>();
        builder.Services.AddSingleton<IWeatherService>(mockWeather.Object);
        builder.Services.AddTransient<IAgent>(sp =>
            new WeatherAgent(
                new AgentApplicationOptions(sp.GetRequiredService<IStorage>()),
                sp.GetRequiredService<IWeatherService>()));
    });

    await host.CreateTestFlow()
        .Send("weather in Seattle")
        .AssertReplyContains("18")
        .StartTestAsync();
}

Service-aanroepen controleren

mockWeather.Verify(
    s => s.GetForecastAsync("Seattle", It.IsAny<CancellationToken>()),
    Times.Once);

Semantische validatie met AI

Wanneer uw agent een taalmodel aanroept en de uitvoer niet-deterministisch is, zijn exacte tekstverklaringen broos. SemanticValidator gebruikt een AI-rechter om te evalueren of een antwoord voldoet aan een ja/nee-vraag.

SemanticValidator

var validator = new SemanticValidator(chatClient, "Does this response describe the weather?");

await host.CreateTestFlow()
    .Send("What is the weather in Seattle?")
    .AssertReplySatisfies(validator, timeout: 60_000)
    .StartTestAsync();

De validator verzendt de antwoordtekst van de agent naar de AI-rechter met de assertievraag. Als de rechter reageert met 'ja', wordt de verklaring doorgegeven. Als de test reageert met 'nee', mislukt de test met een gedetailleerd bericht met de prompt en het daadwerkelijke antwoord.

Een IChatClient maken

using Azure.AI.OpenAI;
using Azure.Core;
using Microsoft.Extensions.AI;

IChatClient chatClient = new AzureOpenAIClient(
        new Uri(endpoint),
        new ApiKeyCredential(apiKey))   // ApiKeyCredential is from System.ClientModel
    .GetChatClient(deploymentName)
    .AsIChatClient();

Aangepaste IResponseValidator

Implementeer IResponseValidator wanneer SemanticValidator dit niet past, bijvoorbeeld wanneer het antwoord een adaptieve kaart is in plaats van tekst zonder opmaak:

public class WeatherCardValidator : IResponseValidator
{
    private readonly IChatClient _judge;
    private readonly string _location;

    public WeatherCardValidator(IChatClient judge, string location)
    {
        _judge = judge;
        _location = location;
    }

    public async Task ValidateAsync(IActivity reply, CancellationToken ct = default)
    {
        string content;

        if (!string.IsNullOrEmpty(reply?.Text))
        {
            content = reply.Text;
        }
        else
        {
            var card = reply?.Attachments?
                .FirstOrDefault(a => a.ContentType == ContentTypes.AdaptiveCard);

            if (card?.Content == null)
                throw new InvalidOperationException("Reply has neither text nor Adaptive Card.");

            // Content is JsonElement after TestAdapter round-trip — use ToString()
            content = card.Content.ToString()!;
        }

        var messages = new List<ChatMessage>
        {
            new(ChatRole.System, "Answer only 'yes' or 'no'."),
            new(ChatRole.User, $"Does this contain a weather forecast for {_location}?\n\n{content}")
        };

        var response = await _judge.GetResponseAsync(messages, cancellationToken: ct);
        var answer = response?.Text?.Trim().ToLowerInvariant() ?? string.Empty;

        if (answer.StartsWith("yes"))
            return;

        if (answer.StartsWith("no"))
            throw new InvalidOperationException(
                $"Validation failed for '{_location}'. Agent replied:\n{content}");

        throw new InvalidOperationException($"Unexpected judge response: '{answer}'");
    }
}

Gebruik:

[Fact(Skip = "Requires Azure OpenAI. Set AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_DEPLOYMENT_NAME.")]
public async Task WeatherAgent_ReturnsSeattleForecast()
{
    string endpoint   = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!;
    string apiKey     = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY")!;
    string deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")!;

    IChatClient judge = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey))
        .GetChatClient(deployment)
        .AsIChatClient();

    var validator = new WeatherCardValidator(judge, "Seattle");

    await using var host = AgentTestHost.Create(builder =>
    {
        builder.Services.AddSingleton<IStorage, MemoryStorage>();
        // ... register Kernel and agent
    });

    await host.CreateTestFlow()
        .SendConversationUpdate()
        .AssertReplyContains("Welcome")
        .Send("What is the weather forecast for Seattle today?")
        .AssertReplySatisfies(validator, timeout: 60_000)
        .AssertNoMoreReplies()
        .StartTestAsync();
}

Note

Tests die externe AI-services aanroepen, zijn traag en hebben omgevingsafhankelijkheden. Markeer ze met [Fact(Skip = "...")] en een duidelijk bericht over vereiste omgevingsvariabelen. Voer ze uit in een toegewezen CI-fase in plaats van met uw suite voor eenheidstests.

Middleware testen

Koppel middleware aan host.Adapter voordat u teststromen maakt. Alle werkstromen die zijn gemaakt vanuit de activiteiten van dat hostproces via de middleware.

Transcriptregistratie

[Fact]
public async Task Transcript_CapturesAllActivities()
{
    await using var host = AgentTestHost.Create(builder =>
    {
        builder.Services.AddSingleton<IStorage, MemoryStorage>();
        builder.Services.AddTransient<IAgent, EchoAgent>();
    });

    var transcript = new MemoryTranscriptStore();
    host.Adapter.Use(new TranscriptLoggerMiddleware(transcript));

    await host.CreateTestFlow()
        .SendConversationUpdate()
        .AssertReply("Hello and Welcome!")
        .Send("hello")
        .AssertReply("You said: hello")
        .AssertNoMoreReplies()
        .StartTestAsync();

    var activities = await GetTranscriptAsync(
        host.Adapter.Conversation.ChannelId,
        host.Adapter.Conversation.Conversation.Id,
        transcript);

    // ConversationUpdate in, welcome out, message in, echo out = 4
    Assert.Equal(4, activities.Count);
}

private static async Task<IList<IActivity>> GetTranscriptAsync(
    string channelId, string conversationId, ITranscriptStore store)
{
    var result = new List<IActivity>();
    string token = null;
    do
    {
        var page = await store.GetTranscriptActivitiesAsync(channelId, conversationId, token);
        token = page.ContinuationToken;
        result.AddRange(page.Items);
    } while (token != null);
    return result;
}

TestAdapter rechtstreeks gebruiken

Gebruik AgentTestHost voor het aanbevolen installatiepad. U kunt TestAdapter en TestFlow echter rechtstreeks maken wanneer u controle op een lager niveau wilt of wanneer u een zelfstandige callback test.

[Fact]
public async Task DirectAdapter_Echo()
{
    var adapter = TestAdapter.Create(channelId: Channels.Msteams);

    async Task BotCallback(ITurnContext tc, CancellationToken ct)
    {
        if (tc.Activity.Type == ActivityTypes.Message)
            await tc.SendActivityAsync($"echo:{tc.Activity.Text}", cancellationToken: ct);
    }

    await new TestFlow(adapter, BotCallback)
        .Test("foo", "echo:foo")
        .StartTestAsync();
}

De gespreksreferentie aanpassen

var adapter = TestAdapter.Create(
    channelId:        "msteams",
    userId:           "u42",
    userName:         "Alice",
    botId:            "mybot",
    botName:          "MyBot",
    conversationId:   "conv-42",
    conversationName: "Test Room");

Stel de gespreksreferentie rechtstreeks in op de adapter van de host voordat u stromen maakt:

host.Adapter.Conversation = new ConversationReference
{
    ChannelId    = Channels.Msteams,
    User         = new ChannelAccount("alice", "Alice"),
    Agent        = new ChannelAccount("bot",   "Bot"),
    Conversation = new ConversationAccount(false, "conv-1", "Conversation 1"),
    ServiceUrl   = "https://test.example.com"
};

OAuth- en tokenstromen testen

TestAdapter maakt methoden beschikbaar voor het vooraf vullen van gebruikerstokens. Met deze methoden kunt u agents testen die GetUserTokenAsync of stromen voor eenmalige aanmelding gebruiken zonder verbinding te maken met een echte id-provider.

Een token vooraf vullen

host.Adapter.AddUserToken(
    connectionName: "MyOAuthConnection",
    channelId:      Channels.Test,
    userId:         "user1",
    token:          "fake-access-token");

Een uitwisselbaar token vooraf invullen voor eenmalige aanmelding

host.Adapter.AddExchangeableToken(
    connectionName:  "MyOAuthConnection",
    channelId:       Channels.Test,
    userId:          "user1",
    exchangableItem: "sso-token",
    token:           "fake-access-token");

Een exchange-fout simuleren

host.Adapter.ThrowOnExchangeRequest(
    connectionName:  "MyOAuthConnection",
    channelId:       Channels.Test,
    userId:          "user1",
    exchangableItem: "bad-sso-token");

Vertragingen invoegen

Gebruik .Delay() deze functie wanneer uw agent tijdafhankelijk gedrag heeft.

await host.CreateTestFlow()
    .Send("start")
    .Delay(TimeSpan.FromSeconds(2))
    .Send("are you still there?")
    .AssertReply("Yes, I'm here.")
    .StartTestAsync();

Standaardinstellingen voor time-outs en foutopsporing

Alle assertiemethoden hebben een timeout parameter in milliseconden, met een standaardwaarde van 3000. Wanneer u het Visual Studio foutopsporingsprogramma koppelt, worden time-outs automatisch ingesteld op uint.MaxValue, zodat onderbrekingspunten geen valse fouten veroorzaken.

Voor langdurige agentbewerkingen, zoals aanroepen van een taalmodel, geeft u expliciet een time-out door.

.AssertReplySatisfies(validator, timeout: 60_000)

AssertNoMoreReplies() maakt gebruik van een kortere standaardwaarde van 1000 ms omdat de agent de verwerking al heeft voltooid.

Reference

AgentTestHost

Lid Description
AgentTestHost.Create(configure) Maakt de host en start deze. Registreert TestAdapter vooraf als IChannelAdapter.
host.Adapter Het TestAdapter-exemplaar. Voeg hier middleware toe.
host.CreateTestFlow() Haalt een IAgent op uit DI en retourneert een TestFlow.
host.DisposeAsync() Stopt de host. Gebruik await using om dit weg te gooien.

TestAdapter

Lid Description
TestAdapter.Create(...) Fabriek voor een volledig geconfigureerde adapter.
adapter.Conversation De ConversationReference gebruikt voor alle activiteiten.
adapter.ActiveQueue Wachtrij van activiteiten die door de agent zijn verzonden.
adapter.Use(middleware) Voegt middleware toe. Retourneert this voor ketenvorming.
adapter.AddUserToken(...) Een gebruikerstoken vooraf ingevuld voor OAuth-tests.
adapter.AddExchangeableToken(...) Vult een SSO-uitwisselbaar token vooraf in.
adapter.ThrowOnExchangeRequest(...) Configureert een inwisselingsaanvraag.

TestFlow-methoden

Methode Description
.Send(text) Verzendt een berichtactiviteit.
.Send(activity) Verzendt elk activiteitstype.
.SendConversationUpdate() Hiermee verzendt u een ConversationUpdate met de standaardgebruiker.
.SendConversationUpdate(members) Verzendt een ConversationUpdate met specifieke leden.
.Test(input, expected) .Send() + .AssertReply() (korte aanduiding).
.AssertReply(string) Exacte tekstovereenkomst (ingekort).
.AssertReplyContains(string) Overeenkomsten in subtekenreeksen.
.AssertReplyOneOf(string[]) Komt overeen met een van meerdere tekenreeksen.
.AssertReply(Activity) Overeenstemmen op basis van activiteitstype en tekst.
.AssertReply(Action<IActivity>) Validatie van aangepaste synchronisatie; genereren voor een fout.
.AssertReplySatisfies(Func<IActivity, Task>) Aangepaste asynchrone validatie.
.AssertReplySatisfies(IResponseValidator) Delegeren aan een validatieobject.
.AssertTypingIndicator() Er wordt een typactiviteit verwacht.
.AssertNoMoreReplies() Mislukt als binnen 1 seconde een activiteit plaatsvindt.
.Delay(ms) Hiermee voegt u een pauze in de stroom in.
.StartTestAsync() Voert de keten uit.

SemanticValidator

Lid Description
new SemanticValidator(chatClient, prompt) Hiermee maakt u een validator. prompt is een ja/nee-vraag over het antwoord.
ValidateAsync(activity, ct) Slaagt als AI reageert met 'ja'; mislukt bij 'nee' of onverwacht.