Skip to content
Integration Testing ASP.NET Core APIs: WebApplicationFactory Patterns and Best Practices

Integration Testing ASP.NET Core APIs: WebApplicationFactory Patterns and Best Practices

1 Integration Testing ASP.NET Core APIs: WebApplicationFactory Patterns and Best Practices

Production-grade integration testing in ASP.NET Core is about testing the API as a running application, not as a collection of isolated classes. The goal is to verify routing, middleware, filters, authentication, dependency injection, configuration, database access, serialization, and external service boundaries together.

For senior developers and architects, the core question is not “Can I call a controller method?” It is: “Can a real HTTP request move through the same application pipeline that production uses and still behave correctly under realistic infrastructure conditions?”

This article covers sections 1–3 from the requested outline: foundations, advanced WebApplicationFactory<TEntryPoint> patterns, and ephemeral database strategies for modern ASP.NET Core API integration testing. The structure and scope are based on the provided article brief and outline.

Recommended stack for practical examples:

.NET 9 or .NET 10
ASP.NET Core Web API
xUnit
Microsoft.AspNetCore.Mvc.Testing
Entity Framework Core
PostgreSQL or SQL Server
Testcontainers for .NET
Respawn
Bogus

Microsoft’s current ASP.NET Core integration testing guidance describes WebApplicationFactory<TEntryPoint> as the standard way to create a TestServer and issue HTTP requests against the system under test without opening a real network port.

1.1 Foundations of Production-Grade Integration Testing in ASP.NET Core

1.1.1 The Shift-Left Philosophy for Architects

Unit tests are still valuable. They are fast, focused, and excellent for business rules that can be tested without infrastructure. But many API failures do not happen inside a pure function.

They happen because:

A middleware runs before authentication is configured.
A JSON enum is serialized differently than the client expects.
A database migration changed a nullable column.
A scoped service is accidentally registered as a singleton.
A health check uses a different connection string than the API.
An authorization policy works locally but fails when claims are missing.

These are integration failures. They appear when components meet each other.

Shift-left integration testing means catching these failures before the code reaches a shared environment. Instead of waiting for QA, UAT, or staging to expose configuration and pipeline problems, the team runs realistic API tests during local development and CI.

A simple unit test may prove that this service works:

var result = await orderService.CreateOrderAsync(command);
Assert.Equal(OrderStatus.Pending, result.Status);

But it does not prove that the real API endpoint works:

POST /api/orders
Authorization: Bearer <token>
Content-Type: application/json

The integration test verifies the route, model binding, validation, authentication, database transaction, response contract, and persistence behavior together.

That matters because API behavior is often an architectural property, not just a class-level property.

1.1.2 The Integration Testing Pyramid vs. the Testing Trophy in Modern API Design

The traditional testing pyramid says most tests should be unit tests, fewer should be integration tests, and very few should be end-to-end tests. That remains useful, but modern API systems often fit better with the “testing trophy” mindset: strong static checks, meaningful unit tests, many integration tests around critical behavior, and fewer browser-level or full end-to-end tests.

For ASP.NET Core APIs, a healthy balance usually looks like this:

Unit tests:
- Domain calculations
- Validators
- Mapping logic
- Policy decisions
- Pure application services

Integration tests:
- HTTP request/response behavior
- Authentication and authorization
- EF Core queries and migrations
- Message publishing boundaries
- External API client behavior via stubs
- Multi-tenant routing
- Idempotency and concurrency behavior

End-to-end tests:
- A few critical user journeys across deployed services

The mistake is treating controller tests as integration tests when they bypass the real HTTP pipeline. Testing a controller class directly skips routing, filters, middleware, JSON serialization, authentication handlers, exception handling, and dependency injection behavior.

A better integration test starts with an HttpClient created by WebApplicationFactory.

public sealed class OrderApiTests 
    : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

    public OrderApiTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task Post_order_returns_201_and_persists_order()
    {
        var request = new
        {
            customerId = "CUST-1001",
            sku = "SKU-ABC",
            quantity = 2
        };

        var response = await _client.PostAsJsonAsync("/api/orders", request);

        response.EnsureSuccessStatusCode();
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }
}

This is still fast compared to a deployed environment test, but it exercises the application more honestly.

1.2 Deconstructing WebApplicationFactory<TEntryPoint>

1.2.1 How It Bootstraps the Application In-Memory Using the TestServer

WebApplicationFactory<TEntryPoint> lives in the Microsoft.AspNetCore.Mvc.Testing package. It starts the ASP.NET Core application in a test host and provides an HttpClient that sends requests to the in-memory TestServer. Microsoft’s API documentation describes the factory as a way to create a TestServer for the MVC application defined by the entry point and create one or more HttpClient instances for tests.

The important point: the test sends real HTTP-style requests, but without real socket traffic.

That means the test can validate:

Routing
Middleware order
Authentication handlers
Authorization policies
Endpoint filters
Model binding
Validation
JSON serialization
Exception handling
DI lifetimes
EF Core behavior

A minimal test project typically references the API project and includes:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
  <PackageReference Include="xunit" Version="2.9.2" />
  <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

Use the exact package versions that match your target framework and enterprise standards.

1.2.2 Understanding the Host Lifecycle: Configuration, DI Scoping, and Asset Routing

A common misunderstanding is that WebApplicationFactory creates a lightweight mock of the app. It does not. It builds the application host with test-specific overrides.

This has practical consequences.

First, configuration is still loaded. If your app reads appsettings.json, environment variables, user secrets, or Azure App Configuration, the test host may try to load them unless you override the behavior.

Second, dependency injection lifetimes still matter. A scoped service is created per request scope. A singleton lives across requests and possibly across multiple tests if the same factory instance is reused.

Third, middleware runs in order. If UseAuthentication() comes after UseAuthorization(), your integration tests should catch authorization failures that unit tests would miss.

Fourth, application assets and content root matter. The factory tries to infer the content root for the application. In large solutions, especially those with multiple API projects, test projects, and custom build paths, you may need explicit configuration to avoid loading the wrong settings or static assets.

1.2.3 Pitfalls of Using Program vs. Explicit Custom Factory Definitions

With minimal hosting, most ASP.NET Core applications use Program.cs as the entry point. That works well:

public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrderApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }
}

But enterprise test suites usually need more control. A raw WebApplicationFactory<Program> quickly leads to repeated setup code across test classes.

