40. Integration Testing

Testing the complete API pipeline with WebApplicationFactory, in-memory databases, and real HTTP calls

About this chapter

Test the complete API pipeline from HTTP request to database response using WebApplicationFactory with real database calls and actual HTTP clients.

  • Unit vs integration tests: Understanding when to use each type
  • WebApplicationFactory: Creating test hosts with in-memory databases
  • In-memory database: Testing data access without external dependencies
  • HTTP client testing: Making real HTTP calls through the test host
  • Setup and teardown: Configuring test databases and fixtures
  • End-to-end scenarios: Testing complete user workflows

Learning outcomes:

  • Understand when integration tests are needed
  • Set up WebApplicationFactory for API testing
  • Configure in-memory databases for tests
  • Write tests that make real HTTP requests
  • Test complete workflows from request to database
  • Verify serialization and response formatting

40.1 Unit Tests vs Integration Tests

Unit tests (Chapter 38-39):

  • Mock external dependencies
  • Test one piece in isolation
  • Fast (milliseconds)
  • Good for catching logic errors

Integration tests:

  • Real database (or in-memory replica)
  • Real HTTP client calls
  • Test the complete pipeline
  • Slower (seconds)
  • Catch configuration issues, serialization problems, middleware behavior

When to use each:

Request comes in
    ↓
Routing (middleware) ← Integration test catches routing bugs
    ↓
Model validation (filters) ← Integration test catches validation issues
    ↓
Controller logic ← Unit test sufficient
    ↓
Repository call ← Unit test sufficient
    ↓
Database ← Integration test needed here
    ↓
Response serialization ← Integration test catches serialization bugs
    ↓
Response goes out

You need both. Unit tests catch logic errors fast. Integration tests catch pipeline issues that unit tests miss.

40.2 Setting Up WebApplicationFactory

Install dependencies:

dotnet add package Microsoft.AspNetCore.Mvc.Testing

Create a test web application factory:

// Tests/IntegrationTests/CommandsApiFactory.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using CommandAPI.Data;

public class CommandsApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Remove the production database context
            var dbDescriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

            if (dbDescriptor != null)
            {
                services.Remove(dbDescriptor);
            }

            // Add in-memory database for testing
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("IntegrationTestDb");
            });

            // Ensure database is created and seeded
            var sp = services.BuildServiceProvider();
            using (var scope = sp.CreateScope())
            {
                var scopedServices = scope.ServiceProvider;
                var db = scopedServices.GetRequiredService<AppDbContext>();

                db.Database.EnsureCreated();
                
                // Seed test data
                SeedTestData(db);
            }
        });
    }

    private static void SeedTestData(AppDbContext context)
    {
        // Clear existing data
        context.Commands.RemoveRange(context.Commands);
        context.Platforms.RemoveRange(context.Platforms);

        // Add platforms
        var platforms = new List<Platform>
        {
            new Platform { Id = 1, Name = "dotnet" },
            new Platform { Id = 2, Name = "linux" }
        };
        context.Platforms.AddRange(platforms);

        // Add commands
        var commands = new List<Command>
        {
            new Command { Id = 1, HowTo = "List files", CommandLine = "ls -la", PlatformId = 1 },
            new Command { Id = 2, HowTo = "List files", CommandLine = "dir", PlatformId = 2 },
            new Command { Id = 3, HowTo = "Create directory", CommandLine = "mkdir", PlatformId = 1 }
        };
        context.Commands.AddRange(commands);

        context.SaveChanges();
    }
}

40.3 Basic Integration Tests

Test collection:

// Tests/IntegrationTests/CommandsControllerIntegrationTests.cs
using System.Net;
using System.Net.Http.Json;
using Xunit;
using CommandAPI.Models;

public class CommandsControllerIntegrationTests : IClassFixture<CommandsApiFactory>
{
    private readonly HttpClient _client;

