Integration tests for ASP.NET Core APIs — WebApplicationFactory, xUnit, ConfigureTestServices, FluentAssertions, database isolation
97
96%
Does it follow best practices?
Impact
99%
1.45xAverage score across 5 eval scenarios
Passed
No known issues
Integration tests for ASP.NET Core APIs. Uses xUnit as the test framework, WebApplicationFactory for in-process hosting, and FluentAssertions for readable assertions.
Always use xUnit for ASP.NET Core tests. Create a test project in a tests/ folder:
dotnet new xunit -o tests/MyApp.Tests
dotnet sln add tests/MyApp.Tests
dotnet add tests/MyApp.Tests reference src/MyApp
dotnet add tests/MyApp.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add tests/MyApp.Tests package Microsoft.EntityFrameworkCore.InMemory
dotnet add tests/MyApp.Tests package FluentAssertionsRun tests with:
dotnet testWebApplicationFactory needs access to the Program class. Add this to the API project (not the test project):
// src/MyApp/Properties/InternalsVisibleTo.cs
// Or add at the bottom of Program.cs:
// Make Program visible to test project
public partial class Program { }This line is required — without it, WebApplicationFactory<Program> cannot find the entry point.
Create a shared factory that replaces the database and configures test services. This is the recommended pattern — a custom subclass in the test project:
// tests/MyApp.Tests/CustomWebApplicationFactory.cs
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove the real database registration
var descriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Add in-memory database with unique name per test class
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb_" + Guid.NewGuid()));
});
builder.UseEnvironment("Testing");
}
}Key details:
ConfigureTestServices (not ConfigureServices) — it runs after the app's own service registration, guaranteeing your overrides win.UseInMemoryDatabase with Guid.NewGuid() gives each test class its own isolated database.UseEnvironment("Testing") prevents production middleware (HTTPS redirection, etc.) from interfering.Use IClassFixture<T> to share the factory across all tests in a class. xUnit creates the factory once and injects it:
// tests/MyApp.Tests/ApiTests.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class ApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public ApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}Why IClassFixture: xUnit creates a new test class instance per test method. IClassFixture ensures the factory (and thus the test server) is created once and shared across all [Fact] methods in the class, avoiding expensive per-test startup.
These catch the bugs that actually happen in production. Every API test suite should start with these:
// Test 1: Happy path — main GET returns data
[Fact]
public async Task GetItems_ReturnsOkWithData()
{
var response = await _client.GetAsync("/api/items");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("data").GetArrayLength().Should().BeGreaterThan(0);
}
// Test 2: Validation — POST rejects bad input with 400
[Fact]
public async Task CreateItem_RejectsEmptyBody()
{
var response = await _client.PostAsJsonAsync("/api/items", new { });
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetProperty("message").GetString()
.Should().NotBeNullOrEmpty();
}
// Test 3: Not found — returns 404, not 500
[Fact]
public async Task GetNonexistentItem_Returns404()
{
var response = await _client.GetAsync("/api/items/99999");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// Test 4: Persistence — POST creates, then GET retrieves
[Fact]
public async Task CreateItem_PersistsAndCanBeRetrieved()
{
var payload = new { name = "Test Item", price = 9.99 };
var createResponse = await _client.PostAsJsonAsync("/api/items", payload);
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>();
var id = created.GetProperty("data").GetProperty("id").GetInt32();
var getResponse = await _client.GetAsync($"/api/items/{id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var retrieved = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
retrieved.GetProperty("data").GetProperty("name").GetString()
.Should().Be("Test Item");
}
// Test 5: Error format consistency — all errors have the same shape
[Fact]
public async Task ErrorResponses_HaveConsistentShape()
{
var badPost = await _client.PostAsJsonAsync("/api/items", new { });
var notFound = await _client.GetAsync("/api/items/99999");
foreach (var response in new[] { badPost, notFound })
{
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetProperty("message").GetString()
.Should().NotBeNull();
}
}
}Assertion patterns used:
response.StatusCode.Should().Be(HttpStatusCode.OK) — FluentAssertions for status codesReadFromJsonAsync<JsonElement>() — deserialize to JsonElement for flexible JSON navigationbody.GetProperty("data") — navigate JSON properties without strongly-typed DTOsPostAsJsonAsync — send JSON payloads (from System.Net.Http.Json)Override the authentication scheme in tests to bypass real auth:
// tests/MyApp.Tests/TestAuthHandler.cs
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[] { new Claim(ClaimTypes.Name, "TestUser"), new Claim(ClaimTypes.Role, "Admin") };
var identity = new ClaimsIdentity(claims, "TestScheme");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "TestScheme");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}Register in the factory:
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("TestScheme")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", options => { });
});services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb_" + Guid.NewGuid()));Limitation: InMemory provider does not enforce referential integrity or SQL constraints. Use SQLite for tests that need relational behavior.
dotnet add tests/MyApp.Tests package Microsoft.EntityFrameworkCore.Sqlitebuilder.ConfigureTestServices(services =>
{
var descriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("DataSource=:memory:"));
// Ensure database is created
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.OpenConnection();
db.Database.EnsureCreated();
});// In factory ConfigureWebHost, after adding DbContext:
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
db.Items.AddRange(
new Item { Name = "Espresso", Price = 3.50m },
new Item { Name = "Latte", Price = 4.50m }
);
db.SaveChanges();Replace any service for testing using ConfigureTestServices:
builder.ConfigureTestServices(services =>
{
// Replace a real email service with a fake
services.RemoveAll<IEmailService>();
services.AddSingleton<IEmailService, FakeEmailService>();
// Replace HttpClient for external API calls
services.RemoveAll<IHttpClientFactory>();
services.AddSingleton<IHttpClientFactory>(new FakeHttpClientFactory());
});# Run all tests
dotnet test
# Run with verbose output
dotnet test --verbosity normal
# Run specific test class
dotnet test --filter "FullyQualifiedName~ApiTests"
# Run with code coverage
dotnet test --collect:"XPlat Code Coverage"Project setup:
tests/ folder with dotnet new xunitMicrosoft.AspNetCore.Mvc.Testing package addedMicrosoft.EntityFrameworkCore.InMemory package addedFluentAssertions package addedpublic partial class Program { } in the API projectdotnet test passesTest infrastructure:
WebApplicationFactory<Program> subclass with ConfigureTestServicesIClassFixture<T> on test class for shared factoryGuid.NewGuid())UseEnvironment("Testing") setMinimum test coverage (the 5 tests):
error.message shapeAssertions:
.Should().Be() for all assertionsReadFromJsonAsync<JsonElement>() for response deserializationPostAsJsonAsync for sending JSON payloadsGetProperty() for JSON navigationasync Task with [Fact] attribute