Integration Testing
1. What is Integration Testing?
In software development, integration testing ensures that multiple components of an application work together correctly as a unified system.
Unlike Unit Testing (which isolates a single method or class by mocking dependencies like databases), integration testing boots up actual infrastructure elements. It tests the interaction between your code, database engines, file systems, external network HTTP clients, and the Core request handling middleware pipeline itself.
Unit Testing: [ isolated code block ] ──> Mocks / Stubs
Integration Test: [ API Endpoint Middleware ] ──> [ DB context ] ──> [ Test DB Instance ]
2. The Core Tooling: WebApplicationFactory
In Core, integration tests rely on the official Microsoft library Microsoft.AspNetCore.Mvc.Testing.
The cornerstone class of this framework is WebApplicationFactory<TEntryPoint>. It performs a brilliant architectural trick: it bootstraps your complete real-world application structure (DI Container, Configuration matrices, Routing pipelines) entirely inside an in-memory test host container. It then gives you a highly optimized HttpClient to fire actual HTTP requests against your endpoints without having to manually publish or run the server on a network port.
3. Concrete Implementation Example
Web API Project
- Create ASP.NET Core minimal web api project
public partial class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default") ?? throw new InvalidOperationException("Connection string 'AppDbContext' not found.")));
// Add services to the container.
builder.Services.AddAuthorization();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddHttpClient("jsonplaceholder", httpClient =>
{
httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseMiddleware<ApiKeyMiddleware>();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", (HttpContext httpContext) =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
})
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.MapPost("/api/users", async (User user, AppDbContext db) =>
{
if (await db.Users.AnyAsync(u => u.Username == user.Username))
return Results.BadRequest("User already exists.");
db.Users.Add(user);
await db.SaveChangesAsync();
return Results.Created($"/api/users/{user.Id}", user);
});
app.MapGet("/api/users", async (AppDbContext db) =>
{
var users = await db.Users.ToListAsync();
return Results.Ok(users);
});
app.MapGet("/api/users/{id:int}", async (int id, AppDbContext db) =>
{
var user = await db.Users.FindAsync(id);
if (user is null)
{
return Results.NotFound();
}
return Results.Ok(user);
});
// Todo
app.MapGet("api/todos", async (IHttpClientFactory httpClientFactory) =>
{
var httpClient = httpClientFactory.CreateClient("jsonplaceholder");
var todos = await httpClient.GetFromJsonAsync<IEnumerable<Todo>>("todos");
return Results.Ok(todos);
});
app.Run();
}
}
- Make program class as partial
- Add User model
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
}
- Add AppDbContext
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
}
- Update appsettings.json
"ConnectionStrings": {
"Default": "Server=(localdb)\\mssqllocaldb;Database=IntegrationTestingSampleDb;Trusted_Connection=True;MultipleActiveResultSets=true"
}
- Add packages
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
Microsoft.VisualStudio.Web.CodeGeneration.Design
- Run migrations
Add-Migration InitialCreate
Update-Database
Test Project
- Create xunit test project
- Add packages
Microsoft.AspNetCore.Mvc.Testing
Microsoft.EntityFrameworkCore.InMemory
- Add reference to web api project
Test endpoint fetching data from Db
- In Test project, create CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _dbName = $"InMemoryDb_{Guid.NewGuid()}";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 1. Remove the existing DbContext configuration
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<IDbContextOptionsConfiguration<AppDbContext>>();
// 2. Register AppDbContext using the In-Memory provider
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(_dbName);
});
// Override the primary handler for the named "jsonplaceholder" client
services.AddHttpClient("jsonplaceholder")
.ConfigurePrimaryHttpMessageHandler(() => new FakeJsonPlaceholderHandler());
});
}
}
- Create UserEndpointsTests.cs
public class UserEndpointsTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public UserEndpointsTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task PostUser_CreatesItem_AndReturnsCreated()
{
// Arrange
var newUser = new User { Username = "david", Email = "david@example.com" };
// Act
var postResponse = await _client.PostAsJsonAsync("/api/users", newUser);
// Assert HTTP response
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
// Act - Fetch the items to verify persistence in the In-Memory DB
var users = await _client.GetFromJsonAsync<List<User>>("/api/users");
// Assert Database State
Assert.NotNull(users);
Assert.Contains(users, t => t.Username == "david");
}
[Fact]
public async Task GetUserById_ReturnsNotFound_WhenItemDoesNotExist()
{
// Act
var response = await _client.GetAsync("/api/users/999");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
- Run and verify tests
Test endpoint calling external API
- Create FakeJsonPlaceholderHandler.cs
public class FakeJsonPlaceholderHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Verify the request path matches what your endpoint calls
if (request.RequestUri?.AbsolutePath == "/todos")
{
var fakeJson = """
[
{ "userId": 1, "id": 1, "title": "Mocked Todo 1", "completed": false },
{ "userId": 1, "id": 2, "title": "Mocked Todo 2", "completed": true }
]
""";
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(fakeJson, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
- Create ExternalTodosTests.cs
public class ExternalTodosTests: IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public ExternalTodosTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetTodos_ReturnsMockedTodosFromExternalApi()
{
// Act
var response = await _client.GetAsync("/api/todos");
// Assert HTTP status
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Assert deserialized payload
var todos = await response.Content.ReadFromJsonAsync<List<Todo>>();
Assert.NotNull(todos);
Assert.Equal(2, todos.Count);
Assert.Equal("Mocked Todo 1", todos[0].Title);
Assert.True(todos[1].Completed);
}
}
TestRunner
TestServer is an in-memory web server built into ASP.NET Core that hosts your application and processes HTTP requests completely in memory without opening a real network port. It lives inside the Microsoft.AspNetCore.TestHost namespace and is the engine running under the hood whenever you use WebApplicationFactory<TProgram>.
How TestServer Works
Normally, when your ASP.NET Core app runs in production or development, it uses Kestrel to listen on a TCP network port (like localhost:5000), parse raw network packets, and pass them into your application pipeline. TestServer replaces Kestrel during tests.
- No Network Sockets: It bypasses the operating system's network stack completely, meaning there are no firewall prompts, port conflicts, or network latency.
- Full Pipeline Execution: Once a request enters
TestServer, it runs through your exact same ASP.NET Core middleware, routing, authentication, model binding, dependency injection, and Minimal API endpoints as it would in production. - In-Memory
HttpClient: When you callfactory.CreateClient(),WebApplicationFactoryasksTestServerto generate a specialHttpClientwired directly toTestServer's internal message handler.
TestServer vs. WebApplicationFactory
You rarely instantiate TestServer directly anymore because WebApplicationFactory wraps it and handles bootstrapping Program.cs for you.
| Feature | TestServer (TestHost) |
WebApplicationFactory<TProgram> |
|---|---|---|
| Role | The actual in-memory HTTP host engine that executes requests. | A high-level wrapper that boots your Program.cs and manages TestServer. |
| Setup Effort | Manual: You have to manually configure IWebHostBuilder and Startup/Program routes. |
Automatic: Reads your real Program.cs, appsettings.json, and content root automatically. |
| When to Use | Testing isolated middleware components or custom IHost pipelines without a full app. |
Standard integration testing for Minimal APIs, MVC controllers, and Razor/Blazor apps. |
Key Benefits in Integration Testing
- Speed: Because there is no TCP handshake or socket overhead, hundreds of HTTP integration tests can run in just a couple of seconds.
- Parallel Execution: Multiple test classes can spin up separate
TestServerinstances simultaneously without colliding onlocalhostports. - Direct Service Access: You can inspect the running server's
IServiceProvider(factory.Server.Services) at any point during a test to check database state or resolve registered services.
(Note: Because TestServer bypasses Kestrel and real network sockets, it will not test Kestrel-specific features like TLS/HTTPS certificates, HTTP/3 negotiation, or low-level socket timeouts.)
Implementation
- Add ApiKeyMiddleware in Web Api project
// In your main project (or directly in your test project for learning)
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
public ApiKeyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-Api-Key", out var key) || key != "secret-123")
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Missing or invalid API Key.");
return;
}
await _next(context);
}
}
- Add ApiKeyMiddlewareTests.cs
public class ApiKeyMiddlewareTests
{
private static async Task<WebApplication> CreateTestAppAsync()
{
var builder = WebApplication.CreateBuilder();
// 1. Swap Kestrel for the in-memory TestServer
builder.WebHost.UseTestServer();
// 2. Register any services your middleware needs
// builder.Services.AddLogging();
var app = builder.Build();
// 3. Add your middleware and a dummy terminal endpoint
app.UseMiddleware<ApiKeyMiddleware>();
app.MapGet("/any-path", () => Results.Ok("Success"));
await app.StartAsync();
return app;
}
[Fact]
public async Task InvokeAsync_Returns401_WhenApiKeyIsMissing()
{
// Arrange
await using var app = await CreateTestAppAsync();
var client = app.GetTestClient();
// Act
var response = await client.GetAsync("/any-path");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task InvokeAsync_CallsNext_WhenApiKeyIsValid()
{
// Arrange
await using var app = await CreateTestAppAsync();
var client = app.GetTestClient();
client.DefaultRequestHeaders.Add("X-Api-Key", "secret-123");
// Act
var response = await client.GetAsync("/any-path");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("\"Success\"", await response.Content.ReadAsStringAsync());
}
}
- Run and Verify Tests
Summary
Integration testing in ASP.NET Core verifies that your application’s components—such as routing, middleware, dependency injection, serialization, and endpoints—work together properly as a unified system. Unlike unit tests that test individual methods in isolation, integration tests execute real HTTP requests against an in-memory host.
Core Building Blocks
TestServer(Microsoft.AspNetCore.TestHost): An in-memory web server that replaces Kestrel during tests. It processes full HTTP request pipelines in memory without opening real network ports, keeping tests fast and free from socket conflicts.WebApplicationFactory<TProgram>(Microsoft.AspNetCore.Mvc.Testing): A high-level wrapper aroundTestServerthat automatically bootstraps your application’sProgram.cs. It generates a pre-configuredHttpClientto send requests to your endpoints and lets you override services in the Dependency Injection container.- Direct
TestServer/WebApplication.CreateBuilder: Used when you want to spin up a stripped-down pipeline without a fullProgram.csto test isolated components like custom middleware or authentication handlers.