    public CommandsControllerIntegrationTests(CommandsApiFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetCommands_ReturnsOkWithCommands()
    {
        // Arrange
        // Data seeded in factory

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

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        
        var content = await response.Content.ReadAsAsync<List<CommandReadDto>>();
        Assert.NotEmpty(content);
        Assert.True(content.Count >= 3);
    }

    [Fact]
    public async Task GetCommandById_WithValidId_ReturnsOk()
    {
        // Arrange
        var commandId = 1;

        // Act
        var response = await _client.GetAsync($"/api/commands/{commandId}");

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        
        var content = await response.Content.ReadAsAsync<CommandReadDto>();
        Assert.NotNull(content);
        Assert.Equal("List files", content.HowTo);
    }

    [Fact]
    public async Task GetCommandById_WithInvalidId_ReturnsNotFound()
    {
        // Arrange
        var commandId = 9999;

        // Act
        var response = await _client.GetAsync($"/api/commands/{commandId}");

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

    [Fact]
    public async Task CreateCommand_WithValidData_ReturnsCreatedAtRoute()
    {
        // Arrange
        var createDto = new CommandMutateDto
        {
            HowTo = "Test command",
            CommandLine = "test --cmd",
            PlatformId = 1
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/commands", createDto);

        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);
        
        var content = await response.Content.ReadAsAsync<CommandReadDto>();
        Assert.NotNull(content);
        Assert.NotEqual(0, content.Id);
        Assert.Equal("Test command", content.HowTo);
    }

    [Fact]
    public async Task CreateCommand_WithInvalidData_ReturnsBadRequest()
    {
        // Arrange
        var createDto = new CommandMutateDto
        {
            HowTo = null,  // Required
            CommandLine = "test",
            PlatformId = 1
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/commands", createDto);

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

    [Fact]
    public async Task UpdateCommand_WithValidData_ReturnsNoContent()
    {
        // Arrange
        var commandId = 1;
        var updateDto = new CommandMutateDto
        {
            HowTo = "Updated command",
            CommandLine = "updated --cmd",
            PlatformId = 1
        };

        // Act
        var response = await _client.PutAsJsonAsync($"/api/commands/{commandId}", updateDto);

        // Assert
        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);

        // Verify the update
        var getResponse = await _client.GetAsync($"/api/commands/{commandId}");
        var content = await getResponse.Content.ReadAsAsync<CommandReadDto>();
        Assert.Equal("Updated command", content.HowTo);
    }

    [Fact]
    public async Task DeleteCommand_WithValidId_ReturnsNoContent()
    {
        // Arrange
        var commandId = 3;

        // Act
        var response = await _client.DeleteAsync($"/api/commands/{commandId}");

        // Assert
        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);

        // Verify deletion
        var getResponse = await _client.GetAsync($"/api/commands/{commandId}");
        Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
    }
}

Extension helper:

// Add this to use ReadAsAsync
// Tests/IntegrationTests/HttpContentExtensions.cs
using System.Net.Http.Json;

public static class HttpContentExtensions
{
    public static async Task<T> ReadAsAsync<T>(this HttpContent content)
    {
        var stream = await content.ReadAsStreamAsync();
        return await System.Text.Json.JsonSerializer.DeserializeAsync<T>(stream)
            ?? throw new InvalidOperationException("Failed to deserialize response");
    }
}

40.4 Testing with Different Databases

Using PostgreSQL for integration tests:

// Tests/IntegrationTests/CommandsApiPostgresFactory.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;

public class CommandsApiPostgresFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private PostgreSqlContainer _postgres;

    public async Task InitializeAsync()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:16")
            .WithDatabase("testdb")
            .WithUsername("testuser")
            .WithPassword("testpass")
            .Build();

        await _postgres.StartAsync();
    }

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

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var dbDescriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));

            if (dbDescriptor != null)
            {
                services.Remove(dbDescriptor);
            }

            var connectionString = _postgres.GetConnectionString();

            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseNpgsql(connectionString);
            });

            var sp = services.BuildServiceProvider();
            using (var scope = sp.CreateScope())
            {
                var scopedServices = scope.ServiceProvider;
                var db = scopedServices.GetRequiredService<AppDbContext>();

                db.Database.Migrate();
                SeedTestData(db);
            }
        });
    }

    private static void SeedTestData(AppDbContext context)
    {
        context.Commands.RemoveRange(context.Commands);
        context.Platforms.RemoveRange(context.Platforms);

        var platforms = new List<Platform>
        {
            new Platform { Id = 1, Name = "dotnet" }
        };
        context.Platforms.AddRange(platforms);

        var commands = new List<Command>
        {
            new Command { HowTo = "List files", CommandLine = "ls -la", PlatformId = 1 }
        };
        context.Commands.AddRange(commands);

        context.SaveChanges();
    }
}

// Test with PostgreSQL
public class CommandsControllerPostgresTests : IClassFixture<CommandsApiPostgresFactory>
{
    private readonly HttpClient _client;