The better approach is a custom factory.

public sealed class CustomWebApplicationFactory
    : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("IntegrationTest");

        builder.ConfigureAppConfiguration((context, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["FeatureFlags:UseFakePaymentGateway"] = "true"
            });
        });

        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<IPaymentGateway>();
            services.AddScoped<IPaymentGateway, FakePaymentGateway>();
        });
    }
}

In many minimal API projects, you also need to make the generated Program class visible to the test assembly:

public partial class Program
{
}

Place that at the bottom of Program.cs. It gives the test project a stable entry point without changing application behavior.


2 Advanced Custom WebApplicationFactory Patterns

2.1 The Enterprise Base Factory Blueprint

2.1.1 Creating an Extensible, Strongly Typed CustomWebApplicationFactory

A good factory becomes the test composition root. It centralizes test infrastructure, configuration overrides, fake services, authentication setup, and database lifecycle.

Avoid this pattern:

// Incorrect
var factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddSingleton<IFoo, FakeFoo>();
        });
    });

This is acceptable for a small sample, but it becomes hard to maintain when 50 test classes need the same setup.

Recommended:

public sealed class AppWebApplicationFactory
    : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly Dictionary<string, string?> _settings = new();

    public AppWebApplicationFactory WithSetting(string key, string? value)
    {
        _settings[key] = value;
        return this;
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("IntegrationTest");

        builder.ConfigureAppConfiguration((context, config) =>
        {
            config.AddInMemoryCollection(_settings);
        });

        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<ISystemClock>();
            services.AddSingleton<ISystemClock>(
                new FixedSystemClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
        });
    }

    public Task InitializeAsync()
    {
        return Task.CompletedTask;
    }

    public new Task DisposeAsync()
    {
        return Task.CompletedTask;
    }
}

This gives tests a clean extension point without repeating infrastructure setup.

2.1.2 Leveraging ConfigureWebHost and ConfigureAppConfiguration

Use ConfigureWebHost for test-specific host behavior. Use ConfigureAppConfiguration for configuration overrides. Keep the distinction clear.

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.UseEnvironment("IntegrationTest");

    builder.ConfigureAppConfiguration((context, config) =>
    {
        config.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["ConnectionStrings:OrdersDb"] = _postgresConnectionString,
            ["ExternalApis:Delivery:BaseUrl"] = _wireMockUrl,
            ["Serilog:MinimumLevel:Default"] = "Warning"
        });
    });
}

This pattern is useful when connection strings are only known at runtime, such as when Testcontainers assigns a dynamic port.

2.2 Clean Dependency Injection Overrides

2.2.1 Best Practices for Swapping Production Services Using ConfigureTestServices

ConfigureTestServices is designed for replacing registered services in integration tests. Microsoft’s ASP.NET Core integration testing guidance shows this as the standard hook for overriding services after the application has configured its normal service collection.

Example:

builder.ConfigureTestServices(services =>
{
    services.RemoveAll<IEmailSender>();
    services.AddSingleton<IEmailSender, CapturingEmailSender>();

    services.RemoveAll<IPaymentGateway>();
    services.AddScoped<IPaymentGateway, StubPaymentGateway>();
});

Use this when the dependency is outside the boundary of the integration test.

Good candidates:

Email providers
Payment gateways
SMS providers
ERP APIs
Document signing services
Third-party HTTP APIs

Poor candidates:

Application services
Repositories
EF Core DbContext
Authorization policies
Model validators

If you replace too much, the test stops being an integration test.

2.2.2 Avoiding Service Registration Pollution and Singleton Cleanup

The most common service override bug is adding a fake service without removing the production registration.

// Incorrect
services.AddSingleton<IEmailSender, FakeEmailSender>();

If the production app already registered IEmailSender, the container may resolve the last registration for single service resolution, but all registrations may still exist for IEnumerable<IEmailSender>. That can create confusing behavior.

Use:

// Recommended
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();

Also be careful with mutable singleton fakes:

public sealed class CapturingEmailSender : IEmailSender
{
    public List<EmailMessage> SentMessages { get; } = new();

    public Task SendAsync(EmailMessage message)
    {
        SentMessages.Add(message);
        return Task.CompletedTask;
    }
}

This is simple but not thread-safe. If tests run in parallel, shared mutable state can cause flaky failures. Use a thread-safe collection or create scoped fakes where possible.

public sealed class CapturingEmailSender : IEmailSender
{
    private readonly ConcurrentQueue<EmailMessage> _messages = new();

    public IReadOnlyCollection<EmailMessage> SentMessages => _messages.ToArray();

    public Task SendAsync(EmailMessage message)
    {
        _messages.Enqueue(message);
        return Task.CompletedTask;
    }
}

2.3 Managing Configuration and Secrets at Test Runtime

2.3.1 Injecting Dynamic Configuration Values via MemoryConfigurationSource

Runtime configuration is unavoidable in serious integration tests. Container ports, database names, WireMock URLs, feature flags, and tenant settings may not be known until test startup.

Use in-memory configuration for these values:

builder.ConfigureAppConfiguration((context, config) =>
{
    var overrides = new Dictionary<string, string?>
    {
        ["ConnectionStrings:OrdersDb"] = _dbConnectionString,
        ["ExternalApis:Delivery:BaseUrl"] = _deliveryApiBaseUrl,
        ["Features:EnableOrderRecommendations"] = "false"
    };

    config.AddInMemoryCollection(overrides);
});

This keeps test configuration close to the test infrastructure. It also avoids modifying appsettings.Development.json or leaking machine-specific settings into source control.

2.3.2 Isolating User Secrets, Environment Variables, and Appsettings JSON Variants

Integration tests should not depend on developer user secrets or real cloud credentials. That creates three risks:

Tests pass only on one developer’s machine.
CI fails because secrets are missing.
Tests accidentally call real external systems.

A safer configuration model is:

appsettings.json
appsettings.IntegrationTest.json
in-memory overrides from the custom factory
container-generated connection strings
fake or local endpoints for external services

Example appsettings.IntegrationTest.json:

{
  "ExternalApis": {
    "Payment": {
      "BaseUrl": "http://localhost"
    }
  },
  "Features": {
    "SendRealEmails": false
  }
}

The rule is simple: integration tests should be realistic inside the sandbox, not dependent on production-like secrets.


3 Ephemeral Infrastructure: Test Database and State Strategies

