Run end-to-end testing flows

Follow a complete test from host configuration to request, observable result, diagnostics, and cleanup. This guide brings together executable flows for CRUD, outbound HTTP, published events, real authentication handlers, snapshots, Azure Functions, and distributed applications.

Choose the boundary you need to exercise before choosing a host. Here, an application flow runs through the application's entry point and real application code with explicitly controlled dependencies. A distributed flow starts real application processes. Neither path drives a browser or validates a deployed environment unless you add that coverage separately.

Choose a flow

Flow Entry point and result Controlled boundary Packages beyond core
CRUD HTTP requests change and retrieve persisted state SQLite database per scenario; simulated identity EntityFrameworkCore
External API HTTP request calls a typed client and persists its response Outbound HTTP handler and scenario database EntityFrameworkCore, Http
Published event HTTP request produces a routed message Publisher records instead of sending to a broker Messaging
Credential validation Signed JWT reaches the application's bearer handler and policy Local issuer and keys None
HTTP contract Real request and response match a reviewed baseline Capture options, redaction, and baseline files Snapshots.Core
Functions Constructed Kafka trigger invokes a function and its application services Pricing HTTP and invocation recorder AzureFunctions, Http
Distributed application AppHost starts a service and a health request reaches its endpoint AppHost test configuration Aspire

These are independent flows, not a sequence of dependent tests. Combine them only when one business operation owns the behavior being asserted. For a single request that combines authorization, external data, persistence, and event publication, follow the complete order workflow.

Set up and run the examples

From a repository checkout, install the SDK selected by global.json and the ASP.NET Core runtimes for .NET 8, .NET 9, and .NET 10. The in-process flows need no external account or broker:

dotnet restore XBullet.EasyTesting.sln
dotnet build XBullet.EasyTesting.sln --configuration Release --no-restore
dotnet test tests/TestApi.IntegrationTests/TestApi.IntegrationTests.csproj --configuration Release --no-build --no-restore
dotnet test tests/TestFunctions.IntegrationTests/TestFunctions.IntegrationTests.csproj --configuration Release --no-build --no-restore

Without --framework, each test project runs all three supported target frameworks. The ASP.NET Core project also contains explicit Aspire tests; those require the separate command below.

In a consuming application, reference your application from its test project and install the packages selected by the table above. For example, an outbound HTTP and persistence flow uses:

dotnet add reference ../MyApi/MyApi.csproj
dotnet add package xunit.v3
dotnet add package XBullet.EasyTesting
dotnet add package XBullet.EasyTesting.EntityFrameworkCore
dotnet add package XBullet.EasyTesting.Http

Expose public partial class Program; in a minimal-host application. Use a Startup host for an existing Startup application. Follow host selection for the exact entry point and factory hooks; production application projects do not need EasyTesting references.

The examples below reuse repository fixtures and domain models. TestApiFactory, EndToEndAuthenticationFactory, OrderFunctionFactory, BuiltInSnapshotAudit, and the application DTOs are sample-project types, not framework APIs. Code blocks are generated from the linked source regions. Method excerpts omit their containing classes and using directives; complete files are linked alongside them.

Follow the framework lifecycle

Every in-process application flow has the same ownership sequence:

  1. Configure the factory. Select the entry point, authentication mode, database provider, and service replacements before the host resolves those dependencies.
  2. Open a scenario. Acquire the factory's scenario gate and create the scenario's host and service provider. Registered resources participate in setup and cleanup.
  3. Arrange inside that scenario. Seed its database and configure the exact stub responses the application will consume. Use the same scope for database setup, requests, and queries.
  4. Send the request or invoke the function. Pass the test cancellation token through I/O. For several requests in one journey, keep one scenario scope and use a client or a fresh request builder for each request; a fluent scenario builder represents one request.
  5. Verify observable behavior. Assert the response, stored state, and relevant outbound calls or messages. Rejected requests should also prove that unwanted side effects did not happen.
  6. Capture diagnostics and clean up. A scenario runner captures registered resource diagnostics before cleanup on failure or cancellation. Dispose clients, responses, and results with using. The fixture owns its factory; each scenario owns its scoped host and scoped services.

The runner protects shared mutable recorders within one factory. It does not make unrelated factories share a lock or isolate a shared external database automatically. See scenario isolation and resource ownership for those contracts.

Create, read, update, and delete a product

Flow: scenario database -> authenticated client -> create -> read -> update -> delete -> database verification.