    public CommandsControllerPostgresTests(CommandsApiPostgresFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetCommands_WithPostgres_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/commands");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

Install Testcontainers:

dotnet add package Testcontainers.PostgreSql

40.5 Testing with Authentication

Add JWT authentication to factory:

// Tests/IntegrationTests/CommandsApiAuthFactory.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;

public class CommandsApiAuthFactory : CommandsApiFactory
{
    public HttpClient CreateAuthenticatedClient(string userId = "test-user", string role = "User")
    {
        var client = CreateClient();

        var token = GenerateJwtToken(userId, role);
        client.DefaultRequestHeaders.Authorization = 
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);

        return client;
    }

    public HttpClient CreateAdminClient()
    {
        return CreateAuthenticatedClient(role: "Admin");
    }

    private static string GenerateJwtToken(string userId, string role)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes("this-is-a-secret-key-of-at-least-32-characters-long"));

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Role, role)
        };

        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: "test-issuer",
            audience: "test-audience",
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(60),
            signingCredentials: creds);

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

// Test with authentication
public class AuthenticatedCommandsControllerTests : IClassFixture<CommandsApiAuthFactory>
{
    private readonly CommandsApiAuthFactory _factory;

    public AuthenticatedCommandsControllerTests(CommandsApiAuthFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task DeleteCommand_WithoutAuth_ReturnsUnauthorized()
    {
        // Arrange
        var unauthenticatedClient = _factory.CreateClient();  // No token

        // Act
        var response = await unauthenticatedClient.DeleteAsync("/api/commands/1");

        // Assert
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task DeleteCommand_WithAuth_ReturnsNoContent()
    {
        // Arrange
        var client = _factory.CreateAuthenticatedClient();

        // Act
        var response = await client.DeleteAsync("/api/commands/1");

        // Assert
        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
    }

    [Fact]
    public async Task AdminOnlyEndpoint_WithoutAdminRole_ReturnsForbidden()
    {
        // Arrange
        var userClient = _factory.CreateAuthenticatedClient(role: "User");

        // Act
        var response = await userClient.DeleteAsync("/api/commands/admin/1");

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

    [Fact]
    public async Task AdminOnlyEndpoint_WithAdminRole_ReturnsNoContent()
    {
        // Arrange
        var adminClient = _factory.CreateAdminClient();

        // Act
        var response = await adminClient.DeleteAsync("/api/commands/admin/1");

        // Assert
        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
    }
}

40.6 Testing Middleware and Filters

Custom middleware:

// Middleware/RequestLoggingMiddleware.cs
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("Request: {Method} {Path}", context.Request.Method, context.Request.Path);
        await _next(context);
        _logger.LogInformation("Response: {StatusCode}", context.Response.StatusCode);
    }
}

// Program.cs
app.UseMiddleware<RequestLoggingMiddleware>();

// Test middleware
public class RequestLoggingMiddlewareTests : IClassFixture<CommandsApiFactory>
{
    private readonly HttpClient _client;

    public RequestLoggingMiddlewareTests(CommandsApiFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task RequestLoggingMiddleware_LogsRequest()
    {
        // Arrange
        var requestUri = "/api/commands";

        // Act
        var response = await _client.GetAsync(requestUri);

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        // In a real test, you'd capture logs and verify they were written
    }
}

40.7 Testing Error Handling and Global Middleware

Global exception handling:

// Middleware/ExceptionHandlingMiddleware.cs
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");

            context.Response.ContentType = "application/json";
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;

            var response = new { error = "Internal server error", detail = ex.Message };
            await context.Response.WriteAsJsonAsync(response);
        }
    }
}

// Test exception handling
public class ExceptionHandlingMiddlewareTests : IClassFixture<CommandsApiFactory>
{
    private readonly HttpClient _client;

    public ExceptionHandlingMiddlewareTests(CommandsApiFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task UnhandledException_Returns500InternalServerError()
    {
        // Arrange
        // Add an endpoint that throws
        // GET /api/commands/error would throw

        // Act
        var response = await _client.GetAsync("/api/commands/error");

        // Assert
        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);

        var content = await response.Content.ReadAsAsync<Dictionary<string, string>>();
        Assert.NotNull(content);
        Assert.True(content.ContainsKey("error"));
    }
}

40.8 Testing Response Headers and Content Types

Test response format:

[Fact]
public async Task GetCommands_ReturnsJsonContentType()
{
    // Arrange
    // Act
    var response = await _client.GetAsync("/api/commands");

    // Assert
    Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}

[Fact]
public async Task CreateCommand_ReturnsLocationHeader()
{
    // Arrange
    var createDto = new CommandMutateDto
    {
        HowTo = "Test",
        CommandLine = "test",
        PlatformId = 1
    };

    // Act
    var response = await _client.PostAsJsonAsync("/api/commands", createDto);

    // Assert
    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    Assert.NotNull(response.Headers.Location);
    Assert.True(response.Headers.Location.ToString().Contains("/api/commands/"));
}

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

    // Assert
    // Check for cache headers (depends on your implementation)
    Assert.NotNull(response.Headers.CacheControl);
}