3.1 The Death of In-Memory Providers

3.1.1 Why EF Core InMemory and SQLite In-Memory Fail to Catch Real Bugs

The EF Core InMemory provider is useful for narrow tests, but it is not a relational database. Microsoft’s provider documentation states that the EF Core in-memory database is not designed for production use, is not designed for robustness, and is not receiving new features.

The deeper issue is behavioral mismatch.

EF Core InMemory may not catch:

Foreign key violations
Unique index behavior
Transaction behavior
SQL translation errors
Provider-specific query differences
Case sensitivity differences
Migration issues
Decimal/date/time precision differences

SQLite in-memory is better because it is relational, but it still does not behave exactly like SQL Server or PostgreSQL. Microsoft’s SQLite provider documentation notes migrations limitations caused by SQLite engine limitations.

Use in-memory providers when testing application logic that happens to use a DbContext. Do not rely on them for production-grade API integration testing.

Better:

Production uses PostgreSQL -> Test with PostgreSQL container
Production uses SQL Server -> Test with SQL Server container
Production uses Redis -> Test with Redis container

3.2 Modern Containerization with Testcontainers for .NET

3.2.1 Architecting a Self-Contained Automated Lifecycle

Testcontainers for .NET supports throwaway Docker containers for test dependencies, including databases and infrastructure services. The official documentation describes it as a library for tests with disposable Docker container instances.

A PostgreSQL container fixture can look like this:

public sealed class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("orders_test")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

    public string ConnectionString => _postgres.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
    }
}

Then pass the container connection string into the factory:

public sealed class AppFactory : WebApplicationFactory<Program>
{
    private readonly string _connectionString;

    public AppFactory(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration((context, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:OrdersDb"] = _connectionString
            });
        });
    }
}

This removes the need for a shared developer database and makes CI more reliable.

3.2.2 Optimizing Container Reuse Across Test Runs

Containers improve correctness, but startup time matters. A suite that starts a new database container for every test will become slow.

Use these tactics:

Start one database container per test collection.
Apply migrations once per collection.
Reset data between tests using Respawn.
Use unique databases or schemas for parallel collections.
Avoid rebuilding Docker images unless the test requires it.
Cache NuGet packages and Docker layers in CI.

Testcontainers also provides xUnit integration packages that automate setup and teardown of container resources using xUnit shared context patterns.

The trade-off is isolation versus speed. For most enterprise APIs, the best default is:

Container per collection
Schema migration once
Database reset per test
Parallel collections only when data boundaries are isolated

3.3 Database Schema Migration and State Seeding Patterns

3.3.1 Executing EF Core Migrations Reliably Inside the Test Container

After the container starts, run the same EF Core migrations that production uses.

public async Task ApplyMigrationsAsync(IServiceProvider services)
{
    using var scope = services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();

    await db.Database.MigrateAsync();
}

Call this once during fixture initialization:

public async Task InitializeAsync()
{
    await _postgres.StartAsync();

    Factory = new AppFactory(_postgres.GetConnectionString());

    using var scope = Factory.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();

    await db.Database.MigrateAsync();
}

This catches migration failures early. It also prevents a common mismatch where tests pass against EnsureCreated() but production fails during migration.

3.3.2 Implementing Respawn for Fast Database Resets

Dropping and recreating a database between tests is reliable but slow. Transaction rollback is fast but breaks down when the API opens its own connections, uses background workers, or commits independently.

Respawn is designed to reset test databases to a clean state by intelligently deleting table data rather than relying on rollback or full database recreation.

Example:

public sealed class DatabaseResetter
{
    private Respawner _respawner = default!;
    private readonly string _connectionString;

    public DatabaseResetter(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task InitializeAsync()
    {
        await using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        _respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            SchemasToInclude = new[] { "public" }
        });
    }

    public async Task ResetAsync()
    {
        await using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        await _respawner.ResetAsync(connection);
    }
}

Use it before each test:

public async Task InitializeAsync()
{
    await _resetter.ResetAsync();
}

This gives each test a clean database without paying the cost of rebuilding the schema.

3.3.3 Deterministic Data Seeding vs. Dynamic Runtime Factories

Seeding strategy affects test reliability.

Use deterministic seed data when the test depends on exact values:

db.Products.Add(new Product
{
    Id = ProductIds.StandardKeyboard,
    Name = "Mechanical Keyboard",
    Sku = "KEY-001",
    Price = 89.99m
});

Use dynamic factories when you need realistic variation:

var customerFaker = new Faker<Customer>()
    .RuleFor(x => x.Id, _ => Guid.NewGuid())
    .RuleFor(x => x.Name, f => f.Company.CompanyName())
    .RuleFor(x => x.Email, f => f.Internet.Email());

var customer = customerFaker.Generate();

The rule:

Use fixed data for assertions.
Use generated data for volume, variety, and edge coverage.
Always control randomness with seeds when failures must be reproducible.

Example:

Randomizer.Seed = new Random(12345);

For production-grade ASP.NET Core API integration testing, the strongest pattern is clear:

Use WebApplicationFactory for the real API pipeline.
Use ConfigureTestServices only at external boundaries.
Use runtime configuration for container-generated values.
Use real database containers instead of EF Core InMemory.
Run migrations once.
Reset state with Respawn.
Seed data intentionally.

That combination gives teams a test suite that is fast enough for CI, realistic enough to catch architecture-level bugs, and maintainable enough for large API surfaces.


4 Securing the Sandbox: Authentication, Authorization, and WireMocking

Security-related integration tests should prove that the API behaves correctly when requests move through the real authorization pipeline. The goal is not to connect every test to Entra ID, Auth0, Okta, or a live identity provider. That would make the suite slow, fragile, and dependent on external configuration. The better pattern is to keep the ASP.NET Core authentication and authorization middleware active, but replace the identity source with a deterministic test handler.

This gives you realistic RBAC and policy testing without making the test suite responsible for validating the identity provider itself.

4.1 Bypassing and Simulating Authentication Infrastructure

4.1.1 Creating a Custom AuthenticationHandler for Testing RBAC and Policy-Based Authorization

A test authentication handler lets the API believe a real authenticated user is calling it. The handler creates a ClaimsPrincipal with roles, tenant claims, permissions, or any other claim shape your production policies expect.

