Well-organised integration tests are as maintainable as the production code they test. A base test class eliminates boilerplate, fluent helper methods make tests read like specifications, and a consistent folder structure mirrors the production API. The final lesson brings these patterns together into a complete, production-ready integration test suite for the BlogApp.
Integration Test Infrastructure
// ── Base integration test class ────────────────────────────────────────────
public abstract class BlogAppIntegrationTest : IAsyncLifetime
{
protected readonly BlogAppFactory Factory;
protected readonly HttpClient Client;
protected readonly HttpClient AdminClient;
protected readonly HttpClient AuthorClient;
private static readonly JsonSerializerOptions Json = new()
{ PropertyNameCaseInsensitive = true };
protected BlogAppIntegrationTest(BlogAppFactory factory)
{
Factory = factory;
Client = factory.CreateClient();
AdminClient = factory.CreateAuthenticatedClient(
userId: "admin-user-id", roles: ["Admin", "Author"]);
AuthorClient = factory.CreateAuthenticatedClient(
userId: "author-user-id", roles: ["Author"]);
}
public virtual async Task InitializeAsync()
=> await Factory.ResetDatabaseAsync();
public virtual Task DisposeAsync() => Task.CompletedTask;
// ── Fluent helpers ────────────────────────────────────────────────────
protected async Task<PostDto> CreatePostAsync(
string? slug = null, string status = "draft",
HttpClient? client = null)
{
client ??= AdminClient;
slug ??= $"test-post-{Guid.NewGuid():N}";
var response = await client.PostAsJsonAsync("/api/posts", new
{
title = "Test Post",
slug,
body = "Body content that is long enough to pass validation.",
status,
});
response.StatusCode.Should().Be(HttpStatusCode.Created,
because: $"post creation with slug '{slug}' should succeed");
return (await response.Content.ReadFromJsonAsync<PostDto>(Json))!;
}
protected async Task<PostDto> PublishPostAsync(int postId,
HttpClient? client = null)
{
client ??= AdminClient;
var response = await client.PostAsync($"/api/posts/{postId}/publish", null);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<PostDto>(Json))!;
}
protected async Task AssertProblemDetails(HttpResponseMessage response,
int expectedStatus, string? expectedErrorKey = null)
{
var problem = await response.Content
.ReadFromJsonAsync<ValidationProblemDetails>(Json);
problem.Should().NotBeNull();
problem!.Status.Should().Be(expectedStatus);
problem.Extensions.Should().ContainKey("traceId");
if (expectedErrorKey != null)
problem.Errors.Should().ContainKey(expectedErrorKey);
}
protected async Task<T> GetAsync<T>(string url, HttpClient? client = null)
{
var response = await (client ?? Client).GetAsync(url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<T>(Json))!;
}
}
// ── Test class using the base ─────────────────────────────────────────────
[Collection("Integration")]
public class PostLifecycleTests : BlogAppIntegrationTest,
IClassFixture<BlogAppFactory>
{
public PostLifecycleTests(BlogAppFactory factory) : base(factory) { }
[Fact]
public async Task CompletePostLifecycle_DraftToPublished()
{
// Create draft
var draft = await CreatePostAsync(status: "draft");
draft.Status.Should().Be("draft");
// Submit for review
var reviewRes = await AdminClient.PostAsync(
$"/api/posts/{draft.Id}/submit-review", null);
reviewRes.StatusCode.Should().Be(HttpStatusCode.OK);
// Publish
var published = await PublishPostAsync(draft.Id);
published.Status.Should().Be("published");
published.IsPublished.Should().BeTrue();
// Verify visible in public list
var list = await GetAsync<PagedResult<PostSummaryDto>>("/api/posts");
list.Items.Should().Contain(p => p.Slug == draft.Slug);
// Delete (soft delete)
var deleteRes = await AdminClient.DeleteAsync($"/api/posts/{draft.Id}");
deleteRes.StatusCode.Should().Be(HttpStatusCode.NoContent);
// Verify not in public list
var listAfter = await GetAsync<PagedResult<PostSummaryDto>>("/api/posts");
listAfter.Items.Should().NotContain(p => p.Slug == draft.Slug);
}
}
[Collection("Integration")] attribute on the test class (combined with a matching [CollectionDefinition("Integration")]) disables parallel execution between test classes in the same collection. This is needed when multiple test classes share a database and the Respawn reset must complete before the next class starts. Without collection grouping, xUnit might run multiple classes simultaneously, causing their Respawn resets to interfere with each other.dotnet test --logger "console;verbosity=detailed" to see individual test timings. Identify the slowest tests — typically those with complex database seeding or multiple HTTP round-trips. For particularly slow tests, consider whether they could be split into a faster unit test (for the business logic) plus a simpler integration test (just verifying the endpoint exists and returns the right status code). The business logic check doesn’t need database access.CompletePostLifecycle_DraftToPublished) tests multiple operations in sequence — this is an anti-pattern in unit tests but acceptable in integration tests when testing a workflow. The risk: if step 2 fails, steps 3-5 also fail (cascading failures). Structure integration tests so early failures produce clear error messages that identify which step failed. The because: parameter in FluentAssertions (.Should().Be(201, because: "draft creation should succeed")) helps identify the failing step quickly.Common Mistakes
Mistake 1 — No [Collection] attribute on test classes sharing a factory (parallel reset interference)
❌ Wrong — two test classes both call ResetDatabaseAsync() concurrently; one reset partially deletes data the other test just created.
✅ Correct — use [Collection("Integration")] to prevent parallel execution between classes sharing a database resource.
Mistake 2 — Fluent helpers that swallow assertion failures (hard to diagnose)
❌ Wrong — helper catches exceptions and returns null; test gets NullReferenceException on the returned object with no context.
✅ Correct — helpers assert on their own operations with because messages; failures are immediately contextualised.