The repository's TestApiFactory uses a separate in-memory SQLite connection for each scenario and registers that connection for scenario disposal. Its HTTP stub and message recorder are registered scenario resources. The xUnit fixture owns the factory; this example explicitly owns its scope, client, and responses.

public sealed class AuthenticatedCrudDocumentationExample : IClassFixture<TestApiFactory>
{
    private readonly TestApiFactory _factory;

    public AuthenticatedCrudDocumentationExample(TestApiFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task Authenticated_client_can_create_read_update_and_delete_a_product()
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        await using var scope = await _factory.CreateTestScenarioScopeAsync(
            cancellationToken: cancellationToken);
        using var client = scope.Client()
            .AsUser(user => user.WithName("CRUD tester"))
            .Build();

        using var createResponse = await client.PostAsJsonAsync(
            "/api/products",
            new ProductRequest("Webcam", 79.95m),
            cancellationToken);
        var created = await createResponse.Content.ReadFromJsonAsync<ProductResponse>(
            cancellationToken);

        Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
        Assert.NotNull(created);

        var resourceUri = $"/api/products/{created.Id}";
        using var readResponse = await client.GetAsync(resourceUri, cancellationToken);
        var read = await readResponse.Content.ReadFromJsonAsync<ProductResponse>(cancellationToken);

        Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
        Assert.Equal(created, read);

        using var updateResponse = await client.PutAsJsonAsync(
            resourceUri,
            new ProductRequest("Conference webcam", 99.95m),
            cancellationToken);
        var updated = await updateResponse.Content.ReadFromJsonAsync<ProductResponse>(
            cancellationToken);

        Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
        Assert.Equal("Conference webcam", updated!.Name);

        using var deleteResponse = await client.DeleteAsync(resourceUri, cancellationToken);
        Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);

        var remainingProducts = await _factory.QueryDatabaseAsync(
            scope,
            (database, token) => database.Products.CountAsync(token),
            cancellationToken);
        Assert.Equal(0, remainingProducts);
    }

    private sealed record ProductRequest(string Name, decimal Price);

    private sealed record ProductResponse(int Id, string Name, decimal Price);
}

Expected results are 201, 200, 200, and 204, followed by zero persisted products. Keep these operations in one scope because each operation consumes state produced by the preceding one. Keep unrelated tests independent of this journey.

The scope is disposed on an assertion failure, but direct scope use does not provide the runner's automatic failure-diagnostic capture. Prefer RunInTestScenarioScopeAsync or ScopedTest.RunAsync when recorded diagnostics matter, as in the next flow. SQLite exercises relational behavior but does not establish compatibility with a different production database provider. Continue with the EF Core guide for provider selection and lifecycle options.

Import from an external API and handle failure

Flow: authenticated import request -> real typed HTTP client -> stubbed remote response -> SQLite write -> API response and stored-row verification.

TestApiFactory replaces the typed client's primary HTTP handler with ExternalCatalog; the typed client's request building and JSON handling remain active. In the complete test class, Run calls _factory.RunInTestScenarioScopeAsync with TestContext.Current.CancellationToken.

[Fact]
public Task Controller_calls_external_api_and_saves_the_result_to_database() =>
    Run(async (scope, cancellationToken) =>
    {
        _factory.ExternalCatalog
            .When(HttpMethod.Get, "/products/701")
            .RespondJson(new { Id = 701, Name = "External Keyboard", Price = 149.95m });
        using var client = scope.Client()
            .AsUser(TestUser.Create(name: "Importer"))
            .Build();

        using var response = await client.PostAsync(
            "/api/products/import/701",
            content: null,
            cancellationToken);
        var body = await response.Content.ReadFromJsonAsync<ProductResponse>(cancellationToken);
        var persisted = await _factory.QueryDatabaseAsync(
            scope,
            (database, token) => database.Products
                .AsNoTracking()
                .SingleAsync(product => product.Id == 701, token),
            cancellationToken);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.Equal(new ProductResponse(701, "External Keyboard", 149.95m), body);
        Assert.Equal("External Keyboard", persisted.Name);
        Assert.Equal(149.95m, persisted.Price);
        var request = Assert.Single(_factory.ExternalCatalog.Requests);
        Assert.Equal(HttpMethod.Get, request.Method);
        Assert.Equal("/products/701", request.RequestUri!.PathAndQuery);
    });

