ASP.NET Core Integration Test Helpers

August 09, 2022#Software Development
Article
Author image.

Steve Smith, Founder and Principal Architect

ASP.NET Core has great support for automated integration testing. The docs provide everything you need to get started, and there are many community articles available as well. For full sample apps with these tests, I recommend Microsoft’s eShopOnWeb reference application and my CleanArchitecture solution template on GitHub.

Long Tests

One thing that you’ll often run into with ASP.NET Core integration tests is the long tests code smell. Tests should be brief and should have minimal duplication for basically all the same reasons methods in your production code should have these same characteristics. It makes them more maintainable and if your tests become unmaintainable eventually you’ll give up on maintaining them and they’ll be thrown away.

In order to test an ASP.NET Core page or API endpoint, there is some common work that must be done. You need to get an HttpClient that can communicate with your server, and typically you use WebApplicationFactory<T> to do so. Here’s a simple example from the docs:

[Fact]
public async Task HelloWorldTest()
{
    var application = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            // ... Configure test services
        });

    var client = application.CreateClient();
    //...
}

What’s not shown is the code to set up the application, which often includes seeding the database and replacing certain real services with fake or mock versions. It’s not unusual for this code to take 10-20 lines or more.

It turns out that configuring test services is typically the same for 99% of your tests, so most of the time the WebApplicationFactory stuff is done in its own custom class and then injected into the tests. Here’s another example from the docs:

public class BasicTests 
    : IClassFixture<WebApplicationFactory<RazorPagesProject.Startup>>
{
    private readonly WebApplicationFactory<RazorPagesProject.Startup> _factory;

    public BasicTests(WebApplicationFactory<RazorPagesProject.Startup> factory)
    {
        _factory = factory;
    }

    [Theory]
    [InlineData("/")]
    [InlineData("/Index")]
    [InlineData("/About")]
    [InlineData("/Privacy")]
    [InlineData("/Contact")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", 
            response.Content.Headers.ContentType.ToString());
    }
}

Now, this test is down to just 4 lines of code, but it’s also not sending anything to the endpoint, nor is it deserializing a type from the response. When testing APIs, many API calls will require that you send a DTO in the body of a POST or PUT, and almost every API endpoint is going to return an object that you’ll need to deserialize.

Writing these kinds of tests requires creating a DTO, serializing it, and then creating a data type that can be sent with an HttpRequest. Then on the response side, the response must be read as a string and then deserialized into the expected response DTO type. Only then can assertions be made in a strongly-typed manner on the values in the response.

For example:

[TestMethod]
public async Task ReturnsSuccessGivenValidNewItemAndAdminUserToken()
{
    var jsonContent = GetValidNewItemJson();
    var adminToken = ApiTokenHelper.GetAdminUserToken();
    var client = ProgramTest.NewClient;
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
    var response = await client.PostAsync("api/catalog-items", jsonContent);
    response.EnsureSuccessStatusCode();
    var stringResponse = await response.Content.ReadAsStringAsync();
    var model = stringResponse.FromJson<CreateCatalogItemResponse>();

    Assert.AreEqual(_testBrandId, model.CatalogItem.CatalogBrandId);
    Assert.AreEqual(_testTypeId, model.CatalogItem.CatalogTypeId);
    Assert.AreEqual(_testDescription, model.CatalogItem.Description);
    Assert.AreEqual(_testName, model.CatalogItem.Name);
    Assert.AreEqual(_testPrice, model.CatalogItem.Price);
}

private StringContent GetValidNewItemJson()
{
    var request = new CreateCatalogItemRequest()
    {
        CatalogBrandId = _testBrandId,
        CatalogTypeId = _testTypeId,
        Description = _testDescription,
        Name = _testName,
        Price = _testPrice
    };
    var jsonContent = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json");

    return jsonContent;
}

Identify Useless Duplication

When you’re looking at your tests, look for duplication that doesn’t add value and is just simple plumbing code. Try to find ways to combine low level operations into methods that can use good names to convey the same information more succinctly. In the code listing above, the code to get a valid new item as JSON is a good example of this approach. Even though it’s currently only used once, the use of a separate method reduces the total length of the test method while actually improving its readability (some argue against refactoring tests because doing so can harm readability. It certainly can but it can also help if you do it right).

Some low level things may change over time, such as how you perform serialization. Try to avoid tightly coupling every test to a particular JSON implementation. Use helpers for your serialization when you can.

So, what are some of the common things you need to do in ASP.NET Core integration tests? Let’s look at the isolated items first, and then see how we might want to group them.

  • Create an object to send with an HTTP request
  • Serialize an object to send
  • Create a StringContent type to send
  • Make an HTTP request to Route using Verb
  • Ensure the request was successful
  • Ensure the request was (NotFound | Redirect | Unauthorized | Forbidden | BadRequest | NoContent)
  • Get the result of a request as a string
  • Ensure the string includes some substring
  • Deserialize the string into an object

