Integration tests for Clean Architecture validate the complete pipeline — from HTTP request through controller, MediatR, pipeline behaviours, handler, domain model, EF Core, and back. The ClassifiedAppFactory follows the same pattern as the BlogApp’s factory but seeds classified-specific test data (listings, contact requests, categories). The tests also verify domain event side effects — that publishing a listing actually triggers the email notification via the fake email service.
Backend Integration Tests
// ── Tests/ClassifiedAppFactory.cs ─────────────────────────────────────────
public class ClassifiedAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Replace SQL Server with SQLite in-memory
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlite("Data Source=:memory:;Cache=Shared"));
// Replace email service with a fake (captures sent messages)
services.RemoveAll<IEmailService>();
services.AddSingleton<FakeEmailService>();
services.AddSingleton<IEmailService>(sp =>
sp.GetRequiredService<FakeEmailService>());
// Replace blob storage with in-memory fake
services.RemoveAll<IBlobStorage>();
services.AddSingleton<IBlobStorage, FakeBlobStorage>();
});
}
public async Task InitializeAsync()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
await SeedAsync(db);
}
private static async Task SeedAsync(AppDbContext db)
{
// Seed test users, categories, and listings
var owner = new ApplicationUser { Id = "owner-001",
Email = "seller@test.com", DisplayName = "Test Seller" };
db.Users.Add(owner);
var listing = Listing.Create(
"Mountain Bike Trek X-Caliber",
"Great condition, barely used.",
new Money(400m, "GBP"),
new Location("Bristol", "BS1 1AA"),
Category.SportsLeisure, "owner-001");
listing.Publish();
db.Listings.Add(listing);
await db.SaveChangesAsync();
}
public new Task DisposeAsync() => Task.CompletedTask;
}
// ── Tests/Listings/ListingLifecycleTests.cs ───────────────────────────────
[Collection("Integration")]
public class ListingLifecycleTests : IClassFixture<ClassifiedAppFactory>, IAsyncLifetime
{
private readonly ClassifiedAppFactory _factory;
private readonly HttpClient _client; // anonymous
private readonly HttpClient _ownerClient; // authenticated as owner-001
private readonly HttpClient _buyerClient; // authenticated as buyer-002
public ListingLifecycleTests(ClassifiedAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
_ownerClient = factory.CreateAuthenticatedClient("owner-001", ["Author"]);
_buyerClient = factory.CreateAuthenticatedClient("buyer-002", ["Author"]);
}
public async Task InitializeAsync() => await _factory.ResetDatabaseAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateListing_ThenPublish_ThenContact_FullCycle()
{
// STEP 1: Owner creates a listing
var createRes = await _ownerClient.PostAsJsonAsync("/api/listings", new {
title = "Vintage Guitar Fender Stratocaster",
description = "1990s classic, all original parts, excellent condition.",
price = 1200.00m,
currency = "GBP",
city = "London",
postcode = "EC1A 1BB",
category = "MusicalInstruments",
});
createRes.StatusCode.Should().Be(HttpStatusCode.Created);
var listingId = await createRes.Content.ReadFromJsonAsync<Guid>();
// STEP 2: Owner publishes the listing
var publishRes = await _ownerClient.PatchAsync(
$"/api/listings/{listingId}/publish", null);
publishRes.StatusCode.Should().Be(HttpStatusCode.NoContent);
// STEP 3: Verify email was sent (domain event fired)
var emailSvc = _factory.Services.GetRequiredService<FakeEmailService>();
emailSvc.SentMessages.Should().ContainSingle(m =>
m.To.Contains("seller@test.com") &&
m.Subject.Contains("now live"));
// STEP 4: Listing appears in public search
var searchRes = await _client.GetAsync(
"/api/listings?keyword=Fender&city=London");
var results = await searchRes.Content
.ReadFromJsonAsync<PagedResult<ListingSummaryDto>>();
results!.Items.Should().ContainSingle(l => l.Id == listingId);
// STEP 5: Buyer sends contact request
var contactRes = await _buyerClient.PostAsJsonAsync(
$"/api/listings/{listingId}/contact",
new { message = "Is the guitar still available? I'm interested." });
contactRes.StatusCode.Should().Be(HttpStatusCode.Accepted);
// STEP 6: Owner sees contact in inbox
var inboxRes = await _ownerClient.GetAsync("/api/contact-requests");
var inbox = await inboxRes.Content.ReadFromJsonAsync<IReadOnlyList<ContactRequestDto>>();
inbox.Should().ContainSingle(cr => cr.ListingId == listingId);
// STEP 7: Owner deletes listing — soft delete
var deleteRes = await _ownerClient.DeleteAsync($"/api/listings/{listingId}");
deleteRes.StatusCode.Should().Be(HttpStatusCode.NoContent);
// STEP 8: Listing no longer in public search
var searchAfter = await _client.GetAsync(
"/api/listings?keyword=Fender");
var afterResults = await searchAfter.Content
.ReadFromJsonAsync<PagedResult<ListingSummaryDto>>();
afterResults!.Items.Should().NotContain(l => l.Id == listingId);
}
}
FakeEmailService is registered as a singleton (not scoped) so all HTTP requests in the same test share the same instance and the same SentMessages list. This allows the test to assert that the email was sent after the publish request — even though the email was sent by a domain event handler in a different request scope. The factory provides access to the singleton via _factory.Services.GetRequiredService<FakeEmailService>().FakeEmailService singleton rather than a scoped service. If the fake is registered as scoped, each HTTP request gets a different instance and the test cannot access the messages sent during the publish request. Register fakes for cross-request verification as singletons; register fakes for per-request behaviour tracking as scoped if needed, and access them via a separate singleton tracker.Common Mistakes
Mistake 1 — FakeEmailService registered as scoped (test cannot access messages from other requests)
❌ Wrong — scoped fake; publish request’s email service instance is discarded after the request; test sees empty SentMessages.
✅ Correct — singleton fake; same instance across all requests; SentMessages accumulates correctly for verification.
Mistake 2 — Not testing domain event side effects (email notification untested)
❌ Wrong — test verifies 204 from publish but never checks FakeEmailService; email handler bug is silently undetected.
✅ Correct — assert on FakeEmailService.SentMessages after publish; confirms the full domain event → notification handler pipeline works.