The test verifies the response body, persisted values, and exactly one request to /products/701. This is a transport-level stub: no request reaches a remote server. The application still exercises its real typed client, unlike a service-interface replacement.

For the failure branch, arrange a remote 503. The application must return 502 and save no product:

[Fact]
public Task External_failure_returns_bad_gateway_without_saving_a_product() =>
    Run(async (scope, cancellationToken) =>
    {
        _factory.ExternalCatalog
            .When(HttpMethod.Get, "/products/703")
            .Respond(HttpStatusCode.ServiceUnavailable);
        using var client = scope.CreateAuthenticatedClient();

        using var response = await client.PostAsync(
            "/api/products/import/703",
            content: null,
            cancellationToken);
        var productCount = await CountProductsAsync(scope, cancellationToken);

        Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
        Assert.Equal(0, productCount);
        Assert.Equal(1, _factory.ExternalCatalog.CallCount);
    });

A mismatched or unarranged request is a setup error, not an automatic remote success. Inspect the recorded method, path, query, and body before changing an expectation. The same class covers missing products and anonymous requests. See outbound HTTP for exact matchers, sequences, transport exceptions, delays, cancellation, and verification APIs.

Publish and verify an integration event

Flow: authenticated order request -> controller and application publisher -> recorded destination, headers, and typed event payload.

The factory replaces IApplicationMessagePublisher with a recording adapter backed by RecordedMessageBus. In the publishing test class, Run uses the scenario runner and CreateAuthenticatedClient(scope) builds a simulated Publisher user.

[Fact]
public Task Controller_publishes_order_to_kafka_topic() =>
    Run(async (scope, cancellationToken) =>
    {
        using var client = CreateAuthenticatedClient(scope);
        using var response = await client.PostAsJsonAsync(
            "/api/publishing/kafka/orders",
            new { OrderId = 42, CustomerId = "customer-7", Total = 125.50m },
            cancellationToken);

        Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
        var recorded = Assert.Single(
            _factory.PublishedMessages.For(MessageTransportNames.Kafka, "orders.created"));
        Assert.Equal("order-created", recorded.Headers["event-type"]);
        Assert.Equal("customer-7", recorded.Headers["partition-key"]);
        Assert.Equal(
            new OrderCreatedMessage(42, "customer-7", 125.50m),
            recorded.GetPayload<OrderCreatedMessage>());
    });

The expected result is 202 and one orders.created event with the customer's partition key. This establishes the application's publication contract, not Kafka delivery, acknowledgement, or consumer processing. The same class verifies that invalid input publishes nothing. If publication runs in background work, use eventual assertions with a bounded timeout; do not wait an arbitrary fixed delay. See messaging for transport and payload checks.

Validate credentials through the real handler

Flow: locally signed JWT -> Authorization header -> real bearer validation -> authorization policy -> endpoint.

Simulated AsUser and AsAzureAdUser identities exercise authorization without validating a token. For a credential-validation flow, configure the corresponding production handler for a test authority. This is the repository's authentication factory:

public sealed class EndToEndAuthenticationFactory : TestApiFactory
{
    protected override void ConfigureTestAuthentication(TestAuthenticationSchemeBuilder authentication)
    {
        authentication
            .UseEndToEndJwt("AzureAd", authority => authority
                .WithIssuer("https://identity.xbullet.test")
                .WithAudience("test-api")
                .WithTokenLifetime(TimeSpan.FromMinutes(2))
                .WithClockSkew(TimeSpan.Zero)
                .SaveAccessToken())
            .UseEndToEndJwt("PartnerBearer", authority => authority
                .WithIssuer("https://partner.xbullet.test")
                .WithAudience("test-api-partner")
                .WithDiscoveryPath("/.well-known/partner/openid-configuration")
                .WithJwksPath("/.well-known/partner/jwks.json")
                .WithoutDefaultScheme())
            .UseEndToEndApiKey("ApiKey", apiKey => apiKey
                .WithHeaderName("X-Api-Key")
                .WithQueryParameterName("api_key"))
            .UseEndToEndClientCertificate("Certificate");
    }
}

The factory derives from TestApiFactory. Its AzureAd scheme uses a local JWT authority, while the other registrations demonstrate a named partner authority, an API-key handler, and a client certificate handler. Your application needs only the registrations it uses.

In the complete authentication test class, Run delegates to the same isolated scenario runner, using this factory:

[Fact]
public Task Locally_signed_jwt_is_validated_by_the_real_bearer_handler() =>
    Run(async (scope, cancellationToken) =>
    {
        using var result = await scope.Scenario()
            .AsJwt(token => token
                .WithSubject("user-42")
                .WithName("Ada")
                .WithClaim("tid", "tenant-42")
                .WithScope("orders.read")
                .ExpiresAfter(TimeSpan.FromMinutes(1)))
            .Get("/api/secure/azure-ad")
            .ExecuteAsync(cancellationToken);

        Assert.Equal(HttpStatusCode.NoContent, result.Response.StatusCode);
    });

The valid JWT returns 204. The same test class checks 401 for expired, malformed, wrong-audience, wrong-issuer, invalid-signature, unknown-key, unsigned, and not-yet-valid tokens. Those failure cases also check authentication-event diagnostics for validation failure and challenge.

The authority and signing keys are local test data. This exercises the configured handler without contacting a live identity provider; it does not establish that deployed issuer configuration or network access is correct. For setup, scheme selection, API keys, and certificates, see authentication models.

Capture and review an HTTP contract

Flow: recorder installed before the request -> product creation -> captured request and response -> stable baseline comparison -> review of any received file.

In TestApiFactory, SnapshotClient(scope, ... ) adds an HttpExchangeRecorder to the client. The snapshot test class supplies the ProductRequest record and scenario runner. BuiltInSnapshotAudit.CreateSettings registers snapshots with the repository's audit catalog; it is not required by the framework. Caller-source defaults locate the baseline beside the test source.

[Fact]
public Task Create_matches_custom_snapshot_with_generated_id_scrubbed() =>
    Run(async (scope, cancellationToken) =>
    {
        using var client = _factory.SnapshotClient(
                scope,
                options => options.Response.IgnoringHeaders("Location"))
            .AsUser(user => user.WithName("CRUD snapshot tester"))
            .Build();

        using var response = await client.PostAsJsonAsync(
            "/api/products",
            new ProductRequest("Webcam", 79.95m),
            cancellationToken);

        await response.ShouldMatchHttpExchangeSnapshot(
            snapshotSettings: BuiltInSnapshotAudit.CreateSettings(
                settings => settings.ScrubMember("id")),
            cancellationToken: cancellationToken);
    });

The generated resource ID is scrubbed and the unstable Location header is excluded. The assertion compares the captured request and response with a committed baseline. A mismatch writes a received file and fails; review the difference before accepting an intentional contract change.

For your application, configure SnapshotSettings directly or use a project helper. Install the recorder before sending the request, apply redaction before capture where possible, and keep baseline updates as an explicit developer action. Assertion options can further transform a recorded copy; they cannot recover data excluded or redacted during capture. The snapshot getting-started guide explains file placement, initial baseline creation, review, and CI behavior.

Invoke a function with application services

Flow: application Program configuration -> test service overrides -> constructed Kafka trigger -> real function and pricing client -> recorded application result.

This flow reuses application DI and function code through AzureFunctionTestFactory<Program>. The factory definition in the Functions guide replaces the pricing HTTP handler and invocation sink. The complete factory test file contains those helper types and imports:

[Fact]
public async Task Factory_reuses_program_configuration_and_services_with_test_overrides()
{
    await using var factory = new OrderFunctionFactory();
    var host = await factory.CreateHostAsync(TestContext.Current.CancellationToken);
    factory.Pricing.When(HttpMethod.Get, "/products/42/price")
        .RespondJson(new ProductPrice(42, 19.95m, "USD"));

    await host.RunInTestScenarioScopeAsync(async (scope, token) =>
    {
        var trigger = KafkaTriggerData.JsonTrigger(
            new OrderPricingRequestedMessage("order-42", 42, 3), topic: "order-pricing");
        await scope.Host.InvokeAsync<PriceOrderKafkaFunction, string>(
            nameof(PriceOrderKafkaFunction), trigger,
            (function, message, context) => function.RunAsync(message, context), token);

        Assert.Equal(59.85m, Assert.Single(factory.Recorder.Invocations).Amount);
        factory.Pricing.VerifyCalled(HttpMethod.Get, "/products/42/price");
    }, TestContext.Current.CancellationToken);
}

The result is one priced invocation for 59.85 and a verified HTTP pricing lookup. This directly invokes a .NET isolated function with constructed trigger context. It does not start the Azure Functions worker runtime, receive a broker message, or validate delivery and acknowledgement. The factory is asynchronously disposed; the scenario runner owns its invocation scope and resets the registered recorder. See the Functions host guide and trigger recipes for binding and middleware coverage.

