Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory
We spin up an ASP.NET Core Web API in memory with WebApplicationFactory and test it at the HTTP level: public partial class Program, swapping the database for in-memory SQLite, Location and ProblemDetails tests, and test isolation.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
In the previous post we tested services and controllers one by one, with mocks. None of those tests ever saw an HTTP request. In this post we will spin up the whole app in memory and send it real HTTP requests. Our tool is WebApplicationFactory<TEntryPoint> from the Microsoft.AspNetCore.Mvc.Testing package.
Guess: you have a Program.cs with top-level statements, created with .NET 6. In the test project you write WebApplicationFactory<Program>. Does the project compile? Does the same code compile on .NET 10? The two questions have different answers.
What Unit Tests Can't See
In the unit tests we created the controller with new and called the action directly. Think about everything we skipped along the way: does the route template really match /api/products/{id:int}, is the JSON body bound to CreateProductRequest, does [ApiController] really return 400 for an invalid model, is the response serialized in camelCase, are the DI registrations complete. None of this is in your code; all of it is in the pipeline. And a good share of bugs show up exactly at these seams.
WebApplicationFactory runs your Program.cs as it is, but uses an in-memory TestServer instead of Kestrel. No network, no port; the HttpClient you get from CreateClient() hands requests straight to the pipeline. Middleware, routing, model binding, filters and serialization are all real. With a theater analogy: a unit test is a rehearsal with a single actor, an integration test is the dress rehearsal with costumes, lights and scenery. There is no audience, but everything on stage is real.
The API we will test is a slimmed-down version of the setup from the EF Core post: an AppDbContext using SQLite and a ProductsController that uses it directly.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Api.Data;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _db;
public ProductsController(AppDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<List<ProductDto>> GetAll(CancellationToken cancellationToken)
{
return await _db.Products
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(cancellationToken);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
{
var product = await _db.Products.FindAsync(new object[] { id }, cancellationToken);
if (product is null)
{
return NotFound();
}
return new ProductDto(product.Id, product.Name, product.Price);
}
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken cancellationToken)
{
var product = new Product { Name = request.Name, Price = request.Price };
_db.Products.Add(product);
await _db.SaveChangesAsync(cancellationToken);
var dto = new ProductDto(product.Id, product.Name, product.Price);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, dto);
}
}
public record ProductDto(int Id, string Name, decimal Price);
public class CreateProductRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
}
Opening Program Up to Tests
The factory's generic parameter is used to find the app's entry point; the Program class in Program.cs is exactly that. But the Program class the compiler generates from top-level statements is internal. Since the test project is a different assembly, it can't access it. On net6.0 the build stops like this:
CatalogApiFactory.cs(11,56): error CS0122: 'Program' is inaccessible due to its protection level
The fix is adding a single declaration to the bottom of Program.cs:
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Api.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
app.MapControllers();
app.Run();
// So the test project can write WebApplicationFactory<Program>
public partial class Program
{
}
Because it is partial, it merges with the class the compiler generates and makes it public. The second half of the guess: when we deleted that line and built for net10.0, there was no error, because in .NET 10 a source generator produces this declaration itself. Keeping the line does no harm on .NET 10; it compiles on both targets. As an alternative you can grant the test assembly access with InternalsVisibleTo in the API project, but the one-line partial class holds fewer surprises.
Setting Up the Test Project
One package is added to the ones from the unit test project. The version of Microsoft.AspNetCore.Mvc.Testing should follow the ASP.NET Core version: 6.0.36 for net6.0, 10.0.14 for net10.0.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.36" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ProductCatalog.Api\ProductCatalog.Api.csproj" />
</ItemGroup>
</Project>
The EF Core and SQLite packages come transitively from the API project; you don't need to add them to the test project separately. During the build, the Mvc.Testing package copies the API's .deps.json file into the test folder; that is how the factory finds the app's dependencies and its content root.
A First Try: Hitting the Real Database
In its simplest form a test starts like this:
public class PlainFactoryTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public PlainFactoryTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetAll_ReturnsOk()
{
var response = await _client.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
}
}
This test is red. The response was a 500, and the body said SQLite Error 1: 'no such table: Products'. On top of that, an empty catalog.db file showed up in the test project's bin folder. Here is what happened: the factory ran Program.cs as it is, and Program.cs read Data Source=catalog.db from appsettings.json. That is both the strength and the weakness of integration tests: everything is real, the database included. And you never want tests writing to or reading from your development database.
Swapping the Database
We write our own factory and change the DI registration for the tests:
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ProductCatalog.Api.Data;
namespace ProductCatalog.Api.IntegrationTests;
public class CatalogApiFactory : WebApplicationFactory<Program>
{
// The in-memory database lives as long as the connection stays open
private readonly SqliteConnection _connection = new("Data Source=:memory:");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
// Find and remove the registration made in Program.cs
var descriptor = services.Single(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
_connection.Open();
services.AddDbContext<AppDbContext>(options => options.UseSqlite(_connection));
});
}
protected override IHost CreateHost(IHostBuilder builder)
{
var host = base.CreateHost(builder);
// Create the schema and load the seed data
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
db.Products.AddRange(
new Product { Name = "Mechanical Keyboard", Price = 1450m },
new Product { Name = "Wireless Mouse", Price = 650m });
db.SaveChanges();
return host;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_connection.Dispose();
}
}
Three overrides, three jobs. ConfigureWebHost runs after the registrations in Program.cs; here we find the DbContextOptions<AppDbContext> registration added by AddDbContext, remove it, and put in-memory SQLite in its place. With UseEnvironment("Testing") we tell the app which environment it runs in; the factory's default is Development.
SQLite's :memory: database is tied to the connection: when the connection closes, the database disappears too. That is why we keep a single connection open and hand it to every DbContext. CreateHost builds the schema with EnsureCreated right after the host is created and adds two products. Dispose closes the connection together with the factory. Why not EF Core's InMemory provider? Because SQLite is a real relational database: a query that can't be translated to SQL or a unique index violation fails here too. The InMemory provider, since it generates no SQL, lets errors like these slip through silently.
Tests at the HTTP Level
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Controllers;
using Xunit;
namespace ProductCatalog.Api.IntegrationTests;
public class ProductsApiTests : IClassFixture<CatalogApiFactory>
{
private readonly HttpClient _client;
public ProductsApiTests(CatalogApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetAll_ReturnsSeededProducts()
{
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
Assert.NotNull(products);
Assert.Contains(products, p => p.Name == "Mechanical Keyboard");
}
[Fact]
public async Task GetById_UnknownId_ReturnsProblemDetails()
{
var response = await _client.GetAsync("/api/products/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task Create_ValidProduct_ReturnsCreatedWithLocation()
{
var response = await _client.PostAsJsonAsync("/api/products", new { name = "USB-C Hub", price = 899 });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
// Does the address in the Location header really work?
var created = await _client.GetFromJsonAsync<ProductDto>(response.Headers.Location);
Assert.Equal("USB-C Hub", created?.Name);
}
[Fact]
public async Task Create_ZeroPrice_ReturnsValidationProblem()
{
var response = await _client.PostAsJsonAsync("/api/products", new { name = "Free Sticker", price = 0 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.NotNull(problem);
Assert.True(problem.Errors.ContainsKey("Price"));
}
}
Each of these tests verifies something a unit test can't see. The second one, that NotFound() turns into a ProblemDetails of type application/problem+json thanks to [ApiController]. The third one, the real address produced by CreatedAtAction, whose ActionName was all we looked at in the unit test; in our run the Location value was http://localhost/api/Products/3, and a GET to that address returned the new product. The fourth one, that validation attributes, model binding and the automatic 400 work together; the errors dictionary in the body has a Price key.
We deliberately send the body as an anonymous object with camelCase properties. That is what a client really sends; had we used CreateProductRequest directly, we could miss serialization mismatches.
dotnet test
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 476 ms - ProductCatalog.Api.IntegrationTests.dll (net6.0)
Four tests finished in half a second. Slow compared to the 40 milliseconds of the unit tests, but very fast compared to tests against a real server and database. The same tests passed on net10.0 as well.
Keeping Tests from Affecting Each Other
IClassFixture<CatalogApiFactory> makes the factory get created once for all the tests in that class. Not starting the app from scratch for every test is a big speed win, but it has a price: the database is shared too. When the Create_ValidProduct test adds a product, that product is still there in the later tests of the same class.
That is why the GetAll test doesn't say "there are exactly two products"; it says "Mechanical Keyboard is in the list". If the test order changed or a new test added another product, the first version would break, the second won't. For the same reason we didn't assert that the new product's id is 3 in the third test either; that 3 was merely a result of the test order in this particular run.
If you need stricter isolation, you have options: every test class already gets its own factory, and therefore its own database; if a single test must start clean, you can move it into a separate class or clear the tables before the test. Spinning up a real SQL Server or PostgreSQL container on every run with Testcontainers is also common; we cover that in detail in the EF Core series.
Common Pitfalls
A version difference when swapping providers. The "find the descriptor, remove it, add a new one" approach in this post worked on both targets because we stayed on the same provider. But when we tried switching from SQLite to EF Core's InMemory provider, the tests passed on net6.0, while on net10.0 all four failed with this error:
System.InvalidOperationException : Services for database providers 'Microsoft.EntityFrameworkCore.Sqlite', 'Microsoft.EntityFrameworkCore.InMemory' have been registered in the service provider. Only a single database provider can be registered in a service provider.
In newer EF Core versions, AddDbContext doesn't keep the options in a single registration; the UseSqlite call from Program.cs lives on through another registration too. We will look at this difference and the fix in newer versions in the version posts.
Asserting on message text. On this machine the validation error text was The field Price must be between 0,01 and 1000000.; the decimal separator is a comma because the tests ran on a machine with Turkish culture. The same test sees 0.01 on a CI server with invariant culture. Assert on the status code and the errors key, not on the text of the error message.
Forgetting the environment. The factory's default environment is Development, which means the developer exception page is on and everything specific to Development runs; that is why we saw a stack trace in the body of the 500 in the first try. If you want to test production behavior, set the environment explicitly with UseEnvironment and account for the effect of the IsDevelopment() blocks in Program.cs on your tests.
Confusing EnsureCreated with migrations. In the tests we build the schema straight from the model; that is fast, but it doesn't test whether your migrations are correct. Verify migrations in a separate step by applying them to a real database.
A Small Experiment
★☆☆ Add a line to the GetById_UnknownId test that reads the body as ProblemDetails and checks that its Status is 404.
★★☆ Write a test that sends a 101-character name for CreateProductRequest.Name and expects a Name key in errors. Then delete [StringLength(100)] and watch the test break; think about why unit tests couldn't catch this.
★★★ Write a second test class and assert that GetAll returns exactly two products; see that it isn't affected by the Create test because it gets its own factory. Then make both classes share the same factory with ICollectionFixture, and examine why that assertion now depends on the order.
Our tests now verify both the pieces and the whole. Next up is packaging this verified app so it runs the same way in every environment, and running the tests automatically on every push. In the next post we put the app into a container with a multi-stage Dockerfile and set up CI/CD with GitHub Actions.