Using Testcontainers in .NET

In gitclub-dotnet I need a few services to run for the integration tests. In this article we will take a look at Testcontainers for .NET, which is ...

[...] a library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions. The library is built on top of the .NET Docker remote API and provides a lightweight implementation to support your test environment in all circumstances.

All code can be found in a Git repository at:

Using Testcontainers with MSTest

In the gitclub-dotnet we have already written a docker-compose.yaml file, which is responsible for spinning up a Postgres instance and OpenFGA containers.

version: '3.8'

networks:
  openfga:

services:
  postgres:
    image: postgres:16
    container_name: postgres
    networks:
      - openfga
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
    volumes:
      - "./postgres/postgres.conf:/usr/local/etc/postgres/postgres.conf"
      - ../sql/openfga.sql:/docker-entrypoint-initdb.d/1-openfga.sql
      - ../sql/gitclub.sql:/docker-entrypoint-initdb.d/2-gitclub.sql
      - ../sql/gitclub-versioning.sql:/docker-entrypoint-initdb.d/3-gitclub-versioning.sql
      - ../sql/gitclub-notifications.sql:/docker-entrypoint-initdb.d/4-gitclub-notifications.sql
      - ../sql/gitclub-replication.sql:/docker-entrypoint-initdb.d/5-gitclub-replication.sql
      - ../sql/gitclub-tests.sql:/docker-entrypoint-initdb.d/6-gitclub-tests.sql
      - ../sql/gitclub-data.sql:/docker-entrypoint-initdb.d/7-gitclub-data.sql
    command: "postgres -c config_file=/usr/local/etc/postgres/postgres.conf"
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U postgres" ]
      interval: 5s
      timeout: 5s
      retries: 5

  migrate:
    depends_on:
      postgres:
        condition: service_healthy
    image: openfga/openfga:latest
    container_name: migrate
    command: migrate
    environment:
      - OPENFGA_DATASTORE_ENGINE=postgres
      - OPENFGA_DATASTORE_URI=postgres://postgres:password@postgres:5432/postgres?sslmode=disable&search_path=openfga
    networks:
      - openfga

  openfga:
    depends_on:
      migrate:
        condition: service_completed_successfully
    image: openfga/openfga:latest
    container_name: openfga
    environment:
      - OPENFGA_DATASTORE_ENGINE=postgres
      - OPENFGA_DATASTORE_URI=postgres://postgres:password@postgres:5432/postgres?sslmode=disable&search_path=openfga
      - OPENFGA_LOG_FORMAT=json
    command: run
    networks:
      - openfga
    ports:
      # Needed for the http server
      - "8080:8080"
      # Needed for the grpc server (if used)
      - "8081:8081"
      # Needed for the playground (Do not enable in prod!)
      - "3000:3000"
    healthcheck:
      test: ['CMD', '/usr/local/bin/grpc_health_probe', '-addr=openfga:8081']
      interval: 5s
      timeout: 30s
      retries: 3

  gitclub-fga-model-docker:
    depends_on:
      openfga:
        condition: service_healthy
    image: openfga/cli:latest
    container_name: gitclub-fga-model
    networks:
      - openfga
    volumes:
      - ../fga/gitclub.fga.yaml:/gitclub.fga.yaml
      - ../fga/gitclub-model.fga:/gitclub-model.fga
      - ../fga/gitclub-tuples.yaml:/gitclub-tuples.yaml
    command: store import --api-url http://openfga:8080 --file /gitclub.fga.yaml --store-id ${FGA_STORE_ID}

We now start the Testcontainers implementation by adding all files required by the Docker containers as a Link in our Solution. This has the nice side-effect, that you can use the files for both, the docker-compose.yaml and the Testcontainers.

Make sure to always copy the files to the output directory.

<Project Sdk="Microsoft.NET.Sdk">

  <!-- ... -->

  <ItemGroup>
    <Folder Include="Resources\docker\" />
    <Folder Include="Resources\fga\" />
    <Folder Include="Resources\sql\" />
  </ItemGroup>

  <ItemGroup>
    <None Include="..\..\docker\postgres\postgres.conf" Link="Resources\docker\postgres.conf">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\fga\gitclub-model.fga" Link="Resources\fga\gitclub-model.fga">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\fga\gitclub-tuples.yaml" Link="Resources\fga\gitclub-tuples.yaml">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\fga\gitclub.fga.yaml" Link="Resources\fga\gitclub.fga.yaml">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\gitclub-data.sql" Link="Resources\sql\gitclub-data.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\gitclub-notifications.sql" Link="Resources\sql\gitclub-notifications.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\gitclub-replication.sql" Link="Resources\sql\gitclub-replication.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\gitclub-tests.sql" Link="Resources\sql\gitclub-tests.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\gitclub-versioning.sql" Link="Resources\sql\gitclub-versioning.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\gitclub.sql" Link="Resources\sql\gitclub.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    <None Include="..\..\sql\openfga.sql" Link="Resources\sql\openfga.sql">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
  </ItemGroup>

  <!-- ... -->

</Project>

We can then create a class DockerContainers, which is basically a one to one translation from the docker-compose.yaml to the Testcontainers syntax.

Please note, that it only needs to be static, because of the MSTest lifecycle, which requires static methods for class and assembly level test initialization.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Configurations;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Networks;

namespace GitClub.Tests
{
    public static class DockerContainers
    {
        public static INetwork OpenFgaNetwork = new NetworkBuilder()
            .WithName("openfga")
            .WithDriver(NetworkDriver.Bridge)
            .Build();

