Painless Integration Testing with ASP.NET Core Web API

As the art of software development has evolved, the strategies and practices used in software testing have grown up with it. Automated testing is an integral part of modern dev ops and agile approaches which enable teams to ship high-quality software faster and with confidence.

Integration testing sits in the middle of the automated test pyramid and ensures individual modules and components in our code can be successfully connected to produce the expected behavior. These tests exercise our code and the code we can't change within third-party frameworks and libraries we're using.

Many integration testing scenarios add things like databases, the filesystem or a network into the loop to test components in different regions of the software stack. The overhead added by these types of external dependencies conflicts with the goals of continuous integration as they require extra time to set up and maintain and make tests slower to run.

These types of tests can be categorized as broad integration tests as they typically require a separate environment with live, running instances of databases and other services. They also exercise code paths beyond just the bits needed for the interaction between modules and services - this isn't a bad thing.

By comparison, the setup -> execution -> tear down cycle of most unit test suites runs extremely fast in memory and can easily be triggered and run on every significant change to the code base to catch errors and prevent regression of working behavior in our application.

Achieving a similar speed and ease of use for API integration tests is trickier. In these cases, we're likely producing broad integration tests which require us to deploy our code to an environment running at minimum a web server, database and some flavor of http client to send requests and validate the responses.

The release of ASP.NET Core 2.1 introduced a handy new package in Microsoft.AspNetCore.Mvc.Testing. Its primary goal is streamlining end-to-end MVC and Web API testing by hosting the full web stack (database included) in memory while providing a client to test "from the outside in" by issuing http requests to our application.

Having this test host available means, we can write tests that look and execute quickly like unit tests but exercise almost all layers of our code without the need for any network or database - rad! 😎

Get notified on new posts

Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.

System Under Test

The Web API project or SUT (system under test) we'll be testing is straightforward. It has a single controller named PlayersController with two action methods for fetching either all players or a single player by their id.

[Route("api/[controller]")]
[ApiController]
public class PlayersController : ControllerBase
{
  private readonly IPlayerRepository _playerRepository;
  
  public PlayersController(IPlayerRepository playerRepository)
  {
    _playerRepository = playerRepository;
  }

  // GET api/players
  [HttpGet]
  public async Task<ActionResult<IEnumerable<Player>>> Get()
  {
    return await _playerRepository.ListAll();
  }

  // GET api/players/5
  [HttpGet("{id}")]
  public async Task<ActionResult<Player>> Get(int id)
  {
    return await _playerRepository.GetById(id);
  }
}

Note the dependency on PlayerRepository which provides the data for the API and is based on Entity Framework Core with a SQLite database. Next, we'll see how to use an in-memory database to replace this dependency during testing.

The Test Project

I added a new .NET Core xUnit Test Project to the solution and NuGetted two packages:

  • Microsoft.AspNetCore.Mvc.Testing - Contains the TestServer and an important class in WebApplicationFactory to help bootstrap our app in-memory.
  • Microsoft.AspNetCore.App - This is the primary package for building ASP.NET Core apps but in the context of our test project it will add the in-memory database provider for Entity Framework removing the need for a real database.

The final bit of setup required is adding a reference to the Web.Api project. As we'll see next, the WebApplicationFactory requires an entry point to intialize the target application which I'm sure you've already guessed is the Startup class in the Web.Api project.

Web Application Factory

Our next step is to build out the web application factory used to bootstrap an in-memory instance of our web API. This factory gets hooked into xUnit as a fixture, and that link is the small bit of magic that allows our test code to talk to a running instance of our web API - powerful stuff! 💪

