← Back to Articles
.NET 8+ ASP.NET Core xUnit Integration Testing Testcontainers Software Quality

Testing ASP.NET Core Web APIs in Production: xUnit, Testcontainers, WebApplicationFactory, and Avoiding Flaky Tests

Green CI pipelines can still hide production bugs. Learn how to build a robust, fast, and reliable testing strategy for ASP.NET Core using xUnit, Testcontainers, WebApplicationFactory, and modern .NET 8+ patterns that actually catch real-world failures.

July 2026
26 min read
Written by Engineering Team

Introduction: The "Green Pipeline" That Cost Us $15,000

We once had a CI/CD pipeline with 95% code coverage and hundreds of passing xUnit tests. Yet, a critical payment webhook failed in production, resulting in $15,000 of unprocessed orders before we caught it. The culprit? Our integration tests used SQLite UseInMemoryDatabase, which silently ignored a SQL Server-specific case-sensitivity constraint that existed in production. The unit tests mocked the database perfectly, so they passed too.

That incident taught us a harsh lesson: testing frameworks don't guarantee quality; testing strategy does. This guide moves beyond basic "Hello World" tutorials. We will cover how to build a production-grade testing architecture for ASP.NET Core using xUnit, Testcontainers, WebApplicationFactory, and modern .NET 8+ patterns that catch the bugs that actually matter.

Modern ASP.NET Core Testing Pyramid
A balanced testing strategy relies heavily on fast unit tests, supported by targeted, realistic integration tests.

The Trap of `UseInMemoryDatabase`

Microsoft's UseInMemoryDatabase is convenient for quick unit tests, but it is dangerous for integration testing. It is not a relational database. It does not enforce foreign key constraints, it handles case-sensitivity differently than SQL Server or PostgreSQL, and it cannot execute raw SQL or database-specific functions.

💡 The Golden Rule of Database Testing

If you are testing how your application interacts with a database, test against the actual database engine you use in production. Anything else is a unit test with a false sense of security.

Unit Testing: Behavior Over Implementation

Unit tests should verify what a component does, not how it does it. Over-mocking leads to brittle tests that break every time you refactor internal code. Use Moq or NSubstitute sparingly, and pair them with FluentAssertions for readable, self-documenting tests.

OrderServiceTests.cs
csharp
              public class OrderServiceTests
{
    [Fact]
    public async Task ProcessOrder_WithValidData_ShouldSaveAndPublishEvent()
    {
        // Arrange
        var order = new Order { Id = 1, Total = 100 };
        var mockRepo = new Mock<IOrderRepository>();
        var mockPublisher = new Mock<IEventPublisher>();

        var service = new OrderService(mockRepo.Object, mockPublisher.Object);

        // Act
        await service.ProcessOrderAsync(order);

        // Assert (FluentAssertions)
        mockRepo.Verify(r => r.SaveAsync(order, It.IsAny<CancellationToken>()),
            Times.Once, "The order should be saved exactly once.");

        mockPublisher.Verify(p => p.PublishAsync(
            It.Is<OrderProcessedEvent>(e => e.OrderId == 1)),
            Times.Once, "A success event must be published.");
    }
}

            

Integration Testing with Testcontainers

Testcontainers allows you to spin up real, disposable Docker containers (PostgreSQL, SQL Server, Redis, Kafka) for your tests. This provides 100% confidence that your database interactions will work in production.

PostgresTestContainer.cs
csharp
              public class PostgresTestContainer : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container =
        new PostgreSqlBuilder().Build();

    public string ConnectionString => _container.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        // Optional: Run EF Core migrations here to set up schema
    }

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

            

Performance Tip: Starting a container per test class is slow. Use xUnit's ICollectionFixture to share a single container instance across all tests in a class, and use a tool like Respawn to clean the database state between tests in milliseconds, rather than recreating the schema.

Mastering `WebApplicationFactory`

WebApplicationFactory<TEntryPoint> is the cornerstone of ASP.NET Core integration testing. It hosts your application in-memory, allowing you to test the entire middleware pipeline, routing, and dependency injection without deploying to a real server.

CustomWebApplicationFactory.cs
csharp
              public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // 1. Remove the real database context
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null) services.Remove(descriptor);

            // 2. Inject Testcontainers connection string
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(TestDatabaseFixture.ConnectionString));

            // 3. Replace external services with fakes
            services.Replace(ServiceDescriptor.Scoped<IEmailService, FakeEmailService>());

            // 4. Bypass real authentication for testing
            services.AddAuthentication("Test")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
        });
    }
}

            

Testing Authentication in Integration Tests

Testing protected endpoints requires a valid authentication context. Instead of generating real JWTs (which requires mocking the signing key and clock), inject a custom AuthenticationHandler that bypasses validation and injects a known ClaimsPrincipal.