Start a distributed application

Flow: AppHost startup -> required resource health -> discovered HTTP endpoint -> service request -> failure diagnostics -> shutdown.

Use AspireTestHost when application processes or a resource topology are part of the flow. The repository's runnable example below is explicit and available on net10.0. Its AppHost project is referenced using the testapphost alias, declared at the top of the complete Aspire test file.

[Fact(Explicit = true)]
public async Task Distributed_app_waits_for_health_serves_http_and_attaches_failure_diagnostics()
{
    var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
        AspireTestHost.Create<testapphost::TestAppHost.AppHostMarker>()
            .WithArguments("--environment=Testing")
            .ConfigureAppHost(_ => { })
            .WaitForResource("api")
            .WithStartupTimeout(TimeSpan.FromMinutes(2))
            .RunAsync(
                async (application, cancellationToken) =>
                {
                    Assert.Contains("api", application.ResourceNames);
                    Assert.NotNull(application.GetEndpoint("api"));
                    using var client = application.CreateHttpClient("api");
                    using var response = await client.GetAsync("/health", cancellationToken);
                    response.EnsureSuccessStatusCode();
                    Assert.Contains(
                        "Healthy",
                        await response.Content.ReadAsStringAsync(cancellationToken));
                    throw new InvalidOperationException("Expected distributed test failure.");
                },
                TestContext.Current.CancellationToken));

    var diagnostics = Assert.IsType<AspireApplicationDiagnostics>(
        exception.Data[AspireApplicationDiagnostics.ExceptionDataKey]);
    var api = Assert.Single(diagnostics.Resources, resource => resource.Name == "api");
    Assert.Equal("Healthy", api.HealthStatus);
    Assert.NotEmpty(api.Logs);
}

The example deliberately throws after a successful health request to verify attached diagnostics. It passes when the expected failure contains healthy resource state and logs. In an application journey, put your service request and business assertions in the RunAsync callback; the runner owns startup and shutdown, including failure cleanup.

Run this class separately after building the repository, with the required Aspire runtime available. When running a filtered class in CI, disable the full-suite snapshot audit for that invocation:

$env:XBULLET_SKIP_SNAPSHOT_AUDIT = "true"
dotnet test tests/TestApi.IntegrationTests/TestApi.IntegrationTests.csproj --configuration Release --framework net10.0 --no-build --no-restore --filter-class TestApi.IntegrationTests.AspireTests --explicit on
Remove-Item Env:XBULLET_SKIP_SNAPSHOT_AUDIT

The sample AppHost launches its API project; Docker is needed when your topology adds container resources. Declare all required resource health waits and bound startup with a timeout. The Aspire guide describes discovery, diagnostics, and manual lifecycle control. For a single-service test with a real database or broker, use the Testcontainers flow instead of starting an entire distributed topology.

Diagnose and maintain a flow

Failure Check first What to verify before retrying
Unexpected 401 or 403 Selected authentication mode, scheme, roles, and scopes Whether credentials should be validated or an identity simulated
Missing or unexpected outbound call Stub's recorded method, URI, and arrangement Whether the right typed client uses the configured handler
Wrong persisted state Database query's scenario scope Whether all requests and setup used that same scope
Missing event HTTP result, destination, payload, and recorder diagnostics Whether publication completed synchronously or needs bounded eventual assertions
Snapshot mismatch Received versus verified file and redaction rules Whether the change is intentional and safe to commit
Distributed startup timeout Attached resource state and logs Whether dependencies are runnable and required health checks can succeed

Keep failures observable: an expected negative flow should assert its status and absence of unwanted rows, calls, or messages. Propagate the test cancellation token; let the runner collect diagnostics and clean up after cancellation. Do not blindly retry a state-changing request against a previous test's state.

Run the ordinary solution build and tests in CI. Add explicit distributed or container jobs with their own prerequisites, timeouts, and artifact policy. Keep credentials and personal data out of recorded requests, snapshots, and uploaded diagnostics; follow sensitive-data guidance.

When changing an example, edit its canonical test source and run eng/sync-documentation-snippets.ps1. The ordinary documentation gate checks that the guide still matches its executable source. See documentation examples for the authoring workflow and the API index for the entry-point contracts used throughout these flows.