        public static IContainer PostgresContainer = new ContainerBuilder()
            .WithName("postgres")
            .WithImage("postgres:16")
            .WithNetwork(OpenFgaNetwork)
            .WithPortBinding(hostPort: 5432, containerPort: 5432)
            // Mount Postgres Configuration and SQL Scripts 
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/docker/postgres.conf"), "/usr/local/etc/postgres/postgres.conf")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/openfga.sql"), "/docker-entrypoint-initdb.d/1-openfga.sql")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/gitclub.sql"), "/docker-entrypoint-initdb.d/2-gitclub.sql")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/gitclub-versioning.sql"), "/docker-entrypoint-initdb.d/3-gitclub-versioning.sql")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/gitclub-notifications.sql"), "/docker-entrypoint-initdb.d/4-gitclub-notifications.sql")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/gitclub-replication.sql"), "/docker-entrypoint-initdb.d/5-gitclub-replication.sql")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/gitclub-tests.sql"), "/docker-entrypoint-initdb.d/6-gitclub-tests.sql")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/sql/gitclub-data.sql"), "/docker-entrypoint-initdb.d/7-gitclub-data.sql")
            // Set Username and Password
            .WithEnvironment(new Dictionary<string, string>
            {
                    {"POSTGRES_USER", "postgres" },
                    {"POSTGRES_PASSWORD", "password" },
            })
            // Start Postgres with the given postgres.conf.
            .WithCommand([
                "postgres",
                "-c",
                "config_file=/usr/local/etc/postgres/postgres.conf"
            ])
            // Wait until the Port is exposed.
            .WithWaitStrategy(Wait
                .ForUnixContainer()
                .UntilPortIsAvailable(5432))
            .Build();

        public static IContainer OpenFgaMigrateContainer = new ContainerBuilder()
            .WithName("openfga-migration")
            .WithImage("openfga/openfga:latest")
            .DependsOn(PostgresContainer)
            .WithNetwork(OpenFgaNetwork)
            .WithEnvironment(new Dictionary<string, string>
            {
                {"OPENFGA_DATASTORE_ENGINE", "postgres" },
                {"OPENFGA_DATASTORE_URI", "postgres://postgres:password@postgres:5432/postgres?sslmode=disable&search_path=openfga" }
            })
            .WithCommand("migrate")
            .Build();

        public static IContainer OpenFgaServerContainer = new ContainerBuilder()
            .WithName("openfga-server")
            .WithImage("openfga/openfga:latest")
            .DependsOn(OpenFgaMigrateContainer)
            .WithNetwork(OpenFgaNetwork)
            .WithCommand("run")
            .WithPortBinding(hostPort: 8080, containerPort: 8080)
            .WithPortBinding(hostPort: 8081, containerPort: 8081)
            .WithPortBinding(hostPort: 3000, containerPort: 3000)
            .WithEnvironment(new Dictionary<string, string>
            {
                {"OPENFGA_DATASTORE_ENGINE", "postgres" },
                {"OPENFGA_DATASTORE_URI", "postgres://postgres:password@postgres:5432/postgres?sslmode=disable&search_path=openfga" }
            })
            .WithWaitStrategy(Wait
                .ForUnixContainer()
                .UntilMessageIsLogged("HTTP server listening on '0.0.0.0:8080'.."))
            .Build();

        public static IContainer OpenFgaModelContainer = new ContainerBuilder()
            .WithName("openfga-model")
            .WithImage("openfga/cli:latest")
            .DependsOn(OpenFgaServerContainer)
            .WithNetwork(OpenFgaNetwork)
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/fga/gitclub.fga.yaml"), "/gitclub.fga.yaml")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/fga/gitclub-model.fga"), "/gitclub-model.fga")
            .WithBindMount(Path.Combine(Directory.GetCurrentDirectory(), "Resources/fga/gitclub-tuples.yaml"), "/gitclub-tuples.yaml")
            .WithCommand([
                "store",
                "import",
                "--api-url", "http://openfga-server:8080",
                "--file", "/gitclub.fga.yaml",
                "--store-id", "01HP82R96XEJX1Q9YWA9XRQ4PM"
            ])
            .Build();

        public static async Task StartAllContainersAsync()
        {
            await PostgresContainer.StartAsync();
            await OpenFgaMigrateContainer.StartAsync();
            await OpenFgaServerContainer.StartAsync();
            await OpenFgaModelContainer.StartAsync();
        }

        public static async Task StopAllContainersAsync()
        {
            await PostgresContainer.StopAsync();
            await OpenFgaMigrateContainer.StopAsync();
            await OpenFgaServerContainer.StopAsync();
            await OpenFgaModelContainer.StopAsync();
        }
    }
}

In the IntegrationTestBase, which is the base class for all integration tests to be written, we will then use DockerContainers in the assembly-level test initialization.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// ...

namespace GitClub.Tests
{
    [TestClass]
    public abstract class IntegrationTestBase
    {
        // ...

        [AssemblyInitialize]
        public static async Task AssemblyInitializeAsync(TestContext context)
        {
            await DockerContainers.StartAllContainersAsync();
        }

        [AssemblyCleanup]
        public static async Task AssemblyCleanupAsync()
        {
            await DockerContainers.StopAllContainersAsync();
        }

        // ...
    }
}

Conclusion

And that's it. You will now see the Containers spinning up for the tests, so there's no need for manually running a docker-compose before executing the tests.

To get a more reliable container initialization sequence, you'll probably need better Wait Strategy, which is described in the Testcontainers documentation at:

Enjoy your integration tests!