DEV Community

Kashif Soofi
Kashif Soofi

Posted on

Integration Test SQL Server with testcontainers-dotnet

This is a continuation of an earlier post Integration Testing SQL Server Store. In this tutorial I will extend the sample to use testcontainers-dotnet to spin up database container and apply migrations before executing our integration tests.

Prior to this sample, pre-requisite of running integration tests was that database server is running either on machine or in a container and migrations are applied. This step removes that manual step.

Setup

Lets start by adding nuget packages

dotnet add package TestContainers.Container.Database.MsSql --version 1.5.4
Enter fullscreen mode Exit fullscreen mode

We would need to start 2 containers before running our integration tests.

  • Database Container - hosting the database server
  • Migrations Container - container to apply database migrations

MigrationsContainer

We will start by adding a new class MigrationsContainer inheriting from GenericContainer. We will add a helper method to create an image using Dockerfile from our db folder.

We will also add a helper method GetExitCodeAsync, this will use the docker client to wait for migrations container to exit, we need this so that we only run our integration tests after the migrations have been applied.

MigrationsContainer would look like following

using Docker.DotNet;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TestContainers.Container.Abstractions;
using TestContainers.Container.Abstractions.Hosting;
using TestContainers.Container.Abstractions.Images;

namespace Movies.Api.Tests.Integration;

public class MigrationsContainer : GenericContainer
{
    [ActivatorUtilitiesConstructor]
    public MigrationsContainer(IDockerClient dockerClient, ILoggerFactory loggerFactory)
        : base(CreateDefaultImage(), dockerClient, loggerFactory)
    {
        this.DockerClient = dockerClient;
    }

    internal IDockerClient DockerClient { get; }

    public async Task<long> GetExitCodeAsync()
    {
        var containerWaitResponse = await this.DockerClient.Containers.WaitContainerAsync(this.ContainerId);
        return containerWaitResponse.StatusCode;
    }

    private static IImage CreateDefaultImage()
    {
        return new ImageBuilder<DockerfileImage>()
            .ConfigureImage((context, image) =>
            {
                image.DockerfilePath = "Dockerfile";
                image.DeleteOnExit = true;
                image.BasePath = "../../../../db";
            })
            .Build();
    }
}
Enter fullscreen mode Exit fullscreen mode

DatabaseFixture

For DatabaseFixture we will add following fields

private readonly bool useServiceDatabase;

private readonly MsSqlContainer? databaseContainer;
private MigrationsContainer? migrationsContainer;
Enter fullscreen mode Exit fullscreen mode

useServiceDatabase will be helpful if we want to just use the database server running either on our host or running in a container and debug a test, this would reduce the startup time while debugging and fixing tests or running the red-green-refactor cycle.

Other 2 variables are to hold the references to containers and we will use those to cleanup after the tests are complete.

We will setup databaseContainer in the constructor by setting the Docker image to use using ConfigureDockerImageName and configuring username, password and database name using ConfigureDatabaseConfiguration extension method. Constructor will look like following

public DatabaseFixture()
{
    this.useServiceDatabase = Debugger.IsAttached;
    if (!this.useServiceDatabase)
    {
        Environment.SetEnvironmentVariable("REAPER_DISABLED", true.ToString());
        this.databaseContainer = new ContainerBuilder<MsSqlContainer>()
            .ConfigureDockerImageName("mcr.microsoft.com/mssql/server:2022-latest")
            .ConfigureDatabaseConfiguration("sa", "Password123", "Movies")
            .ConfigureContainer((contex, container) =>
            {
                container.Env.Add("MSSQL_PID", "Express");
            })
            .Build();
    }
}
Enter fullscreen mode Exit fullscreen mode

InitializeAsync

To start with if we are running in debug mode we will just set the connection string to point to database server already running either on our host or in container.

Fun starts if test is not running in debug mode. We start off by starting our database container. After that we will configure and build our migrations container. This needs to be done here becuase we need to pass connection string to database container to migrations container.

After the image is built, we will start migrations container and wait for it to exit, if the exit code is 0 that would indicate migrations are applied successfully. At this point we will setup ConnectionString in DatabaseFixture that will be used in the integration tests.

Complete code for InitializeAsync method is as follows

public async Task InitializeAsync()
{
    if (this.useServiceDatabase)
    {
        this.ConnectionString = "Server=localhost;Database=Movies;User ID=sa;Password=Password123;Encrypt=False";
        return;
    }

    await this.databaseContainer!.StartAsync();

    this.migrationsContainer = new ContainerBuilder<MigrationsContainer>()
        .ConfigureContainer((context, container) =>
        {
            var connectionString = $"Server=localhost;Port={this.databaseContainer.GetMappedPort(MsSqlContainer.DefaultPort)};User ID=sa;Password=Password123;Database=master;Encrypt=False";
            container.Command = new List<string>
            {
                connectionString,
            };
        })
        .ConfigureNetwork((hostContext, builderContext) =>
        {
            return new NetworkBuilder<UserDefinedNetwork>()
            .ConfigureNetwork((context, network) => { network.NetworkName = "host"; })
            .Build();
        })
        .Build();

    await this.migrationsContainer.StartAsync();
    var exitCode = await this.migrationsContainer.GetExitCodeAsync();
    if (exitCode > 0)
    {
        throw new Exception("Database migration failed");
    }

    this.ConnectionString = $"Server={this.databaseContainer.GetDockerHostIpAddress()};Port={this.databaseContainer.GetMappedPort(MsSqlContainer.DefaultPort)};User ID=sa;Password=Password123;Database=Movies;Encrypt=False";
}
Enter fullscreen mode Exit fullscreen mode