The main verbs to support are:

  • GET
  • POST
  • PUT
  • DELETE

Only POST and PUT support sending objects, so all of the object creation and serializing stuff only needs to go with those.

GET, POST, and PUT all could return results that might be interpreted as strings or deserialized. DELETE might, but typically a successful DELETE should return 204 No Content.

Anything that returns a result should typically also ensure the result is successful, so we’ll do that without saying so explicitly. Those are “happy path” tests.

Breaking it down by verb we have:

GET (Route) and:

  • Ensure response includes some substring
  • Ensure response deserializes to some type
  • Ensure response is 404 Not Found
  • Ensure response is 302 Redirect (to a given path)
  • Ensure response is 401 Unauthorized
  • Ensure response is 403 Forbidden
  • Ensure response is 400 Bad Request

POST (object to Route) and:

  • Ensure response includes some substring
  • Ensure response deserializes to some type
  • Ensure response is 201 Created (and includes a certain path)
  • Ensure response is 404 Not Found
  • Ensure response is 302 Redirect (to a given path)
  • Ensure response is 401 Unauthorized
  • Ensure response is 403 Forbidden
  • Ensure response is 400 Bad Request

PUT (object to Route) and:

  • Ensure response includes some substring
  • Ensure response deserializes to some type
  • Ensure response is 404 Not Found
  • Ensure response is 302 Redirect (to a given path)
  • Ensure response is 401 Unauthorized
  • Ensure response is 403 Forbidden
  • Ensure response is 400 Bad Request

DELETE (Route) and:

  • Ensure response is 204 No Content
  • Ensure response includes some substring
  • Ensure response deserializes to some type
  • Ensure response is 404 Not Found
  • Ensure response is 302 Redirect (to a given path)
  • Ensure response is 401 Unauthorized
  • Ensure response is 403 Forbidden
  • Ensure response is 400 Bad Request

This is not an exhaustive list, but the 30 or so methods shown above can easily be built as helper methods that will cover 99% of the needs of any ASP.NET Core Web API integration / functional test suite. And most of these (at time of writing - more are being added) are available already in my Ardalis.HttpClientTestExtensions NuGet Package.

Ardalis.HttpClientTestExtensions

Install the package in your xUnit test project, and then you can use it to clean up your tests that follow any of the supported scenarios. If your endpoint is returning an object, you’ll still need to perform any assertions on its state yourself, but the helpers can take care of all the rest of the plumbing code for you.

For example, this test makes a request to a GET endpoint and ensures the resulting collection includes the expected number of results, and that one of them has the expected name:

[Fact]
public async Task Returns3Doctors()
{
  var result = await _client.GetAndDeserialize<ListDoctorResponse>("/api/doctors", _outputHelper);

  Assert.Equal(3, result.Doctors.Count());
  Assert.Contains(result.Doctors, x => x.Name == "Dr. Smith");
}

In addition to extensions on HttpClient, you can also ensure that the response matches an expected status code using additional extension methods, as shown here (note call to response.EnsureNotFound()):

[Fact]
public async Task ReturnsNotFoundGivenInvalidAuthorId()
{
  int invalidId = 9999;

  var response = await _client.GetAsync(Routes.Authors.Get(invalidId));

  response.EnsureNotFound();
}

Of course, this could be simplified to use GetAndEnsureNotFoundAsync but there will be times when you don’t want to combine everything into one method.

await client.GetAndEnsureNotFoundAsync(Routes.Authors.Get(invalidId));

The helpers include logging so that you’ll be able to see what was done within them if you’re using xUnit’s ITestOutputHelper type. For example, this test (from the samples included in the HttpClientTestExtensions GitHub repository):

[Fact]
public async Task GetAndDeserializeTestAsync()
{
var expectedId = SeedData.TestCountry1.Id;
var expectedName = SeedData.TestCountry1.Name;

var response = await _client.GetAndDeserializeAsync<CountryDto>("/countries/USA", _outputHelper);

response.Id.ShouldBe(expectedId);
response.Name.ShouldBe(expectedName);
}

Will produce this standard output when run:

Requesting with GET /countries/USA
Response: {"id":"USA","name":"USA"}

Version 3.0.0

12 August 2022 Just published a new version, 3.0.0, which includes all of the above extensions and has improved XML comment docs and README info. Get the latest version of Ardalis.HttpClientTestExtensions here.

Summary

Keep your tests maintainable and avoid unnecessary low-level repetition in them. Try to avoid hiding magic behavior in attributes or through nested inheritance that can be difficult to discover, but use well-named helper methods to keep tests short, consistent, and easy to follow at a higher level of abstraction. As you start to build up sets of useful extensions, consider sharing them more widely within your organization, or even publicly. And if you want to skip ahead on some of that and just leverage ones that the community has already tested, feel free to use existing packages like Ardalis.HttpClientTestExtensions.


Copyright © 2024 NimblePros - All Rights Reserved