TestAuthHandler.cs
csharp
              public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock) : base(options, logger, encoder, clock) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[] {
            new Claim(ClaimTypes.Name, "testuser"),
            new Claim(ClaimTypes.Role, "Admin")
        };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");

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

            

Testing Minimal APIs (.NET 8+)

Minimal APIs are the default for new .NET projects. Testing them is slightly different from MVC controllers, but WebApplicationFactory handles them seamlessly. You can test the endpoint's response and even inspect the returned DTO.

MinimalApiTests.cs
csharp
              public class ProductApiTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

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

    [Fact]
    public async Task GetProduct_Exists_Returns200AndProduct()
    {
        // Act
        var response = await _client.GetAsync("/api/products/1");

        // Assert
        response.EnsureSuccessStatusCode();
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var product = await response.Content.ReadFromJsonAsync<ProductDto>();
        product.Should().NotBeNull();
        product!.Id.Should().Be(1);
    }
}

            

Testing Time-Dependent Code with `TimeProvider`

Code that relies on DateTime.UtcNow is notoriously hard to test reliably. .NET 8 introduced TimeProvider, allowing you to inject and control time in your tests.

TokenServiceTests.cs
csharp
              public class TokenService
{
    private readonly TimeProvider _timeProvider;

    public TokenService(TimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
    }

    public bool IsTokenExpired(DateTime expiration)
    {
        return _timeProvider.GetUtcNow() > expiration;
    }
}

// In your test:
[Fact]
public void IsTokenExpired_FutureDate_ReturnsFalse()
{
    var fakeTime = new FakeTimeProvider();
    fakeTime.SetUtcNow(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));

    var service = new TokenService(fakeTime);
    var expiration = new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero);

    service.IsTokenExpired(expiration).Should().BeFalse();
}

            

Database Cleanup with Respawn

Running EF Core migrations or dropping/recreating the database before every test is slow. Respawn is a lightweight tool that intelligently truncates all tables in your database in milliseconds, ensuring a clean slate for each test without the overhead of schema recreation.

DatabaseCleanup.cs
csharp
              public async Task ResetDatabaseAsync()
{
    using var connection = new NpgsqlConnection(TestDatabaseFixture.ConnectionString);
    await connection.OpenAsync();

    var checkpoint = new Checkpoint
    {
        TablesToIgnore = new[] { "__EFMigrationsHistory" }, // Don't clear migrations
        SchemasToInclude = new[] { "public" }
    };

    await checkpoint.Reset(connection);
}

            

Common Testing Anti-Patterns

  • Testing private methods: Test the public API. If a private method is complex enough to need its own test, it should be extracted into its own class.

  • Over-mocking: If you have to mock 5 different dependencies to test a single method, your class violates the Single Responsibility Principle. Refactor the code, don't just add more mocks.

  • Asserting on implementation details: Don't verify that a specific private method was called. Verify the observable outcome (e.g., the database was updated, the event was published).

  • Ignoring async void: xUnit does not support async void tests. Always return Task to ensure the test runner waits for completion and catches exceptions.

  • Hardcoded test data: Use libraries like Bogus to generate realistic, randomized test data. This prevents tests from passing only because of hardcoded, predictable values.

"A test that passes for the wrong reason is more dangerous than no test at all."

Frequently Asked Questions

How do I fix "Cannot resolve scoped service from root provider" in WebApplicationFactory?
This happens when you try to resolve a scoped service (like DbContext) directly from the factory's root `Services` provider. Instead, create a scope: `using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();`
Should I test my controllers or just my services?
Test services thoroughly for business logic. Test controllers (or Minimal API endpoints) lightly via integration tests to ensure routing, model binding, and authorization filters work correctly. Do not duplicate service logic tests in the controller.
How do I test file uploads in ASP.NET Core?
Use `MultipartFormDataContent` with `StreamContent` in your `HttpClient` request. For unit tests, mock `IFormFile` using Moq, setting up the `OpenReadStream()` and `FileName` properties.
Why are my integration tests running sequentially and slowly?
xUnit runs test classes in parallel by default, but tests *within* a class run sequentially. If you are using `ICollectionFixture` for a database container, all tests in that class share the fixture and run sequentially. To speed this up, ensure your database cleanup (e.g., Respawn) is highly optimized, or split tests into multiple fixture classes if they don't share state.

Conclusion

Testing ASP.NET Core Web APIs is not about achieving an arbitrary percentage of code coverage. It is about building a safety net that catches the bugs that matter. By abandoning the UseInMemoryDatabase trap, embracing Testcontainers for realistic database testing, mastering WebApplicationFactory for end-to-end pipeline validation, and leveraging modern .NET 8 features like TimeProvider, you can ship your APIs with absolute confidence.

Want to deepen your testing expertise? Check out our guides on [Advanced Testcontainers Patterns for .NET], [Mocking Strategies That Don't Break Refactoring], and [Optimizing xUnit CI/CD Pipeline Performance] to build a world-class quality assurance process.

We use cookies to improve your experience.