Integration Testing with Dotnet Testcontainers – Part 2

In last week’s episode, we got an introduction to testcontainers-dotnet and WebApplicationFactory. In this installment, we’re going to take what we learned there and build out an actual integration test. We’ll keep it pretty basic but here are the key takeaways:

  • testcontainers-dotnet gives us a clean way to isolate our tests by allowing us to create a database per test, or a group of (closely) related tests. Isolating our tests like that allows us to run them in parallel and/or in any sequence. It allows us to use databases in a disposable manner – giving us a means to seed the db with the test data that we need, specifically for the tests we’re executing against them.
  • Creating databases with testcontainers-dotnet is just an example use-case. It’s an obvious use-case as most solutions will have a database of some-sort for data persistence. But it doesn’t need to be a database. You can use this library and approach with any other docker containers – perhaps a Redis instance or an Nginx web server or anything else you can package up in a docker container.
  • It’s all just C# and dotnet. This allows you to enjoy the goodness that docker brings to development while not having to leave your comfort zone – C#, dotnet and all the wealth of testing tools and frameworks in the dotnet ecosystem.

Setup

For a demo, I have created a simple Create/Read/Update/Delete (CRUD) Weather Forecasts API. You can download a fully working example from my GitHub account, here:

https://www.github.com/tvaidyan/test-containers-part-two

Run the Example App

  • There is a docker-compose file at the root of the repository. This file contains the setup of two resources:
    • Microsoft SQL Server 2019
    • RoundhousE Database Migrations. When instantiated, it will run the database migrations scripts that resides in the db-migrations folder. Right now, that comprises of creating the lone WeatherForecasts table.
  • You can run docker-compose up to have it setup the database locally and have it run database migrations, creating the WeatherForecasts table. Do not close this terminal window as it will also decommission your database.
  • After the database is up and running, open up another terminal window. Navigate to the .API folder and run dotnet run to start up the API.
  • You can test the API endpoints using Postman
POST API call adding a new weather forecast
HTTP POST
HTTP Get Request in Postman fetching the weather forecast for Las Vegas
HTTP GET

There is also an HTTP DELETE and HTTP PUT request in this example repository.

Other Relevant Details

  • The example app uses the MediatR library employing the mediator pattern.
  • The app uses Dapper for database access. There is a thin-wrapper around Dapper to allow the SQL queries to exist in standalone .sql files which are then referenced in the handlers, rather than hard-coding those queries directly within the C# class files.

Let’s Write Some Integration Tests

Although the example app is rather basic, this same basic pattern and approach can be adopted to fit your specific needs.

  • Setup your test database to have your desired starting state. That is, populate your tables with the test data that you need in them to run and prove out your theories.
  • Run the relevant API calls, have it interact with your test database.
  • Check your database to see if the data was modified by your API correctly (in the case for an Add, Update or Delete) or if it fetched (GET) the results you expected.

Here’s an example test that tests both the Add Weather Forecast and Get Weather Forecast endpoints.

using FluentAssertions;
using System.Data.SqlClient;
using System.Text;
using System.Text.Json;
using TestContainersPartTwo.Api.Weather;
using TestContainersPartTwo.Tests.Shared;
using Xunit;

namespace TestContainersPartTwo.Tests;
public class AddWeatherForecastTests : IClassFixture<DbFixture>
{
    private readonly DbFixture dbFixture;

    public AddWeatherForecastTests(DbFixture dbFixture)
    {
        this.dbFixture = dbFixture;
    }

    [Fact]
    public async Task CanSaveAndRetrieveWeatherForecasts()
    {
        var factory = new CustomWebApplicationFactory<Program>(services =>
        {
            services.SetupDatabaseConnection(dbFixture.DatabaseConnectionString);
        });

        var client = factory.CreateClient();

        CreateRandomSamplingOfWeatherForecastsInTheDatabase();

        var forecast = new AddWeatherForecastRequest
        {
            City = "New York",
            Summary = "Sunny",
            TemperatureC = 30
        };

        HttpContent c = new StringContent(JsonSerializer.Serialize(forecast), Encoding.UTF8, "application/json");
        await client.PostAsync("/weatherforecast", c);

        var response = (await client.SendAsync(new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri("https://localhost:7087/weatherforecast"),
            Content = new StringContent(JsonSerializer.Serialize(new WeatherForecast { City = "New York" }), Encoding.UTF8, "application/json")
        })).Content.ReadAsStringAsync().Result;

        var getResponse = JsonSerializer.Deserialize<GetWeatherForecastResponse>(response, new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        })!;

        getResponse.WeatherForecasts.Should().HaveCount(1);
        getResponse.WeatherForecasts.Should().ContainSingle(x =>
            x.TemperatureC == 30
            && x.Summary == "Sunny");
    }

    private void CreateRandomSamplingOfWeatherForecastsInTheDatabase()
    {
        var insertSQL = new StringBuilder();
        for (int i = 0; i < 25; i++)
        {
            insertSQL.AppendLine("INSERT INTO WeatherForecasts ([City],[TemperatureC],[CreatedDate]" +
                ",[Summary]) " +
                $" VALUES('TestCity-{Randomizer.GetRandomString(5)}',{i}, GETUTCDATE(), 'Test-{Randomizer.GetRandomString(5)}');");
        }

        using (SqlConnection connection = new SqlConnection(
               dbFixture.DatabaseConnectionString))
        {
            SqlCommand command = new SqlCommand(insertSQL.ToString(), connection);
            command.Connection.Open();
            command.ExecuteNonQuery();
        }
    }
}

Things to Note:

  • The CreateRandomSamplingOfWeatherForecastsInTheDatabase method is generating SQL INSERT statements and inserting those into the WeatherForecasts table prior to invoking the API’s POST and GET endpoints. This is done to ensure that that the API invocations work as expected against existing data. For instance, the GET API should return only the weather forecast for New York (that was inserted through the API endpoint) and nothing else and having other test data in the table allows for such a test.
  • The testcontainers-dotnet related code is tucked away in a separate DbFixture class. You can check this out in the GitHub repo.
  • Also checkout the other helper files in the Shared folder in the project to get a complete understanding of how these tests are running.
  • I’m using xUnit as my test framework here. It allows me to use the IClassFixture interface which provides a way for me to bring in the test database created by testcontainers-dotnet into my test class in a clean manner and have that database available for all the tests in that one test class. You can learn more about it, here.

Closing Thoughts

While the previous post introduced the testcontainers-dotnet and WebApplicationFactory libraries, in this one, we were able to dig a bit deeper and see how you can utilize them in an actual ASP.NET Web API test project. My example shows how to test the POST and GET endpoints. Can you write the tests for the DELETE and PUT endpoints?

The companion GitHub repository for this post is located here:

https://www.github.com/tvaidyan/test-containers-part-two

Leave a Comment

Your email address will not be published. Required fields are marked *