Introduction

ASP.NET Core has awesome support for writing integration tests. I like to use them when I build APIs to verify that I don't break any public contracts when developing.

Our first approach

We have the following endpoint that returns details about a hamburger...

[ApiController]
[Route("[controller]")]
public class HamburgersController : ControllerBase
{
    private static readonly Dictionary<string, HamburgerDto> Hamburgers;

    static HamburgersController()
    {
        Hamburgers = new Dictionary<string, HamburgerDto>
        {
            { "big-mac", new HamburgerDto { Name = "Big Mac" } }
        };
    }

    [HttpGet("{burgerName}")]
    public ActionResult<HamburgerDto> Get(string burgerName)
    {
        if (!Hamburgers.TryGetValue(burgerName, out var hamburger))
        {
            return new NotFoundResult();
        }

        return new OkObjectResult(hamburger);
    }
}

public class HamburgerDto
{
    public string Name { get; set; }
}

We then create a integration test to verify that the endpoint works as it should.

[Fact]
public async Task ShouldReturn200OkOnGetSpecificHamburgerEndpoint()
{
    var request = new HttpRequestMessage(HttpMethod.Get, "/hamburgers/big-mac");
    var client = _webApplicationFactory.CreateClient();

    using var response = await client.SendAsync(request);

    response.StatusCode.ShouldBe(HttpStatusCode.OK);
}

Lets write another tests that verifies that the response body contains the name of the hamburger (testing the contract).

[Fact]
public async Task ShouldReturnBurgerNameInResponseBody()
{
    var request = new HttpRequestMessage(HttpMethod.Get, "/hamburgers/big-mac");
    var client = _webApplicationFactory.CreateClient();

    using var response = await client.SendAsync(request);
    await using var responseBody = await response.Content.ReadAsStreamAsync();
    var responseData = await JsonSerializer.DeserializeAsync<HamburgerDto>(
                responseBody, new JsonSerializerOptions(JsonSerializerDefaults.Web));

    responseData.Name.ShouldBe("Big Mac");
}

The test works great, but it has one major problem, can you spot it?
Remember that I said that I like to use the tests to verify that I don't break the contract?

Now imagine that we were to rename the Name property to HamburgerName (a breaking change in the contract). If we were to use a refactoring tool that automatically renames the property for us in all places where it's used, the above test would be updated automatically and still pass, like this:

public async Task ShouldReturnBurgerNameInResponseBody()
{
    var request = new HttpRequestMessage(HttpMethod.Get, "/hamburgers/big-mac");
    var client = _webApplicationFactory.CreateClient();

    using var response = await client.SendAsync(request);
    await using var responseBody = await response.Content.ReadAsStreamAsync();
    var responseData = await JsonSerializer.DeserializeAsync<HamburgerDto>(
                responseBody, new JsonSerializerOptions(JsonSerializerDefaults.Web));

    // This was updated automatically when refactoring, making the test pass
    responseData.HamburgerName.ShouldBe("Big Mac");
}

The problem is that we are deserializing the response to the HamburgerDto class. We are tying our integration test to an implementation detail of our API. Since I want to treat the integration tests as an external consumer of our API, that's obviously not a good thing to do :).

A better approach

Instead of deserializing the response to a HamburgerDto, we can use JsonDocument.

[Fact]
public async Task ShouldReturnBurgerNameInResponseBody_JsonDocument()
{
    var request = new HttpRequestMessage(HttpMethod.Get, "/hamburgers/big-mac");
    var client = _webApplicationFactory.CreateClient();

    using var response = await client.SendAsync(request);
    await using var responseBody = await response.Content.ReadAsStreamAsync();
    var responseData = await JsonDocument.ParseAsync(responseBody);

    responseData.RootElement.GetProperty("name").GetString().ShouldBe("Big Mac");
}

Now if we were to change the property Name to HamburgerName, the above test would fail. This makes the test behave more like an external consumer of our API. By having this approach, we can prevent pushing breaking changes to production.