Testing ProblemDetails responses in ASP.NET Core
When building APIs that return structured error responses using ProblemDetails, you need integration tests that validate both the HTTP status codes and the error response structure. I've found that testing only status codes misses important validation errors and business rule violations that clients depend on.
To read more on ProblemDetails, check RFC9457 and ProblemDetails in ASP.NET Core.
Setting up the test dependencies #
First, ensure you have the necessary NuGet packages installed in your test project:
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package xunit
dotnet add package Shouldly
dotnet add package Flurl.Http
Creating the test fixture #
Implement the test fixture that starts the API project in memory within the integration tests:
using Microsoft.AspNetCore.Mvc.Testing;
using Flurl.Http;
using Playground.WebApi;
namespace Playground.IntegrationTests
{
public class ApiTestFixture: WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly FlurlClient _client;
public ApiTestFixture()
{
_client = new FlurlClient(CreateClient());
}
public FlurlClient Client => _client;
public async Task InitializeAsync()
{
// Perform any initialization here if needed
await Task.CompletedTask;
}
public new async Task DisposeAsync()
{
_client.Dispose();
await base.DisposeAsync();
}
}
}
Writing the test cases #
Now you can write test cases that validate both successful responses and ProblemDetails error responses:
using Flurl.Http;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
using System.Net;
namespace Playground.IntegrationTests.ApiTests
{
public class StudentTests: IClassFixture<ApiTestFixture>
{
private readonly FlurlClient _client;
private const string BaseUrl = "http://localhost/api";
public StudentTests(ApiTestFixture fixture)
{
_client = fixture.Client;
}
private IFlurlRequest Request(string url) => _client.Request(BaseUrl + url);
{
[Fact]
public async Task Create_student_with_valid_input()
{
// Arrange
var studentData = new
{
FirstName = "John",
LastName = "Doe",
EnrollmentDate = DateTime.UtcNow
};
// Act
var response = await Request("/students").PostJsonAsync(studentData);
// Assert
response.StatusCode.ShouldBe((int) HttpStatusCode.OK);
}
[Fact]
public async Task Create_student_fails_when_first_name_is_not_provided()
{
// Arrange
var studentWithEmptyFirstName = new
{
FirstName = "",
LastName = "A",
EnrollmentDate = DateTime.UtcNow
};
// Act
var exception = await Should.ThrowAsync<FlurlHttpException>(async() => {
await Request("/students").PostJsonAsync(studentWithEmptyFirstName);
});
// Assert
exception.StatusCode.ShouldBe((int) HttpStatusCode.BadRequest);
var errorResponse = await exception.GetResponseJsonAsync<ValidationProblemDetails>();
errorResponse.ShouldNotBeNull();
errorResponse.Status.ShouldBe((int)HttpStatusCode.BadRequest);
errorResponse.Title.ShouldBe("One or more validation errors occurred.");
errorResponse.Errors.ShouldContainKey("FirstName");
errorResponse.Errors["FirstName"].ShouldContain("The FirstName field is required.");
}
[Fact]
public async Task Create_student_fails_with_submitted_reserved_name()
{
// Arrange
var studentWithReservedName = new
{
FirstName = "James",
LastName = "Bond",
EnrollmentDate = DateTime.UtcNow
};
// Act
var exception = await Should.ThrowAsync<FlurlHttpException>(async() => {
await Request("/students").PostJsonAsync(studentWithReservedName);
});
// Assert
exception.StatusCode.ShouldBe((int) HttpStatusCode.BadRequest);
var problemDetails = await exception.GetResponseJsonAsync<ValidationProblemDetails>();
problemDetails.ShouldNotBeNull();
problemDetails.Status.ShouldBe((int)HttpStatusCode.BadRequest);
problemDetails.Title.ShouldBe("Cannot create a student named James Bond because it's a reserved name.");
}
[Fact]
public async Task Create_student_fails_when_EnrollmentDate_is_too_far_in_the_past()
{
// Arrange
var tooOldDate = DateTime.UtcNow.AddYears(-3); // 3 years ago
var studentWithTooOldEnrollmentDate = new
{
FirstName = "John",
LastName = "Doe",
EnrollmentDate = tooOldDate
};
// Act
var exception = await Should.ThrowAsync<FlurlHttpException>(async() => {
await Request("/students").PostJsonAsync(studentWithTooOldEnrollmentDate);
});
// Assert
exception.StatusCode.ShouldBe((int) HttpStatusCode.BadRequest);
var problemDetails = await exception.GetResponseJsonAsync<ValidationProblemDetails>();
problemDetails.ShouldNotBeNull();
problemDetails.Status.ShouldBe((int)HttpStatusCode.BadRequest);
problemDetails.Title.ShouldBe("One or more validation errors occurred.");
problemDetails.Errors.ShouldContainKey("EnrollmentDate");
problemDetails.Errors["EnrollmentDate"].ShouldContain("Enrollment date cannot be more than 500 days in the past.");
}
}
This approach validates both the HTTP status codes and the structure of ProblemDetails error responses. Testing the complete error response structure helps catch issues where your API returns the wrong status code or malformed error details that break client error handling.
- Previous: SSH tips on Windows
- Next: Use enums over booleans for status fields