DisposeAsync

DisposeAsync is simple, if we are not running in debug mode, we double check if database and migration continers are not null we stop those using StopAsync.

It would look like as follows

public async Task DisposeAsync()
{
    if (this.useServiceDatabase)
    {
        return;
    }

    if (this.migrationsContainer != null)
    {
        await this.migrationsContainer.StopAsync();
    }

    if (this.databaseContainer != null)
    {
        await this.databaseContainer.StopAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

DatabaseFixture.cs

Complete source of DatabaseFixture.cs is as follows

using System.Diagnostics;
using TestContainers.Container.Abstractions.Hosting;
using TestContainers.Container.Abstractions.Networks;
using TestContainers.Container.Database.Hosting;
using TestContainers.Container.Database.MsSql;

namespace Movies.Api.Tests.Integration;

public class DatabaseFixture : IAsyncLifetime
{
    public string ConnectionString { get; private set; } = default!;

    private readonly bool useServiceDatabase;

    private readonly MsSqlContainer? databaseContainer;
    private MigrationsContainer? migrationsContainer;

    public DatabaseFixture()
    {
        this.useServiceDatabase = Debugger.IsAttached;
        if (!this.useServiceDatabase)
        {
            Environment.SetEnvironmentVariable("REAPER_DISABLED", true.ToString());
            this.databaseContainer = new ContainerBuilder<MsSqlContainer>()
                .ConfigureDockerImageName("mcr.microsoft.com/mssql/server:2022-latest")
                .ConfigureDatabaseConfiguration("sa", "Password123", "Movies")
                .ConfigureContainer((contex, container) =>
                {
                    container.Env.Add("MSSQL_PID", "Express");
                })
                .Build();
        }
    }

    public async Task InitializeAsync()
    {
        if (this.useServiceDatabase)
        {
            this.ConnectionString = "Server=localhost;Database=Movies;User ID=sa;Password=Password123;Encrypt=False";
            return;
        }

        await this.databaseContainer!.StartAsync();

        this.migrationsContainer = new ContainerBuilder<MigrationsContainer>()
            .ConfigureContainer((context, container) =>
            {
                var connectionString = $"Server=localhost;Port={this.databaseContainer.GetMappedPort(MsSqlContainer.DefaultPort)};User ID=sa;Password=Password123;Database=master;Encrypt=False";
                container.Command = new List<string>
                {
                    connectionString,
                };
            })
            .ConfigureNetwork((hostContext, builderContext) =>
            {
                return new NetworkBuilder<UserDefinedNetwork>()
                .ConfigureNetwork((context, network) => { network.NetworkName = "host"; })
                .Build();
            })
            .Build();

        await this.migrationsContainer.StartAsync();
        var exitCode = await this.migrationsContainer.GetExitCodeAsync();
        if (exitCode > 0)
        {
            throw new Exception("Database migration failed");
        }

        this.ConnectionString = $"Server={this.databaseContainer.GetDockerHostIpAddress()};Port={this.databaseContainer.GetMappedPort(MsSqlContainer.DefaultPort)};User ID=sa;Password=Password123;Database=Movies;Encrypt=False";
    }

    public async Task DisposeAsync()
    {
        if (this.useServiceDatabase)
        {
            return;
        }

        if (this.migrationsContainer != null)
        {
            await this.migrationsContainer.StopAsync();
        }

        if (this.databaseContainer != null)
        {
            await this.databaseContainer.StopAsync();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Test

Now Run Test should automatically perform following

  • Spin up a Database container
  • Create an image for migrations
  • Spin up migrations container to execute migrations
  • Execute integration tests
  • Stop containers

This is all for this post, this automates spining up database and migrations before running integration tests.

Integration Tests in CI

I have also added GitHub Actions workflow to run these integration tests as part of the CI when a change is pushed to main branch.

We will use the standard steps defined in Building and testing .NET guide. Running database server and migrations would be taken care by DatabaseFixture in Tests.Integration project.

Here is the complete listing of the workflow.

name: Integration Test SQL Server (testcontainers-dotnet)

on:
  push:
    branches: [ "main" ]
    paths:
     - 'integration-test-sqlserver-with-testcontainers-dotnet/**'

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: integration-test-sqlserver-with-testcontainers-dotnet

    steps:
      - uses: actions/checkout@v3
      - name: Setup .NET Core SDK
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: 7.0.x
      - name: Install dependencies
        run: dotnet restore
      - name: Build
        run: dotnet build --configuration Release --no-restore        
      - name: Run integration tests
        run: dotnet test --configuration Release --no-restore --no-build --verbosity normal
Enter fullscreen mode Exit fullscreen mode

Source

Source code for the demo application is hosted on GitHub in blog-code-samples repository.

Source for Integration Test SQL Server (testcontainers-dotnet) workflow is in integration-test-sqlserver-testcontainers-dotnet.yml.

References

In no particular order

Top comments (0)