Backend Integration Tests — Testing the Full Clean Architecture Stack

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);
    }
}
Note: The 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>().
Tip: Structure integration tests as lifecycle flows (create → publish → contact → delete) rather than isolated endpoint tests where possible. Lifecycle tests verify that the system works correctly as a user experiences it — the state changes from one step are visible in subsequent steps. They also catch state-transition bugs that isolated endpoint tests miss: can a listing be contacted after it’s been soft-deleted? (It shouldn’t be — the 404 from the GET should prevent the contact form from showing.)
Warning: Integration tests that verify email sending must use a 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.

🧠 Test Yourself

A buyer sends a contact request to a listing that has been soft-deleted. The handler loads the listing via _repo.GetByIdAsync(id). The global query filter excludes deleted listings. What does the handler receive?