CustomWebApplicationFactory is a subclass of WebApplicationFactory and has a single method implemented called ConfigureWebHost() in which we configure the application before it gets built for testing.

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
  builder.ConfigureServices(services =>
  {
    // Create a new service provider.
    var serviceProvider = new ServiceCollection().AddEntityFrameworkInMemoryDatabase()       .BuildServiceProvider();

    // Add a database context (AppDbContext) using an in-memory database for testing.
    services.AddDbContext<AppDbContext>(options =>
    {
      options.UseInMemoryDatabase("InMemoryAppDb");
      options.UseInternalServiceProvider(serviceProvider);
    });

    // Build the service provider.
    var sp = services.BuildServiceProvider();

    // Create a scope to obtain a reference to the database contexts
    using (var scope = sp.CreateScope())
    {
      var scopedServices = scope.ServiceProvider;
      var appDb = scopedServices.GetRequiredService<AppDbContext>();

      var logger = scopedServices.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>();
      // Ensure the database is created.
      appDb.Database.EnsureCreated();

       try
       {
         // Seed the database with some specific test data.
         SeedData.PopulateTestData(appDb);
       }
       catch (Exception ex)
       {
         logger.LogError(ex, "An error occurred seeding the " + "database with test messages. Error: {ex.Message}");
       }
     }
   });
}

A few interesting things are happening here, let's break them down:

  • ServiceCollection().AddEntityFrameworkInMemoryDatabase()... adds Entity Framework in-memory DB support to the dependency injection container via the ServiceCollection.
  • services.AddDbContext<AppDbContext>(options => { options.UseInMemoryDatabase("InMemoryAppDb")... adds our database context to the service container and instructs it to use an in-memory database instead of whatever real database our application might be using. Here we're overriding the link to the local SQLite DB; this same pattern applies to any database used with Entity Framework, e.g., SQL Server, MySQL, etc.
  • Lastly, it would be nice to seed the test database with some specific data that we can use to validate our test results. SeedData.PopulateTestData(appDb) does just that by passing a resolved instance of the database context to a helper class SeedData which inserts some test data into the in-memory database.

All of the pieces are now in place for us to write some integration tests that leverage the new web application factory.

Writing Integration Tests

I added a new test class called PlayersControllerIntegrationTests with a test to cover the happy path of the two action methods we saw earlier in PlayersController.

public class PlayersControllerIntegrationTests : IClassFixture<CustomWebApplicationFactory<Startup>>
{
  private readonly HttpClient _client;

  public PlayersControllerIntegrationTests(CustomWebApplicationFactory<Startup> factory)
  {
    _client = factory.CreateClient();
  }

  [Fact]
  public async Task CanGetPlayers()
  {
    // The endpoint or route of the controller action.
    var httpResponse = await _client.GetAsync("/api/players");

    // Must be successful.
    httpResponse.EnsureSuccessStatusCode();

    // Deserialize and examine results.
    var stringResponse = await httpResponse.Content.ReadAsStringAsync();
    var players = JsonConvert.DeserializeObject<IEnumerable<Player>>(stringResponse);
    Assert.Contains(players, p => p.FirstName=="Wayne");
    Assert.Contains(players, p => p.FirstName == "Mario");
  }
...

The IClassFixture interface is a decorator used to indicate that the tests in this class rely on a fixture to run. Our fixture value is of course CustomWebApplicationFactory<Startup> which wires up the factory we just built and provides our tests with the in-memory instance of our API.

As I mentioned earlier, we're testing "from the outside in" by issuing requests to our Web API from an actual http client. We create this client in the constructor by calling the web application factory's CreateClient() method.

Finally, we're in a position to write a test! The CanGetPlayers() test is pretty straightforward. It issues an http GET to the endpoint on our PlayersController responsible for returning a list of players. From there we examine the http response to assert the status and content meet our expectations.

The structure, number of lines and execution speed of this test is very similar to your typical unit test BUT we're running it against essentially the full Web API stack without any of the hassle or overhead involved in standing up an actual instance by deploying our code to another environment.

Running the Tests

I executed the tests using the dotnet test CLI command from within the Web.Api.IntegrationTests folder. The test run completes pretty quickly and is a signal to me that these types of tests could certainly be added to a continuous integration workflow to run along with our automated unit tests.

Wrapping Up

Building and maintaining test infrastructure is generally a pain and takes time away from higher value activities we'd like to spend time on. ASP.NET Core offers a valuable package in Microsoft.AspNetCore.Mvc.Testing that allows our project to quickly realize many of the benefits end to end tests offer without the typical costs associated with setting them up.

I hope you find some value in this post, and if you have any thoughts or questions, please share in the comments below.

Happy testing!

Source code here

Get notified on new posts

Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.

Get notified on new posts
X

Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.