Unit tests verify a single class in isolation with all dependencies mocked. Integration tests verify that multiple components work correctly together — including real databases, HTTP pipelines, and middleware.
// Unit test — PricingEngine in complete isolation:
[Fact]
public void Calculate_AppliesDiscountCorrectly()
{
var engine = new PricingEngine(); // no dependencies
var result = engine.Calculate(100m, discountPct: 10);
Assert.Equal(90m, result);
}
// Integration test — full HTTP pipeline with real EF Core (in-memory):
public class OrdersIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersIntegrationTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task PostOrder_ReturnsCreated_AndPersistsToDatabase()
{
var payload = new StringContent(
"""{"sku":"X","quantity":2}""",
Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/api/orders", payload);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// Verify via GET that it actually persisted:
var location = response.Headers.Location!;
var getResp = await _client.GetAsync(location);
getResp.EnsureSuccessStatusCode();
}
}
Integration tests are slower and harder to parallelize than unit tests but catch a different class of bugs: mismatched serialization, middleware ordering problems, EF Core mapping errors, and misconfigured DI.
Rule of thumb: Test business logic with unit tests; test cross-layer concerns (HTTP → service → DB round-trips) with integration tests. Aim for many unit tests and a smaller number of high-value integration tests.
WebApplicationFactory<TProgram> (from Microsoft.AspNetCore.Mvc.Testing)
spins up an in-process test server that runs the real ASP.NET Core pipeline.
Tests call it through an HttpClient with no networking overhead.
// Install: dotnet add package Microsoft.AspNetCore.Mvc.Testing
// Minimal setup — use the app as-is:
public class BasicIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public BasicIntegrationTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task GetHealth_Returns200()
{
var response = await _client.GetAsync("/health");
response.EnsureSuccessStatusCode();
}
}
// Custom factory — override configuration or swap services:
public class CustomWebFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Replace the real DbContext with an in-memory one:
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase("TestDb-" + Guid.NewGuid()));
// Swap a real external service with a fake:
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
builder.UseEnvironment("Testing");
}
}
// Use the custom factory:
public class OrderTests : IClassFixture<CustomWebFactory>
{
private readonly HttpClient _client;
public OrderTests(CustomWebFactory factory) => _client = factory.CreateClient();
}
Rule of thumb: Use IClassFixture<WebApplicationFactory<Program>> for
read-only tests (the factory is shared). When tests write data, use a unique
in-memory database per class to prevent interference between test runs.
The in-memory provider is fast but doesn't enforce relational constraints (FK, unique indexes) and behaves differently from real SQL. SQLite in-memory mode runs real SQL and is fast enough for most test suites.
// Option 1: EF Core InMemory — fast, but no FK enforcement or raw SQL:
services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase("TestDb"));
// Pitfall: this passes with InMemory but would fail in production:
// INSERT with a FK violation → InMemory silently succeeds
// Option 2: SQLite in-memory — real SQL, relational constraints enforced:
// dotnet add package Microsoft.EntityFrameworkCore.Sqlite
services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlite("Data Source=:memory:"));
// SQLite in-memory databases are per-connection.
// Keep the connection open for the test's lifetime:
public class SqliteTestFixture : IDisposable
{
public SqliteConnection Connection { get; }
public AppDbContext Context { get; }
public SqliteTestFixture()
{
Connection = new SqliteConnection("Data Source=:memory:");
Connection.Open();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(Connection)
.Options;
Context = new AppDbContext(opts);
Context.Database.EnsureCreated(); // create schema
}
public void Dispose() { Context.Dispose(); Connection.Close(); }
}
// Option 3: Respawn + real DB — reset a real database between tests (slowest):
// Respawn is a NuGet package that resets SQL Server/Postgres state fast
// by deleting rows in dependency order without recreating the schema.
Rule of thumb: Use SQLite in-memory for most integration tests — it's fast and realistic. Reserve a real database (via Testcontainers or Respawn) for tests that exercise database-specific features (full-text search, JSON columns, stored procedures).
Test data should be created in the test itself (or a fixture) — never rely on data already in the database from a previous test, as that creates order dependencies.
public class ProductTests : IClassFixture<CustomWebFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly AppDbContext _db;
public ProductTests(CustomWebFactory factory)
{
_client = factory.CreateClient();
// Get a scoped DbContext from the test server's DI container:
var scope = factory.Services.CreateScope();
_db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
}
public async Task InitializeAsync()
{
// Seed deterministic test data before each test:
await _db.Database.EnsureCreatedAsync();
_db.Products.AddRange(
new Product { Id = 1, Name = "Widget", Price = 9.99m },
new Product { Id = 2, Name = "Gadget", Price = 24.99m }
);
await _db.SaveChangesAsync();
}
public async Task DisposeAsync()
{
// Clean up so the next test class starts from a known state:
_db.Products.RemoveRange(_db.Products);
await _db.SaveChangesAsync();
}
[Fact]
public async Task GetProducts_ReturnsAllSeededProducts()
{
var response = await _client.GetAsync("/api/products");
response.EnsureSuccessStatusCode();
var products = await response.Content.ReadFromJsonAsync<List<Product>>();
Assert.Equal(2, products!.Count);
}
}
For complex seeding, extract an ObjectMother or builder so test bodies stay
focused on the scenario, not the setup.
Rule of thumb: Seed exactly the data each test needs, no more. Use unique IDs (or GUIDs) to avoid collisions between tests running in parallel.
Integration tests need a way to bypass or fake authentication. The standard
approach is a custom AuthenticationHandler that grants a fixed test identity.
// Custom test auth handler:
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, "test-user"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("sub", "user-42"),
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// Register in the custom WebApplicationFactory:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
}
// Now all requests to _client are authenticated as "test-user" / "Admin":
[Fact]
public async Task AdminEndpoint_AuthenticatedUser_Returns200()
{
var response = await _client.GetAsync("/api/admin/dashboard");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
// Test unauthorized access with a second client that has no auth:
var anonClient = _factory.CreateClient(
new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
// anonClient requests have no authentication header → 401
Rule of thumb: Create one factory for authenticated scenarios and configure
CreateClient() per test when you need to vary the identity (different roles,
different claims) within the same test class.
Testcontainers (Testcontainers NuGet) starts real Docker containers
(SQL Server, Postgres, Redis, etc.) during the test run and tears them down
after. This gives you production-equivalent database behavior without
maintaining a shared test instance.
// dotnet add package Testcontainers.MsSql
public class SqlServerIntegrationTests
: IAsyncLifetime, IClassFixture<MsSqlContainer>
{
private readonly MsSqlContainer _sqlContainer =
new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Str0ng!Passw0rd")
.Build();
private AppDbContext _db = default!;
public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_sqlContainer.GetConnectionString())
.Options;
_db = new AppDbContext(opts);
await _db.Database.MigrateAsync(); // run real EF migrations
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _sqlContainer.DisposeAsync();
}
[Fact]
public async Task CreateOrder_PersistsAndRetrievesWithRealConstraints()
{
var order = new Order { Sku = "X", Quantity = 1, CustomerId = Guid.NewGuid() };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
var retrieved = await _db.Orders.FindAsync(order.Id);
Assert.NotNull(retrieved);
Assert.Equal("X", retrieved.Sku);
}
}
Testcontainers requires Docker to be running on the test host. They are slower than in-memory alternatives (~5-15 s startup) but catch database-specific bugs that in-memory databases miss.
Rule of thumb: Use Testcontainers for tests that must exercise database-specific behavior (triggers, JSON queries, full-text search, EF migrations). Use SQLite in-memory for fast feedback on CRUD logic.
Use WebApplicationFactory to build a minimal host that includes only the
middleware under test, or use TestServer directly for fine-grained control.
// Testing a custom ExceptionHandlingMiddleware:
public class ExceptionMiddlewareTests
{
[Fact]
public async Task Middleware_WhenDownstreamThrows_Returns500WithProblemDetails()
{
// Build a minimal test host with only the middleware under test:
using var host = await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder.UseTestServer();
webBuilder.Configure(app =>
{
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Next handler always throws:
app.Run(_ => throw new InvalidOperationException("Boom!"));
});
})
.StartAsync();
var client = host.GetTestClient();
var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("application/problem+json",
response.Content.Headers.ContentType!.MediaType);
}
}
// Testing request/response transformation (e.g. a rate-limiting middleware):
[Fact]
public async Task RateLimiter_After10Requests_Returns429()
{
using var host = await new HostBuilder()
.ConfigureWebHost(w =>
{
w.UseTestServer();
w.Configure(app =>
{
app.UseMiddleware<RateLimitingMiddleware>(maxRequests: 10);
app.Run(ctx => ctx.Response.WriteAsync("OK"));
});
})
.StartAsync();
var client = host.GetTestClient();
for (int i = 0; i < 10; i++)
(await client.GetAsync("/")).EnsureSuccessStatusCode();
var eleventh = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode);
}
Rule of thumb: Use TestServer with a hand-built pipeline to test
middleware in isolation. Use WebApplicationFactory to test middleware
behavior inside the full application pipeline.
xUnit runs tests in different assemblies in parallel by default. Tests within the same class run sequentially; tests in different classes within the same assembly run sequentially too (unless opt-in parallelism is enabled).
// xunit.runner.json — control parallelism settings:
{
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}
// Collection attribute — group tests that must NOT run in parallel:
[Collection("Integration")] // same collection name = same test runner thread
public class OrderTests { /* ... */ }
[Collection("Integration")]
public class ProductTests { /* ... */ }
// ICollectionFixture — share an expensive fixture across a collection:
[CollectionDefinition("Integration")]
public class IntegrationCollection : ICollectionFixture<CustomWebFactory> { }
// Both test classes share ONE CustomWebFactory instance:
[Collection("Integration")]
public class OrderTests
{
private readonly HttpClient _client;
public OrderTests(CustomWebFactory factory) => _client = factory.CreateClient();
}
// Isolation strategy when sharing a factory:
// Each test uses a unique resource (GUID-named database, distinct SKU, etc.)
// so parallel tests within the collection don't step on each other's data.
Shared mutable state (a single in-memory database) is the most common cause of intermittent failures in integration tests run in parallel.
Rule of thumb: Put integration tests that share a database fixture in the
same [Collection]. Use unique IDs for test data to allow parallel execution
within that collection.
A typed HttpClient wraps HttpClient with domain-specific methods and is
registered via AddHttpClient<T>. In tests, use WebApplicationFactory to
replace the handler, or inject a mock handler via the factory.
// Production typed client:
public class WeatherClient
{
private readonly HttpClient _http;
public WeatherClient(HttpClient http) => _http = http;
public async Task<WeatherForecast?> GetForecastAsync(string city)
{
var response = await _http.GetAsync($"/weather/{city}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<WeatherForecast>();
}
}
// Registered in Program.cs:
// builder.Services.AddHttpClient<WeatherClient>(c =>
// c.BaseAddress = new Uri("https://api.weather.example.com"));
// Integration test — replace the handler in the factory:
public class WeatherTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public WeatherTests(WebApplicationFactory<Program> factory)
=> _factory = factory;
[Fact]
public async Task GetForecast_WhenApiReturnsData_MapsCorrectly()
{
var fakeJson = """{"city":"London","tempC":15}""";
var fakeHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{ Content = new StringContent(fakeJson, Encoding.UTF8, "application/json") });
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace the named client handler:
services.AddHttpClient<WeatherClient>()
.ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
});
}).CreateClient();
var response = await client.GetAsync("/api/weather/london");
response.EnsureSuccessStatusCode();
}
}
Rule of thumb: Inject the handler at the IHttpClientFactory level rather
than constructing HttpClient directly in the class. This makes the handler
swappable in tests without touching the typed client's code.
Response caching in integration tests can produce false positives (a cached stale response masking a regression) or false negatives (the test bypasses the cache entirely). You need to control the cache explicitly.
// Disable caching in the test factory so every test sees fresh responses:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Replace the distributed cache with a no-op:
services.AddSingleton<IDistributedCache, MemoryDistributedCache>();
// Override response caching to have max-age=0:
services.Configure<ResponseCachingOptions>(opts =>
opts.MaximumBodySize = 0); // disables body caching
});
}
// Test caching behavior explicitly:
[Fact]
public async Task CachedEndpoint_SecondRequest_ReturnsCachedResponse()
{
// First request — populates cache:
var r1 = await _client.GetAsync("/api/products");
r1.EnsureSuccessStatusCode();
Assert.Equal("MISS", r1.Headers.GetValues("X-Cache").FirstOrDefault());
// Second request — should hit cache:
var r2 = await _client.GetAsync("/api/products");
r2.EnsureSuccessStatusCode();
Assert.Equal("HIT", r2.Headers.GetValues("X-Cache").FirstOrDefault());
// Verify both return the same body:
var body1 = await r1.Content.ReadAsStringAsync();
var body2 = await r2.Content.ReadAsStringAsync();
Assert.Equal(body1, body2);
}
// Reset cache between tests using IDistributedCache directly:
var cache = factory.Services.GetRequiredService<IDistributedCache>();
await cache.RemoveAsync("products:all");
Rule of thumb: Run most endpoint tests with caching disabled to avoid flaky test order dependencies. Write a dedicated, isolated test class for caching behavior that explicitly populates and inspects cache state.
ASP.NET Core returns ProblemDetails (RFC 7807) for error responses when
AddProblemDetails() is configured. Integration tests should assert on the
status code, Content-Type, and specific problem fields.
// ProblemDetails shape:
// {
// "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
// "title": "Bad Request",
// "status": 400,
// "detail": "The Sku field is required.",
// "instance": "/api/orders"
// }
[Fact]
public async Task PostOrder_WithMissingSku_Returns400ProblemDetails()
{
var payload = new StringContent(
"""{"quantity":2}""", // missing Sku
Encoding.UTF8, "application/json");
var response = await _client.PostAsync("/api/orders", payload);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json",
response.Content.Headers.ContentType!.MediaType);
// Deserialize and assert specific fields:
var problem = await response.Content
.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal(400, problem!.Status);
Assert.Contains("Sku", problem.Detail ?? "");
}
// For validation errors, ASP.NET Core returns ValidationProblemDetails
// which has an Errors dictionary:
var validationProblem = await response.Content
.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.True(validationProblem!.Errors.ContainsKey("Sku"));
Rule of thumb: Assert both the HTTP status code AND the Content-Type header
in error response tests. A 400 returning text/html means the error middleware
is misconfigured, even though the status code is correct.
Running migrations in tests catches schema drift, broken migration scripts,
and EF model/migration mismatches before they reach production. The key is
using a real (or SQLite) database and calling MigrateAsync() in the fixture.
// MigrationTests — verify the full migration chain applies without error:
public class MigrationTests : IAsyncLifetime
{
private SqliteConnection _connection = default!;
private AppDbContext _db = default!;
public async Task InitializeAsync()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
_db = new AppDbContext(opts);
// Apply every migration from scratch — fails if any script has an error:
await _db.Database.MigrateAsync();
}
[Fact]
public async Task AllMigrations_ApplyWithoutError()
{
// If InitializeAsync succeeded, all migrations applied cleanly.
// Verify the final schema contains expected tables:
var tables = await _db.Database
.SqlQueryRaw<string>(
"SELECT name FROM sqlite_master WHERE type='table'")
.ToListAsync();
Assert.Contains("Orders", tables);
Assert.Contains("Products", tables);
Assert.Contains("Customers", tables);
}
[Fact]
public async Task PendingMigrations_AfterMigrate_IsEmpty()
{
// Confirms no migration was accidentally left un-applied:
var pending = await _db.Database.GetPendingMigrationsAsync();
Assert.Empty(pending);
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
_connection.Close();
}
}
On SQL Server, use EnsureDeleted() + MigrateAsync() at the start of each
CI run to test against a clean slate. For local dev speed, prefer SQLite.
Rule of thumb: Run migration tests in CI on every branch. A migration that cannot be replayed from scratch on an empty database is a migration that will fail the first deployment to a new environment.
WebApplicationFactory.WithWebHostBuilder lets you override individual
services per test class without creating a whole new factory subclass.
This is cleaner when only one or two tests need a different service.
public class OrderIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public OrderIntegrationTests(WebApplicationFactory<Program> factory)
=> _factory = factory;
[Fact]
public async Task PlaceOrder_WhenEmailFails_StillReturnsCreated()
{
// Replace only the email sender with a failing fake for this one test:
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove the real registration:
var descriptor = services.Single(
d => d.ServiceType == typeof(IEmailSender));
services.Remove(descriptor);
// Add a fake that always throws:
services.AddSingleton<IEmailSender, AlwaysFailingEmailSender>();
});
}).CreateClient();
var payload = new StringContent(
"""{"sku":"X","quantity":1}""",
Encoding.UTF8, "application/json");
// Order creation should succeed even if email sending fails:
var response = await client.PostAsync("/api/orders", payload);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
[Fact]
public async Task PlaceOrder_WithSlowInventoryCheck_RespectsTimeout()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var descriptor = services.Single(
d => d.ServiceType == typeof(IInventoryService));
services.Remove(descriptor);
// Slow fake simulates a lagging external dependency:
services.AddSingleton<IInventoryService, SlowInventoryService>();
});
}).CreateClient();
var response = await client.PostAsync("/api/orders",
new StringContent("""{"sku":"X","quantity":1}""",
Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode);
}
}
Each call to WithWebHostBuilder returns a new factory instance with its own
HttpClient and DI container — it does not mutate the shared fixture.
Rule of thumb: Use WithWebHostBuilder for one-off service swaps within
a test method. Extract a subclass of WebApplicationFactory only when the
override is needed by an entire test class or multiple test classes.
IHostedService and BackgroundService implementations run on the host
lifecycle. In tests, start the host, let the service execute, then verify
its side effects (database writes, queue messages, outbox events).
// Background service that processes an outbox table every 5 seconds:
public class OutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OutboxProcessor(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pending = await db.OutboxMessages
.Where(m => !m.Sent).ToListAsync(ct);
foreach (var msg in pending)
{
// Note: publish logic omitted for brevity
msg.Sent = true;
}
await db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}
// Integration test — start the host, seed data, wait for processing:
[Fact]
public async Task OutboxProcessor_MarksMessagesSent_WithinExpectedTime()
{
// Use a factory with fast polling (override the delay via config):
await using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(b =>
{
b.ConfigureAppConfiguration((_, cfg) =>
cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["OutboxPollingIntervalMs"] = "100" // fast for tests
}));
});
// Seed an outbox message:
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.OutboxMessages.Add(new OutboxMessage { Payload = "{}", Sent = false });
await db.SaveChangesAsync();
// Wait for the background service to process it:
var deadline = DateTime.UtcNow.AddSeconds(3);
bool sent = false;
while (DateTime.UtcNow < deadline)
{
await Task.Delay(150);
sent = await db.OutboxMessages.AnyAsync(m => m.Sent);
if (sent) break;
}
Assert.True(sent, "OutboxProcessor did not mark messages sent within 3 seconds.");
}
For services that poll on long intervals, inject the interval via configuration so tests can set it to milliseconds without modifying production code.
Rule of thumb: Test background services against their observable side effects
(DB state, queue contents), not their internal timer logic. Use short polling
intervals in tests and a time-bounded wait loop rather than fixed Task.Delay.
Minimal APIs (introduced in .NET 6) register endpoints via app.MapGet/Post/...
in Program.cs. They are tested exactly like controller endpoints — through
WebApplicationFactory and HttpClient — but you assert on the response
rather than on a controller action.
// Minimal API definition in Program.cs:
// app.MapPost("/api/products", async (CreateProductRequest req, IProductService svc) =>
// {
// var product = await svc.CreateAsync(req);
// return Results.Created($"/api/products/{product.Id}", product);
// })
// .WithName("CreateProduct")
// .Produces<Product>(201)
// .ProducesProblem(400);
// Integration test:
public class ProductMinimalApiTests : IClassFixture<CustomWebFactory>
{
private readonly HttpClient _client;
public ProductMinimalApiTests(CustomWebFactory factory)
=> _client = factory.CreateClient();
[Fact]
public async Task CreateProduct_ValidRequest_Returns201WithLocationHeader()
{
var request = new { Name = "Widget", Price = 9.99 };
var payload = JsonContent.Create(request); // sets Content-Type automatically
var response = await _client.PostAsync("/api/products", payload);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
// Deserialize the response body:
var product = await response.Content.ReadFromJsonAsync<Product>();
Assert.Equal("Widget", product!.Name);
}
[Fact]
public async Task CreateProduct_MissingName_Returns400ValidationProblem()
{
var request = new { Price = 9.99 }; // Name is missing
var payload = JsonContent.Create(request);
var response = await _client.PostAsync("/api/products", payload);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content
.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.True(problem!.Errors.ContainsKey("Name"));
}
[Fact]
public async Task GetProduct_AfterCreate_ReturnsCreatedProduct()
{
// Round-trip test: POST then GET to confirm persistence:
var created = await _client.PostAsync("/api/products",
JsonContent.Create(new { Name = "Gadget", Price = 24.99 }));
var location = created.Headers.Location!;
var getResp = await _client.GetAsync(location);
getResp.EnsureSuccessStatusCode();
var product = await getResp.Content.ReadFromJsonAsync<Product>();
Assert.Equal("Gadget", product!.Name);
}
}
Rule of thumb: Treat Minimal API tests as black-box HTTP tests — send a request, assert on status code, headers, and response body. Avoid reaching into the handler implementation; if you need to verify a side effect (email sent, DB written), assert on the observable outcome.
More Testing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.