40. Integration Testing
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:
- Reuse the factory (use class fixtures)
- Minimize seeding (only seed what’s needed)
- Use in-memory DB for fast tests
- Use PostgreSQL in containers for realistic tests
- 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.