ASP.NET Core Web API · Quality & Delivery

Integration Testing

Unit tests cannot see routing, model validation, filters, JSON serialization or authentication. Integration tests run the real app in memory with WebApplicationFactory and send real HTTP requests — catching the bugs that only show up when everything is wired together.

Advanced 7 min Lesson 31 of 37Examples target .NET 9.
Before you start:Unit Testing
0/37 in ASP.NET Core Web API
On this page · 7 min · Advanced

In simple words#

Integration tests open the restaurant for a trial run: real routing, real middleware and a test database work together.

WebApplicationFactory
Hosts the API in memory for tests.
HttpClient
Calls the in-memory API.
Test database
Testcontainers or a dedicated DB.

Learning objectives#

  • Create a WebApplicationFactory-based test fixture
  • Replace the database with a test database
  • Test validation, status codes and JSON end to end
  • Share a configured factory across tests

Explanation#

Add Microsoft.AspNetCore.Mvc.Testing to the test project and reference the API project. Make Program visible with public partial class Program; at the end of Program.cs.

WebApplicationFactory<Program> boots the whole app with an in-memory TestServer; factory.CreateClient() returns an HttpClient that talks to it — no ports, no deployment.

Override services with WithWebHostBuilder / ConfigureTestServices: remove the real DbContext registration and add one for an InMemory, SQLite or Testcontainers SQL Server database, plus fake external services.

Write tests the way clients use the API: POST JSON, assert 201 and the Location, GET it back, assert 400 problem details for bad input, assert 401 for missing tokens.

Use IClassFixture<T> to share one factory per test class, and give each test its own data (unique names or a fresh database name) so tests do not depend on each other.

Syntax#

C# Illustrative
public class ProductsApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Create_then_get()
    {
        var client = factory.CreateClient();
        var response = await client.PostAsJsonAsync("/api/products", new { name = "Mouse", price = 799 });
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }
}

Code example: The real pipeline, an in-memory database#

C#ShopSmart.Api.Tests/ProductsApiTests.cs Illustrative
// Program.cs
// (in the API project)
using Microsoft.EntityFrameworkCore;


var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<CatalogDb>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Catalog")));
var app = builder.Build();
app.MapControllers();
app.Run();


public partial class Program;                                        // lets the test project see Program


// Data/CatalogDb.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;


public class CatalogItem
{
    public int Id { get; set; }
    [MaxLength(100)] public string Name { get; set; } = "";
    public decimal Price { get; set; }
}


public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
    public DbSet<CatalogItem> Items => Set<CatalogItem>();
}


// Controllers/ProductsController.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;


public record CreateItemDto([Required, StringLength(100)] string Name, [Range(1, 1_000_000)] decimal Price);


[ApiController]
[Route("api/products")]
public class ProductsController(CatalogDb db) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<CatalogItem>> Get(int id) => await db.Items.FindAsync(id) is { } item ? item : NotFound();


    [HttpPost]
    public async Task<ActionResult<CatalogItem>> Create(CreateItemDto dto)
    {
        var item = new CatalogItem { Name = dto.Name, Price = dto.Price };
        db.Items.Add(item);
        await db.SaveChangesAsync();
        return CreatedAtAction(nameof(Get), new { id = item.Id }, item);
    }
}


// Tests/ApiFactory.cs
// (in the test project, which references the API project)
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;


public class ApiFactory : WebApplicationFactory<Program>
{
    private readonly string _databaseName = 
quot;tests-
{Guid.NewGuid()}";
protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureTestServices(services => { // Drop the SQL Server registration. EF Core 9 stores the UseSqlServer call in // IDbContextOptionsConfiguration<T>, so remove that too or both providers end up registered. services.RemoveAll<DbContextOptions<CatalogDb>>(); services.RemoveAll<IDbContextOptionsConfiguration<CatalogDb>>(); services.AddDbContext<CatalogDb>(o => o.UseInMemoryDatabase(_databaseName)); }); } // Tests/ProductsApiTests.cs using System.Net; using System.Net.Http.Json; using System.Text.Json; using Xunit; public class ProductsApiTests(ApiFactory factory) : IClassFixture<ApiFactory> { [Fact] public async Task Post_then_get_returns_the_same_product() { var client = factory.CreateClient(); var created = await client.PostAsJsonAsync("/api/products", new { name = "Wireless Mouse", price = 799 }); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var item = await client.GetFromJsonAsync<JsonElement>(created.Headers.Location); Assert.Equal("Wireless Mouse", item.GetProperty("name").GetString()); } [Fact] public async Task Post_with_invalid_body_returns_validation_problem() { var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/products", new { name = "", price = 0 }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); var errors = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("errors"); Assert.True(errors.TryGetProperty("Name", out _)); Assert.True(errors.TryGetProperty("Price", out _)); } [Fact] public async Task Get_unknown_id_returns_404() { var response = await factory.CreateClient().GetAsync("/api/products/12345"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } }
Expected output
dotnet test runs the 3 tests against the real ASP.NET Core pipeline (routing, [ApiController] validation, JSON, EF Core with an in-memory database): Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. public partial class Program makes the top-level Program type visible to WebApplicationFactory.
  2. ApiFactory starts the real app but swaps the SQL Server DbContext for an InMemory database with a unique name.
  3. The first test exercises routing, JSON binding, EF Core, CreatedAtAction and a GET through the Location header.
  4. The second proves [ApiController] validation returns problem details with errors for Name and Price — something a unit test cannot see.
  5. IClassFixture shares one factory (and server) across the tests in the class.

Real-world use case#

ShopSmart runs about 300 integration tests on every pull request in under a minute. A Testcontainers-based suite with a real SQL Server runs nightly to catch provider-specific issues the InMemory provider would miss.

Common mistakes#

Forgetting public partial class Program

With top-level statements Program is internal and the test project cannot use WebApplicationFactory<Program>.

Only removing DbContextOptions<T> (EF Core 9)

EF Core 9 also registers IDbContextOptionsConfiguration<T>, which re-applies UseSqlServer. Remove both, or every request fails with “services for database providers SqlServer, InMemory have been registered”.

Tests sharing data

Tests that depend on what another test inserted fail randomly when the order changes. Isolate data per test or per class.

Trusting InMemory for SQL behaviour

The InMemory provider ignores constraints and SQL translation. Use SQLite or Testcontainers SQL Server for database-specific tests.

Best practices#

  • WebApplicationFactory + ConfigureTestServices
  • Test like a client: HTTP in, JSON out
  • Isolated test data
  • Real database containers for provider-specific tests

Remember this#

WebApplicationFactory<Program> = the real app, in memory.

ConfigureTestServices swaps the database and fakes.

Integration tests catch routing, validation and JSON bugs.

Practice exercise#

Your task: Add a test that posts a product with a 101-character name and asserts a 400 response with an error for Name.

Need a hint?
new string('x', 101) for the name.
Show solution
C# Illustrative
// Tests/ProductsValidationTests.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;


public class ProductsValidationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Name_longer_than_100_characters_is_rejected()
    {
        var response = await factory.CreateClient()
            .PostAsJsonAsync("/api/products", new { name = new string('x', 101), price = 10 });


        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var errors = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("errors");
        Assert.True(errors.TryGetProperty("Name", out _));
    }
}

Mini quiz#

Integration Testing quizMultiple choice Question 1 of 3

What does WebApplicationFactory<Program> provide?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.