public sealed class TestAuthHandler 
    : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string SchemeName = "TestAuth";

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user-001"),
            new Claim(ClaimTypes.Name, "Integration Test User"),
            new Claim(ClaimTypes.Role, "OrderManager"),
            new Claim("permission", "orders.create"),
            new Claim("tenant_id", "tenant-a")
        };

        var identity = new ClaimsIdentity(claims, SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, SchemeName);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Register it inside the custom factory:

builder.ConfigureTestServices(services =>
{
    services.AddAuthentication(TestAuthHandler.SchemeName)
        .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
            TestAuthHandler.SchemeName,
            options => { });

    services.PostConfigure<AuthenticationOptions>(options =>
    {
        options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
        options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
    });
});

Now tests can verify real authorization outcomes:

[Fact]
public async Task Post_order_allows_user_with_create_permission()
{
    var response = await _client.PostAsJsonAsync("/api/orders", new
    {
        customerId = "CUST-2001",
        sku = "SKU-001",
        quantity = 1
    });

    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}

For negative cases, make the handler configurable instead of hardcoding one claim set. A simple approach is to pass claims through request headers used only in integration tests.

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
    var role = Request.Headers["X-Test-Role"].FirstOrDefault() ?? "Reader";
    var permission = Request.Headers["X-Test-Permission"].FirstOrDefault();

    var claims = new List<Claim>
    {
        new(ClaimTypes.NameIdentifier, "test-user-001"),
        new(ClaimTypes.Role, role)
    };

    if (!string.IsNullOrWhiteSpace(permission))
    {
        claims.Add(new Claim("permission", permission));
    }

    var identity = new ClaimsIdentity(claims, SchemeName);
    var ticket = new AuthenticationTicket(
        new ClaimsPrincipal(identity),
        SchemeName);

    return Task.FromResult(AuthenticateResult.Success(ticket));
}

Then test a denied request cleanly:

[Fact]
public async Task Post_order_returns_403_without_required_permission()
{
    using var request = new HttpRequestMessage(HttpMethod.Post, "/api/orders");
    request.Headers.Add("X-Test-Role", "Reader");
    request.Content = JsonContent.Create(new
    {
        customerId = "CUST-2002",
        sku = "SKU-002",
        quantity = 1
    });

    var response = await _client.SendAsync(request);

    Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

This keeps the test focused on API authorization behavior, not token acquisition.

4.1.2 Simulating JWT Tokens Using WebApplicationFactory Token Generation Schemes

Some teams prefer passing a bearer token because their middleware, logging, or downstream code expects the Authorization header to exist. That is reasonable, but the token still does not need to come from a live issuer.

A practical pattern is to create signed test JWTs with a local symmetric key and configure the API to trust that key only in the test environment.

public static class TestJwtTokenFactory
{
    private static readonly byte[] Key =
        Encoding.UTF8.GetBytes("integration-test-signing-key-32chars");

    public static string CreateToken(params Claim[] claims)
    {
        var credentials = new SigningCredentials(
            new SymmetricSecurityKey(Key),
            SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: "integration-tests",
            audience: "orders-api",
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(30),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public static TokenValidationParameters ValidationParameters => new()
    {
        ValidateIssuer = true,
        ValidIssuer = "integration-tests",
        ValidateAudience = true,
        ValidAudience = "orders-api",
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Key),
        ClockSkew = TimeSpan.Zero
    };
}

Register the test validation rules in the factory:

builder.ConfigureTestServices(services =>
{
    services.PostConfigure<JwtBearerOptions>(
        JwtBearerDefaults.AuthenticationScheme,
        options =>
        {
            options.TokenValidationParameters =
                TestJwtTokenFactory.ValidationParameters;
        });
});

Use the token in a test:

[Fact]
public async Task Get_orders_returns_only_authorized_results()
{
    var token = TestJwtTokenFactory.CreateToken(
        new Claim(ClaimTypes.NameIdentifier, "manager-001"),
        new Claim(ClaimTypes.Role, "OrderManager"),
        new Claim("tenant_id", "tenant-a"));

    _client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", token);

    var response = await _client.GetAsync("/api/orders");

    response.EnsureSuccessStatusCode();
}

Use this approach when you want to exercise JWT bearer authentication behavior. Use the custom authentication handler when you want simpler and faster role or policy testing.

4.2 Isolating Internal Services via WireMock.Net

4.2.1 Setting Up WireMock Servers to Stub Outbound HttpClient Requests

Many enterprise APIs call other systems: payment gateways, ERP platforms, delivery APIs, tax engines, address validation services, or document management services. Integration tests should not call those systems directly. They should verify that your API sends the correct request and handles the response correctly.

WireMock.Net is a good fit for this boundary. Start a local server during the test run and point your typed HttpClient configuration to it.

public sealed class ExternalApiFixture : IDisposable
{
    public WireMockServer DeliveryApi { get; } =
        WireMockServer.Start();

    public string DeliveryApiBaseUrl => DeliveryApi.Url!;

    public void Dispose()
    {
        DeliveryApi.Stop();
        DeliveryApi.Dispose();
    }
}

Configure a stub:

DeliveryApi
    .Given(Request.Create()
        .WithPath("/shipments")
        .UsingPost())
    .RespondWith(Response.Create()
        .WithStatusCode(201)
        .WithHeader("Content-Type", "application/json")
        .WithBodyAsJson(new
        {
            shipmentId = "SHIP-1001",
            status = "Created"
        }));

Inject the WireMock URL through test configuration:

builder.ConfigureAppConfiguration((context, config) =>
{
    config.AddInMemoryCollection(new Dictionary<string, string?>
    {
        ["ExternalApis:Delivery:BaseUrl"] = _externalApis.DeliveryApiBaseUrl
    });
});

Now the API can use its normal typed client:

public sealed class DeliveryClient
{
    private readonly HttpClient _httpClient;

    public DeliveryClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<CreateShipmentResult> CreateShipmentAsync(
        CreateShipmentRequest request,
        CancellationToken cancellationToken)
    {
        var response = await _httpClient.PostAsJsonAsync(
            "/shipments",
            request,
            cancellationToken);

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<CreateShipmentResult>(
            cancellationToken: cancellationToken)
            ?? throw new InvalidOperationException("Empty delivery response.");
    }
}

The test still validates the API boundary, but the external system is controlled, repeatable, and fast.

4.2.2 Simulating Network Failures, Slow Responses, and Transient HTTP Faults

A reliable integration suite should include failure-path tests. The important scenarios are not only successful 200 responses. Real APIs need to handle timeouts, throttling, temporary outages, and malformed responses.

Example 5xx response:

DeliveryApi
    .Given(Request.Create()
        .WithPath("/shipments")
        .UsingPost())
    .RespondWith(Response.Create()
        .WithStatusCode(503)
        .WithBody("Delivery service unavailable"));

Example slow response:

DeliveryApi
    .Given(Request.Create()
        .WithPath("/shipments")
        .UsingPost())
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithDelay(TimeSpan.FromSeconds(5))
        .WithBodyAsJson(new
        {
            shipmentId = "SHIP-DELAYED",
            status = "Created"
        }));

If the API uses Polly or the .NET resilience pipeline, test the observable behavior rather than internal retry counters. For example, verify that the API returns 503 Service Unavailable, creates no shipment record, and logs an integration failure event.

[Fact]
public async Task Create_order_returns_503_when_delivery_api_is_unavailable()
{
    _deliveryApi
        .Given(Request.Create().WithPath("/shipments").UsingPost())
        .RespondWith(Response.Create().WithStatusCode(503));

    var response = await _client.PostAsJsonAsync("/api/orders", new
    {
        customerId = "CUST-3001",
        sku = "SKU-FAIL",
        quantity = 1
    });

    Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}

That is more valuable than asserting that a specific retry method was called.


5 Architecting for Large API Surfaces and Vertical Slices

As the API grows, integration tests need structure. A flat Tests folder with hundreds of files becomes hard to navigate. The test assembly should reflect how the system is maintained, not just how the code is technically layered.

5.1 Structuring Large Test Assemblies

5.1.1 Organization Strategies for Clean Architecture vs. Vertical Slice Architecture

In Clean Architecture, production code is often organized by layers:

Api
Application
Domain
Infrastructure

But integration tests should usually be organized by behavior:

IntegrationTests
  Orders
    CreateOrderTests.cs
    CancelOrderTests.cs
    GetOrderHistoryTests.cs
  Products
    SearchProductsTests.cs
    UpdateInventoryTests.cs
  Customers
    RegisterCustomerTests.cs

This makes it easier to understand what business capability is protected by the test suite.

For Vertical Slice Architecture, the test layout can mirror the feature slices directly:

Features
  Orders
    Create
      CreateOrderEndpointTests.cs
    Cancel
      CancelOrderEndpointTests.cs
  Catalog
    Search
      SearchCatalogEndpointTests.cs

The key is to avoid splitting tests by technical mechanism:

ControllersTests
ServicesTests
RepositoriesTests
ValidatorsTests

That structure forces readers to reconstruct the feature from scattered files.

5.1.2 Grouping Tests by Feature Slice Rather Than Technical Layer

A useful pattern is one test class per endpoint behavior, not one massive class per controller.

public sealed class CreateOrderTests 
    : IClassFixture<OrderApiFixture>
{
    private readonly HttpClient _client;

    public CreateOrderTests(OrderApiFixture fixture)
    {
        _client = fixture.Client;
    }

    [Fact]
    public async Task Returns_201_for_valid_order()
    {
        var response = await _client.PostAsJsonAsync("/api/orders", new
        {
            customerId = "CUST-4001",
            sku = "SKU-100",
            quantity = 2
        });

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }

    [Fact]
    public async Task Returns_400_for_invalid_quantity()
    {
        var response = await _client.PostAsJsonAsync("/api/orders", new
        {
            customerId = "CUST-4001",
            sku = "SKU-100",
            quantity = 0
        });

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }
}

This keeps the test name, request, expected behavior, and feature context close together.

5.2 Minimizing the Test Server Bootstrapping Penalty

5.2.1 Utilizing xUnit IClassFixture<T> and ICollectionFixture<T>

Bootstrapping the ASP.NET Core host, starting containers, applying migrations, and warming up clients can be expensive. The answer is not to avoid integration tests. The answer is to share expensive infrastructure at the right level.

Use IClassFixture<T> when the fixture is safe to share within one test class.

public sealed class OrderApiFixture : IAsyncLifetime
{
    public AppWebApplicationFactory Factory { get; private set; } = default!;
    public HttpClient Client { get; private set; } = default!;

    public async Task InitializeAsync()
    {
        Factory = new AppWebApplicationFactory();
        Client = Factory.CreateClient();

        await Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        Client.Dispose();
        await Factory.DisposeAsync();
    }
}

Use ICollectionFixture<T> when multiple classes need the same expensive setup.

[CollectionDefinition("Order API")]
public sealed class OrderApiCollection 
    : ICollectionFixture<OrderApiFixture>
{
}

Then attach test classes to the collection:

[Collection("Order API")]
public sealed class CreateOrderTests
{
    private readonly OrderApiFixture _fixture;

    public CreateOrderTests(OrderApiFixture fixture)
    {
        _fixture = fixture;
    }
}

5.2.2 Balancing Test Isolation with Suite Execution Speed

Sharing a factory improves speed, but it introduces shared state risk. The safe compromise is to share infrastructure and reset data.

Share:
- TestServer
- HttpClient
- Database container
- WireMock server

Reset:
- Database rows
- Captured fake messages
- WireMock mappings
- Mutable singleton state

A fixture can expose a reset method:

public async Task ResetAsync()
{
    await Database.ResetAsync();

    DeliveryApi.ResetMappings();
    DeliveryApi.ResetLogEntries();

    EmailSender.Clear();
}

Call it before each test when the test class implements IAsyncLifetime.

public async Task InitializeAsync()
{
    await _fixture.ResetAsync();
}

This gives you clean test behavior without paying the full startup cost for every test.

5.3 Managing the Request/Response Pipeline in Multi-Tenant Environments

5.3.1 Dynamically Injecting Tenant Context via Custom HTTP Request Headers

Multi-tenant APIs usually resolve tenant context from headers, hostnames, JWT claims, or route segments. Integration tests should validate that tenant resolution happens before business logic executes.

A simple test tenant middleware might expect a header:

public sealed class TenantContextMiddleware
{
    private readonly RequestDelegate _next;

    public TenantContextMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ITenantContext tenantContext)
    {
        var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();

        if (string.IsNullOrWhiteSpace(tenantId))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Missing tenant header.");
            return;
        }

        tenantContext.TenantId = tenantId;
        await _next(context);
    }
}

