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.