40.9 Running Integration Tests

# Run all integration tests
dotnet test --filter "IntegrationTests"

# Run specific test class
dotnet test --filter "CommandsControllerIntegrationTests"

# Run with verbose output
dotnet test -v d --filter "AuthenticatedCommandsControllerTests"

# Run and skip unit tests (integration only)
dotnet test --filter "FullyQualifiedName~IntegrationTests"

# Watch mode
dotnet watch test

40.10 Integration Tests Best Practices

Use class fixtures for factory reuse:

// ❌ WRONG - Creates factory for every test
[Fact]
public async Task Test1()
{
    var factory = new CommandsApiFactory();
    var client = factory.CreateClient();
    // ...
}

// ✓ CORRECT - Reuses factory
public class Tests : IClassFixture<CommandsApiFactory>
{
    private readonly HttpClient _client;
    
    public Tests(CommandsApiFactory factory)
    {
        _client = factory.CreateClient();
    }
}

Keep tests isolated:

// ❌ WRONG - Tests share data
[Fact]
public async Task Test1_CreatesCommand()
{
    await _client.PostAsJsonAsync("/api/commands", dto1);
}

[Fact]
public async Task Test2_DeletesCommand()
{
    var response = await _client.DeleteAsync("/api/commands/1");
    Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

// ✓ CORRECT - Each test seeds required data
[Fact]
public async Task Test1_CreatesCommand()
{
    // Arrange: Factory creates fresh DB for this test
    var response = await _client.PostAsJsonAsync("/api/commands", dto1);
    Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}

[Fact]
public async Task Test2_DeletesCommand()
{
    // Arrange: Factory creates fresh DB, seeded with commands
    var response = await _client.DeleteAsync("/api/commands/1");
    Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

Test realistic scenarios:

[Fact]
public async Task CreateUpdateDelete_FullLifecycle()
{
    // Arrange
    var createDto = new CommandMutateDto { HowTo = "Test", CommandLine = "test", PlatformId = 1 };

    // Act 1: Create
    var createResponse = await _client.PostAsJsonAsync("/api/commands", createDto);
    Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
    var created = await createResponse.Content.ReadAsAsync<CommandReadDto>();

    // Act 2: Read
    var getResponse = await _client.GetAsync($"/api/commands/{created.Id}");
    Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
    var retrieved = await getResponse.Content.ReadAsAsync<CommandReadDto>();
    Assert.Equal(created.HowTo, retrieved.HowTo);

    // Act 3: Update
    var updateDto = new CommandMutateDto { HowTo = "Updated", CommandLine = "updated", PlatformId = 1 };
    var updateResponse = await _client.PutAsJsonAsync($"/api/commands/{created.Id}", updateDto);
    Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);

    // Act 4: Delete
    var deleteResponse = await _client.DeleteAsync($"/api/commands/{created.Id}");
    Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);

    // Act 5: Verify deletion
    var finalGetResponse = await _client.GetAsync($"/api/commands/{created.Id}");
    Assert.Equal(HttpStatusCode.NotFound, finalGetResponse.StatusCode);
}

40.11 Performance Considerations

Integration tests are slower:

Unit test:           ~1ms (mocked DB)
Integration test:    ~100-500ms (real or in-memory DB)
Integration with PostgreSQL: ~500-2000ms (container startup, DB operations)

Optimize test performance:

  1. Reuse the factory (use class fixtures)
  2. Minimize seeding (only seed what’s needed)
  3. Use in-memory DB for fast tests
  4. Use PostgreSQL in containers for realistic tests
  5. Run integration tests separately (keep fast unit tests in CI)
# Fast unit tests in CI
dotnet test --filter "UnitTests" -p:WarningLevel=0

# Slower integration tests in separate stage
dotnet test --filter "IntegrationTests" -p:WarningLevel=0

40.12 What’s Next

You now have:

  • ✓ Understanding when to use integration vs unit tests
  • ✓ WebApplicationFactory setup for test isolation
  • ✓ In-memory database testing
  • ✓ PostgreSQL with Testcontainers
  • ✓ Testing authentication and authorization
  • ✓ Testing middleware and error handling
  • ✓ Testing response headers and content types
  • ✓ Full lifecycle testing (create-read-update-delete)

Next: Testing Background Jobs—Testing Hangfire jobs with mocked context and error scenarios.