The test should pass tenant headers explicitly:

using var request = new HttpRequestMessage(HttpMethod.Get, "/api/orders");
request.Headers.Add("X-Tenant-Id", "tenant-a");

var response = await _client.SendAsync(request);

response.EnsureSuccessStatusCode();

This makes tenant behavior visible in every test that depends on it.

5.3.2 Validating Multi-Tenant Database Routing and Data Isolation

For tenant-isolated data, do not only test successful reads. Test that cross-tenant access fails.

[Fact]
public async Task Get_order_does_not_return_order_from_another_tenant()
{
    await _fixture.SeedOrderAsync(
        tenantId: "tenant-b",
        orderId: "ORD-9001");

    using var request = new HttpRequestMessage(
        HttpMethod.Get,
        "/api/orders/ORD-9001");

    request.Headers.Add("X-Tenant-Id", "tenant-a");

    var response = await _client.SendAsync(request);

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

Returning 404 is often safer than returning 403 because it avoids confirming that another tenant’s record exists. The exact response depends on your security model, but the test should make the intended behavior explicit.


6 Maximizing Throughput: Parallel Execution, Flakiness, and CI/CD

A large integration suite must be predictable. Speed matters, but predictable results matter more. A five-minute suite that fails randomly is worse than an eight-minute suite that developers trust.

6.1 Parallelization Mastery with xUnit and Semantic Isolation

6.1.1 Designing Collections to Avoid Deadlocks and State Contamination

xUnit runs test classes in parallel by default, but tests in the same collection do not run in parallel with each other. Use that behavior intentionally.

[CollectionDefinition("Database collection", DisableParallelization = true)]
public sealed class DatabaseCollection 
    : ICollectionFixture<DatabaseFixture>
{
}

Disable parallelization only where shared state requires it. Do not turn it off globally unless the suite is small or the infrastructure cannot safely support parallel runs.

Group tests by shared resource:

Order database collection
Catalog database collection
External API collection
Read-only query collection

Read-only tests can often run in parallel. Tests that mutate the same tables need stronger isolation.

6.1.2 Achieving Concurrency with Unique Ports and Database-Per-Collection

For higher throughput, give each collection its own database or schema.

public static string CreateDatabaseName()
{
    return $"orders_test_{Guid.NewGuid():N}";
}

When using containers, avoid hardcoded host ports. Let Docker assign ports dynamically and pass the resolved connection string into the factory.

private readonly PostgreSqlContainer _postgres =
    new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase(CreateDatabaseName())
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

This prevents collisions when CI agents run multiple jobs on the same machine.

6.2 Eliminating Flakiness in Enterprise Test Suites

6.2.1 Identifying and Fixing Race Conditions in Asynchronous Endpoints

Flaky tests often come from assuming asynchronous work has completed immediately after the API returns. If the endpoint writes to a queue or starts background processing, the test must wait for an observable condition.

Avoid arbitrary sleeps:

// Incorrect
await Task.Delay(3000);

Use polling with a timeout:

public static async Task EventuallyAsync(
    Func<Task<bool>> condition,
    TimeSpan timeout)
{
    var deadline = DateTimeOffset.UtcNow.Add(timeout);

    while (DateTimeOffset.UtcNow < deadline)
    {
        if (await condition())
        {
            return;
        }

        await Task.Delay(100);
    }

    throw new TimeoutException("Condition was not met in time.");
}

Then assert on real state:

await EventuallyAsync(async () =>
{
    var order = await _fixture.FindOrderAsync("ORD-1001");
    return order?.Status == OrderStatus.ShipmentRequested;
}, TimeSpan.FromSeconds(5));

This makes the test resilient without hiding real failures.

6.2.2 Handling Background Hosted Services inside WebApplicationFactory

Background services can interfere with integration tests if they start timers, process queues, or call external systems. For most endpoint tests, replace them with no-op implementations unless the background behavior is the subject of the test.

builder.ConfigureTestServices(services =>
{
    services.RemoveAll<IHostedService>();
    services.AddHostedService<NoOpHostedService>();
});

Example no-op service:

public sealed class NoOpHostedService : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}

For tests that need the background worker, keep it enabled but isolate its dependencies. Use fake queues, controlled clocks, and deterministic data so the worker does not race with unrelated tests.

6.3 CI/CD Pipeline Integration Patterns

6.3.1 Optimizing GitHub Actions or Azure DevOps Pipelines for Testcontainers

A CI agent running Testcontainers needs Docker available. In GitHub Actions, that usually means running on a Linux hosted runner with Docker support.

name: api-integration-tests

on:
  pull_request:
  push:
    branches:
      - main

jobs:
  integration-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release --filter Category=Integration

For Azure DevOps, keep the same idea: use an agent with Docker, restore once, build once, and run integration tests as a separate stage so failures are easy to identify.

6.3.2 Artifact Caching Strategies to Keep Feedback Loops Short

Most CI time is lost in repeated restore, image pulls, and rebuilds. Cache NuGet packages and rely on Docker layer caching where your pipeline supports it.

- name: Cache NuGet packages
  uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
    restore-keys: |
      nuget-${{ runner.os }}-

Keep the integration test stage focused. Do not run every browser test, load test, and security scan in the same fast feedback pipeline. The pull request pipeline should answer one question quickly: did this change break the API contract, persistence behavior, authorization model, or external boundary handling?

For a large ASP.NET Core API, the practical CI target is not “run every possible test.” It is “run the tests that catch expensive mistakes early.” With shared factories, isolated containers, deterministic authentication, controlled external services, and reliable reset logic, integration tests become a normal engineering tool instead of a slow release gate.


7 Integrating and Testing GenAI Components within the API

GenAI features change the shape of integration testing because the API now depends on systems that are probabilistic, rate-limited, latency-sensitive, and often expensive to call. The API contract still needs to be deterministic, even when the model output is not. For production teams, the goal is not to test whether the model is “smart.” The goal is to test whether the API handles prompts, retrieved context, structured responses, provider failures, token limits, and downstream persistence correctly.

7.1 The AI Integration Dilemma in Integration Testing

7.1.1 Deterministic Code Meeting Non-Deterministic LLM Outputs

A normal API test can assert exact values. GenAI endpoints are different because the same prompt may produce slightly different wording. That makes brittle assertions a real problem.

Avoid this style:

Assert.Equal("This product is recommended because it has excellent battery life.", result.Reason);

