Testing API Endpoints — Requests, Responses and Status Codes

Integration tests for the BlogApp API verify the complete HTTP contract: correct status codes, response body shape, headers, and error responses. They test what unit tests cannot — that the middleware pipeline is configured correctly, that model binding works as expected, that validation returns proper ProblemDetails, and that the response is serialised in the expected JSON shape. Each test makes a real HTTP request through the in-memory application and asserts on the response.

Endpoint Integration Tests

public class PostsEndpointTests : IClassFixture<BlogAppFactory>
{
    private readonly BlogAppFactory _factory;
    private readonly HttpClient     _client;
    private readonly HttpClient     _adminClient;

    private static readonly JsonSerializerOptions JsonOpts = new()
        { PropertyNameCaseInsensitive = true };

    public PostsEndpointTests(BlogAppFactory factory)
    {
        _factory     = factory;
        _client      = factory.CreateClient();
        _adminClient = factory.CreateAuthenticatedClient();
    }

    // ── GET /api/posts — public list ─────────────────────────────────────
    [Fact]
    public async Task GetPosts_ReturnsOkWithPagedResult()
    {
        var response = await _client.GetAsync("/api/posts");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        response.Content.Headers.ContentType!.MediaType.Should().Be("application/json");

        var body = await response.Content.ReadFromJsonAsync<PagedResult<PostSummaryDto>>(JsonOpts);
        body.Should().NotBeNull();
        body!.Items.Should().NotBeNull();
        body.PageSize.Should().BeGreaterThan(0);
    }

    // ── GET /api/posts/{slug} — 404 for missing post ─────────────────────
    [Fact]
    public async Task GetBySlug_WhenSlugNotFound_Returns404WithProblemDetails()
    {
        var response = await _client.GetAsync("/api/posts/definitely-does-not-exist");

        response.StatusCode.Should().Be(HttpStatusCode.NotFound);

        var problem = await response.Content
            .ReadFromJsonAsync<ProblemDetails>(JsonOpts);
        problem.Should().NotBeNull();
        problem!.Status.Should().Be(404);
        problem.Type.Should().NotBeNullOrEmpty();
        // Verify traceId extension is present
        problem.Extensions.Should().ContainKey("traceId");
    }

    // ── POST /api/posts — 401 when unauthenticated ────────────────────────
    [Fact]
    public async Task CreatePost_WhenUnauthenticated_Returns401()
    {
        var request = new { title = "Test", body = "Body", status = "draft" };
        var response = await _client.PostAsJsonAsync("/api/posts", request);

        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    // ── POST /api/posts — 422 on validation failure ───────────────────────
    [Fact]
    public async Task CreatePost_WithEmptyTitle_Returns422WithErrors()
    {
        var request = new { title = "", body = "Body", slug = "slug", status = "draft" };
        var response = await _adminClient.PostAsJsonAsync("/api/posts", request);

        response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);

        var problem = await response.Content
            .ReadFromJsonAsync<ValidationProblemDetails>(JsonOpts);
        problem.Should().NotBeNull();
        problem!.Errors.Should().ContainKey("title");
        problem.Errors["title"].Should().NotBeEmpty();
    }

    // ── POST /api/posts — 201 Created on success ──────────────────────────
    [Fact]
    public async Task CreatePost_WithValidData_Returns201WithLocationHeader()
    {
        var request = new {
            title  = "My Integration Test Post",
            slug   = $"integration-test-{Guid.NewGuid():N}",  // unique slug
            body   = "This is a long enough body for validation to pass.",
            status = "draft",
        };

        var response = await _adminClient.PostAsJsonAsync("/api/posts", request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();
        response.Headers.Location!.ToString().Should().Contain(request.slug);

        var created = await response.Content.ReadFromJsonAsync<PostDto>(JsonOpts);
        created.Should().NotBeNull();
        created!.Slug.Should().Be(request.slug);
        created.Id.Should().BePositive();

        // Verify it was actually persisted in the database
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var saved = await db.Posts.FirstOrDefaultAsync(p => p.Slug == request.slug);
        saved.Should().NotBeNull();
        saved!.Status.Should().Be("draft");
    }

    // ── DELETE /api/posts/{id} — soft delete verification ─────────────────
    [Fact]
    public async Task DeletePost_AsOwner_Returns204AndSoftDeletes()
    {
        // Create a post first
        var createReq = new { title = "To Delete", slug = $"to-delete-{Guid.NewGuid():N}",
                               body = "Body content here.", status = "draft" };
        var createRes = await _adminClient.PostAsJsonAsync("/api/posts", createReq);
        var created   = await createRes.Content.ReadFromJsonAsync<PostDto>(JsonOpts);

        // Delete it
        var deleteRes = await _adminClient.DeleteAsync($"/api/posts/{created!.Id}");

        deleteRes.StatusCode.Should().Be(HttpStatusCode.NoContent);

        // Verify soft delete — row still exists but status is archived
        using var scope = _factory.Services.CreateScope();
        var db   = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var post = await db.Posts.IgnoreQueryFilters()
                             .FirstOrDefaultAsync(p => p.Id == created.Id);
        post.Should().NotBeNull();
        post!.Status.Should().Be("archived");
        post.IsPublished.Should().BeFalse();
    }
}
Note: Using unique slugs per test ($"integration-test-{Guid.NewGuid():N}") prevents test interference when multiple tests create posts and the database is shared. Without unique slugs, a second test run (without resetting the database) fails because the slug is already taken. Unique GUIDs in slugs are not production-quality slug generation — they exist only to make tests independent. The test verifies the slug that was submitted, not the slug format.
Tip: Access the DI container directly from integration tests using _factory.Services.CreateScope() to verify database state after an HTTP call. This is the canonical way to assert that data was correctly persisted — more reliable than making another GET request (which tests the GET endpoint, not the POST’s side effect). Use IgnoreQueryFilters() when accessing soft-deleted entities, as EF Core’s global filter would hide them from a standard query.
Warning: Never use a shared AppDbContext instance between HTTP requests and database verification queries. The AppDbContext from _factory.Services.CreateScope() is a different instance than the one used by the HTTP request. This is correct — each request gets its own scoped DbContext. However, after the HTTP request saves data, the change tracker’s cache in the request’s DbContext is discarded. The verification DbContext reads from the database with no cache, giving a true picture of what was persisted.

Common Mistakes

Mistake 1 — Using the same slug across tests (second run fails with ConflictException)

❌ Wrong — hardcoded slug “test-post” in create test; second test run finds slug taken; 409 instead of 201; test fails.

✅ Correct — generate unique slug per test: $"test-{Guid.NewGuid():N}"; each test run succeeds independently.

Mistake 2 — Asserting database state with the request’s DbContext (stale cache)

❌ Wrong — using the same DbContext that processed the request; may return cached data, not what was actually saved.

✅ Correct — create a new scope and DbContext: using var scope = _factory.Services.CreateScope().

🧠 Test Yourself

An integration test creates a post, then deletes it, then calls GET /api/posts/{slug}. The test expects 404. The global query filter on Post is HasQueryFilter(p => !p.IsDeleted). What does the GET return?