CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/aspnet-testing

Integration tests for ASP.NET Core APIs — WebApplicationFactory, xUnit, ConfigureTestServices, FluentAssertions, database isolation

97

1.45x
Quality

96%

Does it follow best practices?

Impact

99%

1.45x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/aspnet-testing/

name:
aspnet-testing
description:
Integration tests for ASP.NET Core APIs using xUnit and WebApplicationFactory. Covers project setup, service replacement with ConfigureTestServices, database isolation, the 5 essential test patterns, authentication testing, and FluentAssertions. Use when a .NET API has no tests, when adding endpoints, or when a reviewer asks for test coverage.
keywords:
aspnet testing, xunit, webapplicationfactory, integration test dotnet, aspnet core test, test server, dotnet api testing, in-memory database test, fluentassertions, configuretestservices, iclassfixture
license:
MIT

ASP.NET Core API Testing with xUnit & WebApplicationFactory

Integration tests for ASP.NET Core APIs. Uses xUnit as the test framework, WebApplicationFactory for in-process hosting, and FluentAssertions for readable assertions.


1. Project Setup

Create test project and add packages

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 FluentAssertions

Run tests with:

dotnet test

Expose the Program class

WebApplicationFactory 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.


2. Custom WebApplicationFactory

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:

  • Use 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.

3. Test Class Structure with IClassFixture

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.


4. The 5 Essential Tests

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 codes
  • ReadFromJsonAsync<JsonElement>() — deserialize to JsonElement for flexible JSON navigation
  • body.GetProperty("data") — navigate JSON properties without strongly-typed DTOs
  • PostAsJsonAsync — send JSON payloads (from System.Net.Http.Json)

5. Testing Authenticated Endpoints

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 => { });
});

6. Database Isolation Strategies

Option A: EF Core InMemory Provider (simplest, good for most tests)

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.

Option B: SQLite In-Memory (better fidelity)

dotnet add tests/MyApp.Tests package Microsoft.EntityFrameworkCore.Sqlite
builder.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();
});

Option C: Seed data for tests

// 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();

7. Testing Service Overrides

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());
});

8. Running Tests

# 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"

Checklist

Project setup:

  • xUnit test project in tests/ folder with dotnet new xunit
  • Microsoft.AspNetCore.Mvc.Testing package added
  • Microsoft.EntityFrameworkCore.InMemory package added
  • FluentAssertions package added
  • public partial class Program { } in the API project
  • dotnet test passes

Test infrastructure:

  • Custom WebApplicationFactory<Program> subclass with ConfigureTestServices
  • IClassFixture<T> on test class for shared factory
  • In-memory database with unique name per test class (Guid.NewGuid())
  • UseEnvironment("Testing") set

Minimum test coverage (the 5 tests):

  • Happy path GET returns 200 with data
  • Validation POST returns 400 with error message
  • Nonexistent resource returns 404
  • POST creates and GET retrieves (persistence)
  • All error responses have consistent error.message shape

Assertions:

  • FluentAssertions .Should().Be() for all assertions
  • ReadFromJsonAsync<JsonElement>() for response deserialization
  • PostAsJsonAsync for sending JSON payloads
  • GetProperty() for JSON navigation
  • All test methods are async Task with [Fact] attribute

Verifiers

  • aspnet-tests-created — Create API tests with WebApplicationFactory and xUnit
  • aspnet-test-infrastructure — Set up test project with proper packages and factory configuration
  • aspnet-test-patterns — Use FluentAssertions, async patterns, and proper JSON assertions

skills

aspnet-testing

tile.json