A better test verifies the stable contract:

Assert.NotNull(result);
Assert.NotEmpty(result.Recommendations);
Assert.All(result.Recommendations, item =>
{
    Assert.False(string.IsNullOrWhiteSpace(item.ProductId));
    Assert.InRange(item.Score, 0.0, 1.0);
});

For AI endpoints, the API should convert model output into a clear DTO before returning it to clients.

public sealed record ProductRecommendationResponse(
    IReadOnlyList<RecommendedProduct> Recommendations,
    string ModelTraceId);

public sealed record RecommendedProduct(
    string ProductId,
    double Score,
    string Reason);

The integration test should assert schema, boundaries, safety behavior, fallback behavior, and persistence. Do not assert every sentence unless the output is mocked and intentionally fixed.

7.1.2 To Mock or to Integrate: Defining Boundaries for Semantic Kernel, LangChain, or Direct OpenAI Clients

The clean boundary is usually the application service that calls the model provider. Whether the implementation uses Semantic Kernel, LangChain, Azure OpenAI, or a direct OpenAI client, the API should depend on an interface.

public interface IProductRecommendationService
{
    Task<ProductRecommendationResponse> RecommendAsync(
        string customerId,
        string query,
        CancellationToken cancellationToken);
}

For most integration tests, mock the provider boundary and test your API behavior. For a smaller number of nightly or pre-release tests, call the real provider in a controlled environment.

builder.ConfigureTestServices(services =>
{
    services.RemoveAll<IProductRecommendationService>();
    services.AddScoped<IProductRecommendationService, StubRecommendationService>();
});

A stub can return deterministic content:

public sealed class StubRecommendationService : IProductRecommendationService
{
    public Task<ProductRecommendationResponse> RecommendAsync(
        string customerId,
        string query,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(new ProductRecommendationResponse(
            new[]
            {
                new RecommendedProduct(
                    ProductId: "SKU-KEYBOARD-001",
                    Score: 0.91,
                    Reason: "Matches ergonomic keyboard search intent.")
            },
            ModelTraceId: "test-trace-001"));
    }
}

This keeps the API test fast and repeatable while still validating routing, authentication, request validation, serialization, and response shape.

7.2 Mocking LLMs and External AI Providers

7.2.1 Using WireMock.Net to Mock Structured JSON Outputs, Streaming Responses, and Chat Completion Endpoints

When the AI provider is accessed through HttpClient, WireMock.Net can simulate the provider endpoint. This is useful when you want to test your real AI client code without making a live model call.

_aiServer
    .Given(Request.Create()
        .WithPath("/v1/chat/completions")
        .UsingPost())
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithHeader("Content-Type", "application/json")
        .WithBodyAsJson(new
        {
            id = "chatcmpl-test-001",
            choices = new[]
            {
                new
                {
                    message = new
                    {
                        role = "assistant",
                        content = """
                        {
                          "recommendations": [
                            {
                              "productId": "SKU-MOUSE-001",
                              "score": 0.88,
                              "reason": "Good match for wireless mouse search."
                            }
                          ]
                        }
                        """
                    }
                }
            }
        }));

Then the API test calls the real endpoint:

[Fact]
public async Task Recommend_products_returns_structured_recommendations()
{
    var response = await _client.PostAsJsonAsync("/api/recommendations", new
    {
        customerId = "CUST-5001",
        query = "wireless mouse for travel"
    });

    response.EnsureSuccessStatusCode();

    var body = await response.Content
        .ReadFromJsonAsync<ProductRecommendationResponse>();

    Assert.NotNull(body);
    Assert.Single(body!.Recommendations);
    Assert.Equal("SKU-MOUSE-001", body.Recommendations[0].ProductId);
}

For streaming endpoints, keep the test focused on whether the API can consume chunks and produce a valid final result. Do not make the test depend on token-by-token formatting unless the stream protocol itself is the behavior under test.

7.2.2 Simulating Token Limits, API Throttling, and Transient Timeouts

AI failures are often different from normal REST failures. The provider may reject a request because the prompt is too large, return 429 Too Many Requests, or time out while generating a response. These conditions should be tested because they directly affect user experience and cost control.

_aiServer
    .Given(Request.Create()
        .WithPath("/v1/chat/completions")
        .UsingPost())
    .RespondWith(Response.Create()
        .WithStatusCode(429)
        .WithHeader("Retry-After", "2")
        .WithBodyAsJson(new
        {
            error = new
            {
                type = "rate_limit_error",
                message = "Too many requests."
            }
        }));

The API should convert this into a predictable client response:

[Fact]
public async Task Recommend_products_returns_503_when_ai_provider_is_rate_limited()
{
    var response = await _client.PostAsJsonAsync("/api/recommendations", new
    {
        customerId = "CUST-5002",
        query = "best laptop under budget"
    });

    Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}

For token limits, test the guardrail before the model call. If the prompt is too large, the API should reject or summarize before sending it to the provider.

if (prompt.EstimatedTokens > _options.MaxPromptTokens)
{
    throw new PromptTooLargeException(
        "The recommendation request contains too much context.");
}

That behavior is cheaper and more reliable than discovering the problem after the provider rejects the request.

7.3 Testing RAG Pipelines and Vector Databases via Testcontainers

7.3.1 Spinning Up Vector Databases Alongside the Relational Database

RAG integration tests should include the retrieval layer. If the API uses pgvector, Qdrant, Milvus, or another vector store, the test should seed a small, known corpus and verify retrieval behavior against it.

A pgvector-style container setup can reuse the PostgreSQL pattern already used for application data, but with the vector extension enabled in the image or migration.

await db.Database.ExecuteSqlRawAsync(
    "CREATE EXTENSION IF NOT EXISTS vector;");

For Qdrant, a generic container can be started as part of the fixture:

private readonly IContainer _qdrant = new ContainerBuilder()
    .WithImage("qdrant/qdrant:latest")
    .WithPortBinding(6333, true)
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilHttpRequestIsSucceeded(r => r.ForPort(6333)))
    .Build();

public string QdrantUrl =>
    $"http://localhost:{_qdrant.GetMappedPublicPort(6333)}";

Pass the URL into the API factory through runtime configuration.

config.AddInMemoryCollection(new Dictionary<string, string?>
{
    ["VectorSearch:BaseUrl"] = _ragFixture.QdrantUrl
});

7.3.2 Deterministic Integration Tests for Semantic Search Endpoints

Do not seed random embeddings when testing semantic search. Use small fixed vectors where the expected ranking is clear.

await vectorStore.UpsertAsync(new[]
{
    new ProductVector("SKU-CHAIR-001", new[] { 0.90f, 0.10f, 0.05f }),
    new ProductVector("SKU-DESK-001",  new[] { 0.85f, 0.12f, 0.07f }),
    new ProductVector("SKU-LAMP-001",  new[] { 0.10f, 0.88f, 0.20f })
});

Then query with a nearby vector:

[Fact]
public async Task Smart_search_returns_relevant_products_in_expected_range()
{
    var response = await _client.PostAsJsonAsync("/api/products/smart-search", new
    {
        query = "ergonomic office chair",
        testEmbedding = new[] { 0.91f, 0.09f, 0.04f }
    });

    response.EnsureSuccessStatusCode();

    var result = await response.Content
        .ReadFromJsonAsync<SmartSearchResponse>();

    Assert.NotNull(result);
    Assert.Equal("SKU-CHAIR-001", result!.Results.First().ProductId);
    Assert.True(result.Results.First().Score >= 0.80);
}

The point is not to prove the embedding model is perfect. The point is to prove that the API retrieves context, ranks results, applies thresholds, and returns a stable contract.


8 Pragmatic Implementation: A Real-World E-Commerce and AI-RAG Case Study

8.1 The Reference Architecture Breakdown

8.1.1 Order Processing with AI Smart Search and Recommendation

A practical reference API has two connected capabilities. The order module handles normal transactional behavior: create order, reserve inventory, call delivery provider, persist order state, and return a stable response. The AI module supports product discovery: receive a search query, retrieve candidate products from the vector store, optionally enrich the prompt with catalog metadata, call the model provider, and return structured recommendations.

/api/orders
/api/orders/{id}
/api/products/smart-search
/api/recommendations

The integration suite should treat these as connected but independently testable slices. Order tests should not depend on live AI. AI tests should not depend on real payment or delivery services.

8.2 Deep Dive Into the Code Base

8.2.1 Custom Factory, Container Orchestration, and Reset Wiring

A practical test fixture brings the pieces together.

public sealed class EcommerceApiFixture : IAsyncLifetime
{
    public PostgreSqlContainer Postgres { get; } =
        new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")
            .WithDatabase("ecommerce_tests")
            .WithUsername("postgres")
            .WithPassword("postgres")
            .Build();

    public WireMockServer AiProvider { get; private set; } = default!;
    public WireMockServer DeliveryApi { get; private set; } = default!;
    public AppWebApplicationFactory Factory { get; private set; } = default!;
    public HttpClient Client { get; private set; } = default!;

    public async Task InitializeAsync()
    {
        await Postgres.StartAsync();

        AiProvider = WireMockServer.Start();
        DeliveryApi = WireMockServer.Start();

        Factory = new AppWebApplicationFactory(
            Postgres.GetConnectionString(),
            AiProvider.Url!,
            DeliveryApi.Url!);

        Client = Factory.CreateClient();

        await Factory.ApplyMigrationsAsync();
        await Factory.InitializeRespawnAsync();
    }

    public async Task ResetAsync()
    {
        await Factory.ResetDatabaseAsync();
        AiProvider.ResetMappings();
        DeliveryApi.ResetMappings();
    }

    public async Task DisposeAsync()
    {
        Client.Dispose();
        AiProvider.Dispose();
        DeliveryApi.Dispose();
        await Factory.DisposeAsync();
        await Postgres.DisposeAsync();
    }
}

The fixture is intentionally boring. That is a good sign. Complex test infrastructure usually becomes its own source of bugs.

8.3 Complete Integration Test Scenarios

8.3.1 Scenario A: Posting a New Order

This test validates the transactional path while keeping the delivery provider controlled.

[Fact]
public async Task Create_order_persists_order_and_requests_delivery()
{
    await _fixture.ResetAsync();

    _fixture.DeliveryApi
        .Given(Request.Create().WithPath("/shipments").UsingPost())
        .RespondWith(Response.Create()
            .WithStatusCode(201)
            .WithBodyAsJson(new
            {
                shipmentId = "SHIP-7001",
                status = "Created"
            }));

    var response = await _fixture.Client.PostAsJsonAsync("/api/orders", new
    {
        customerId = "CUST-7001",
        sku = "SKU-CHAIR-001",
        quantity = 1
    });

    Assert.Equal(HttpStatusCode.Created, response.StatusCode);

    var order = await _fixture.FindOrderByCustomerAsync("CUST-7001");

    Assert.NotNull(order);
    Assert.Equal("SHIP-7001", order!.ShipmentId);
}

The test proves that the API accepted the request, persisted the order, called the delivery boundary, and stored the returned shipment reference.

8.3.2 Scenario B: Testing AI-Powered Product Recommendation

This test validates the AI-RAG boundary with fixed provider output.

[Fact]
public async Task Recommendation_endpoint_returns_ranked_products()
{
    await _fixture.ResetAsync();
    await _fixture.SeedProductVectorsAsync();

    _fixture.AiProvider
        .Given(Request.Create()
            .WithPath("/v1/chat/completions")
            .UsingPost())
        .RespondWith(Response.Create()
            .WithStatusCode(200)
            .WithBodyAsJson(new
            {
                choices = new[]
                {
                    new
                    {
                        message = new
                        {
                            content = """
                            {
                              "recommendations": [
                                {
                                  "productId": "SKU-CHAIR-001",
                                  "score": 0.93,
                                  "reason": "Best match for ergonomic seating."
                                }
                              ]
                            }
                            """
                        }
                    }
                }
            }));

    var response = await _fixture.Client.PostAsJsonAsync("/api/recommendations", new
    {
        customerId = "CUST-8001",
        query = "comfortable chair for long work hours"
    });

    response.EnsureSuccessStatusCode();

    var result = await response.Content
        .ReadFromJsonAsync<ProductRecommendationResponse>();

    Assert.NotNull(result);
    Assert.Equal("SKU-CHAIR-001", result!.Recommendations[0].ProductId);
}

Together, these scenarios show the practical shape of modern ASP.NET Core API integration testing: real HTTP pipeline, real persistence, controlled external services, deterministic AI behavior, and isolated infrastructure that can run locally or in CI.

Advertisement