· Diego Martin · tutorials  · 9 min read

Testing CosmosDb access in GitHub Actions with its Docker emulator and xUnit

Automated testing in CI/CD with GitHub actions, Docker, FluentDocker, Testcontainers and CosmosDb emulator

Automated testing  in CI/CD with GitHub actions, Docker, FluentDocker, Testcontainers and CosmosDb emulator

Integration testing is crucial for ensuring that different parts of an application work together as expected. When dealing with external services like databases, the complexity increases. In this tutorial, we’ll explore a strategy for testing a .NET application that accesses CosmosDB using the CosmosDB Docker emulator. We’ll use two different mechanisms to spin up the CosmosDB database with Docker: FluentDocker and Testcontainers, and automate tests using xUnit testing framework.

Pre-requirements

Before we dive into the implementation, ensure you have the following installed on your local machine:

  • .NET 8 SDK
  • Docker

This setup works on both Windows and Unix systems, making it a versatile solution for developers using different operating systems.

Scenario Description

We have a simple service that uses the CosmosDB SDK to access a CosmosDB server and create a database. The service has a dependency on the CosmosDb client which needs to be injected through its constructor, and the functionality to test returns whether the operation to create a database succeeded or not.

Here’s the code for our DatabaseCreator service:

public class DatabaseCreatorTestContainer
{
    private readonly CosmosClient _cosmosClient;

    public DatabaseCreatorTestContainer(CosmosClient cosmosClient)
        => _cosmosClient = cosmosClient;
    
    public async Task<DatabaseCreatorResult> Create(string databaseName)
    {
        var result = await _cosmosClient.CreateDatabaseIfNotExistsAsync(databaseName);
        var databaseCreatorResult = new DatabaseCreatorResult(result.StatusCode is HttpStatusCode.OK or HttpStatusCode.Created);
        return databaseCreatorResult;
    }
}

public record DatabaseCreatorResult(bool IsSuccessful);

Our goal is to test this functionality with the convenience of spinning up a CosmosDB emulator with Docker. Instead of manually running the container, we rely on xUnit lifecycle and use two different approaches: Ductus Fluent Docker and Testcontainers.

Common Testing Strategy

The overall strategy involves creating unit tests with xUnit to test database creation both when validating SSL and when bypassing this validation.

CosmosDB uses HTTPS protocol, and the emulator provides a certificate https://localhost:8081/_explorer/emulator.pem that could be added to trusted authorities. However, for testing purposes, we don’t want to do this or rely on this.

Using Ductus Fluent Docker

Ductus Fluent Docker is a .NET library that serves as a wrapper for Docker commands. It allows developers to manage Docker containers programmatically using a fluent API, which simplifies the process of setting up and tearing down containers during tests. In our case, it will help us spin up a CosmosDB emulator container conveniently.

Setting Up the Test Fixture

We define a fixture class that handles the lifecycle of the CosmosDB emulator container using Fluent Docker. This fixture ensures that the container starts when tests run and stops when tests finish.

Here’s the implementation of the FluentDockerFixture class:

public class FluentDockerFixture 
    : IAsyncLifetime
{
    private IContainerService? _cosmosDbService;

    public static string AccountEndpoint => "https://localhost:8081";
    public static string AuthKeyOrResourceToken => "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";

    public Task InitializeAsync()
    { 
        _cosmosDbService =
            new Builder()
                .UseContainer()
                .UseImage("mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest")
                .ExposePort(8081, 8081)
                .ExposePortRange(10250, 10255)
                .WithEnvironment(
                    "AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE=127.0.0.1")
                .DeleteIfExists()
                .RemoveVolumesOnDispose()
                .WaitForHttps("https://localhost:8081/_explorer/emulator.pem", ignoreSslErrors: true)
                .Build();

        var container = _cosmosDbService.Start();
        return Task.CompletedTask;
    }

    public Task DisposeAsync()
    {
        _cosmosDbService?.Stop();
        _cosmosDbService?.Remove();
        return Task.CompletedTask;
    }
}

Notice the following:

  • Account Endpoint and Auth Token: These are predefined for the CosmosDB emulator.
  • IAsyncLifetime Interface: This xUnit interface allows us to control the lifecycle of the test fixture. The InitializeAsync method starts the container, and the DisposeAsync method stops and removes it.

Extension Methods: ExposePortRange: Maps a range of ports. WaitForHttps: Waits for the emulator to be fully ready by polling a URL, ensuring no test runs until the emulator is ready.

There are a few custom extension methods to expose a range of ports and, more importantly, to design a wait strategy for CosmosDb emulator ensuring no test runs until the emulator is ready. The emulator is considered ready, when the URL for its certificate returns something. There could be other strategies, but I like this one.

internal static class ContainerBuilderExtensions
{
    public static ContainerBuilder ExposePortRange(this ContainerBuilder containerBuilder, int start, int end)
    {
        for (var port = start; port <= end; port++)
        {
            containerBuilder.ExposePort(port);
        }

        return containerBuilder;
    }

    public static ContainerBuilder WaitForHttps(
        this ContainerBuilder builder, 
        string url, 
        bool ignoreSslErrors = false,
        int retries = 40, 
        int delayMilliseconds = 5000)
    {
        return builder.Wait("Wait for Https", (_, count) =>
        {
            if (count > retries)
            {
                var secondsWaited = count * (delayMilliseconds / 1000);
                throw new Exception($"Failed to wait for {url} after {secondsWaited} seconds");
            }

            var httpClientHandler =
                ignoreSslErrors
                    ? new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
                    : new HttpClientHandler();

            using var client = new HttpClient(httpClientHandler);
            try
            {
                var response = client.GetAsync(url).Result;
                if (response.IsSuccessStatusCode)
                {
                    return 0;
                }
            }
            catch (Exception)
            {
                // ignored
            }

            Thread.Sleep(delayMilliseconds);
            return 1;
        });
    }
}

Writing the Tests

Now the self-explanatory tests can be designed and executed to test expectations when validating SSL and when ignoring it (in case it’s not in Trusted Authority in local operating system).

public class DatabaseCreatorFluentDockerTests(ITestOutputHelper testOutputHelper)
    : IClassFixture<FluentDockerFixture>
{
    [Fact]
    public async Task Secure_Client_Should_Fail()
    {
        // Given
        var cosmosClientOptions =
            new CosmosClientOptions
            {
                ConnectionMode = ConnectionMode.Gateway
            };

        var cosmosClient = new CosmosClient(FluentDockerFixture.AccountEndpoint, FluentDockerFixture.AuthKeyOrResourceToken, cosmosClientOptions);
        var databaseName = Guid.NewGuid().ToString();

        var sut = new DatabaseCreatorTestContainer(cosmosClient);

        // When
        testOutputHelper.WriteLine($"Attempting to create {databaseName}");
        var exception = await Assert.ThrowsAsync<HttpRequestException>(() => sut.Create(databaseName));
        testOutputHelper.WriteLine("Attempt finished");

        // Then
        Assert.Contains("The SSL connection could not be established", exception.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Insecure_Client_Should_Succeed()
    {
        // Given
        var cosmosClientOptions =
            new CosmosClientOptions
            {
                ConnectionMode = ConnectionMode.Gateway,
                HttpClientFactory = () =>
                {
                    var httpMessageHandler =
                        new HttpClientHandler
                        {
                            ServerCertificateCustomValidationCallback =
                                HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
                        };

                    return new HttpClient(httpMessageHandler);
                },
            };

        var cosmosClient = new CosmosClient(FluentDockerFixture.AccountEndpoint, FluentDockerFixture.AuthKeyOrResourceToken, cosmosClientOptions);
        var databaseName = Guid.NewGuid().ToString();
        var sut = new DatabaseCreatorTestContainer(cosmosClient);

        // When
        testOutputHelper.WriteLine($"Attempting to create {databaseName}");
        var result = await sut.Create(databaseName);
        testOutputHelper.WriteLine("Attempt finished");

        // Then
        Assert.True(result.IsSuccessful);
    }
}

By using IClassFixture<FluentDockerFixture> we share the same Docker container for all test executions within the class, ensuring efficiency and consistency. More on this could be found in xUnit documentation, but it’s out of scope.

Using Testcontainers

Testcontainers is a .NET library that provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. It simplifies the process of integrating Docker containers into your tests, allowing you to focus on writing tests rather than managing Docker commands.

Setting Up the Test Fixture

The TestContainerFixture class handles the lifecycle of the CosmosDB emulator container using Testcontainers. It ensures that the container starts when tests run and stops when tests finish. Additionally, it exposes an HTTP client required for configuring CosmosDB options, as the CosmosDB client must use this client to connect to the emulator.

Here’s the implementation of the TestContainerFixture class:

public class TestContainerFixture 
    : IAsyncLifetime
{
    private CosmosDbContainer _cosmosDbContainer = null!;
    public string CosmosDbConnectionString { get; private set; } = null!;
    public HttpClient CosmosDbHttpClient { get; private set; } = null!;
    
    public async Task InitializeAsync()
    {
        _cosmosDbContainer = 
            new CosmosDbBuilder()
                .WithEnvironment("AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", "127.0.0.1")
                .Build();
        await _cosmosDbContainer.StartAsync();
        CosmosDbConnectionString = _cosmosDbContainer.GetConnectionString();
        CosmosDbHttpClient = _cosmosDbContainer.HttpClient;
    }

    public Task DisposeAsync() => _cosmosDbContainer.StopAsync();
}

Notice the following:

  • Connection string and HttpClient: The fixture exposes the connection string and an HTTP client. The HTTP client is necessary for configuring CosmosDB options to connect to the emulator.
  • IAsyncLifetime Interface: This xUnit interface allows us to control the lifecycle of the test fixture. The InitializeAsync method starts the container, and the DisposeAsync method stops and removes it.

Writing the Tests

Now the tests for the same scenarios but with Testcontainers fixture.

public class DatabaseCreatorTestContainerTests(TestContainerFixture testContainerFixture, ITestOutputHelper testOutputHelper)
    : IClassFixture<TestContainerFixture>
{
    [Fact]
    public async Task Secure_Client_Should_Fail()
    {
        // Given
        var cosmosClientOptions =
            new CosmosClientOptions
            {
                ConnectionMode = ConnectionMode.Gateway
            };

        var cosmosClient = new CosmosClient(testContainerFixture.CosmosDbConnectionString, cosmosClientOptions);
        var databaseName = Guid.NewGuid().ToString();
        
        var sut = new DatabaseCreatorTestContainer(cosmosClient);
        
        // When
        testOutputHelper.WriteLine(
            $"Attempting to create {databaseName} " +
            $"with connection string {testContainerFixture.CosmosDbConnectionString}");
        var exception = await Assert.ThrowsAsync<HttpRequestException>(() => sut.Create(databaseName));
        testOutputHelper.WriteLine("Attempt finished");

        // Then
        Assert.Contains("The SSL connection could not be established", exception.Message, StringComparison.OrdinalIgnoreCase);
    }
    
    [Fact]
    public async Task Insecure_Client_Should_Succeed()
    {
        // Given
        var cosmosClientOptions =
            new CosmosClientOptions
            {
                ConnectionMode = ConnectionMode.Gateway,
                HttpClientFactory = () => testContainerFixture.CosmosDbHttpClient
            };

        var cosmosClient = new CosmosClient(testContainerFixture.CosmosDbConnectionString, cosmosClientOptions);
        var databaseName = Guid.NewGuid().ToString();
        var sut = new DatabaseCreatorTestContainer(cosmosClient);
        
        // When
        testOutputHelper.WriteLine(
            $"Attempting to create {databaseName} " +
            $"with connection string {testContainerFixture.CosmosDbConnectionString}");
        var result = await sut.Create(databaseName);
        testOutputHelper.WriteLine("Attempt finished");

        // Then
        Assert.True(result.IsSuccessful);
    }
}

By using IClassFixture<TestContainerFixture> we share the same Docker container for all test executions within the class, ensuring efficiency and consistency.

Notice how the HttpClientFactory now returns the HTTP client provided by the Testcontainers fixture. This client is necessary for the CosmosDB client to connect to the emulator.

Configuring Tests to Run Sequentially or in Parallel

In xUnit, you can control whether tests run in parallel or sequentially by configuring the xunit.runner.json file. By default, xUnit runs tests in parallel to improve performance. However, for troubleshooting or specific scenarios, you may want to run tests sequentially.

xUnit Configuration

Create or update the xunit.runner.json file in the root of your test project with the following content:

{
  "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
  "maxParallelThreads": 0
}

Automating CI/CD with GitHub Actions

In this section, we will explore how to automate the testing process in a CI/CD pipeline using GitHub Actions. We’ll discuss setting up the workflow file, running the tests, and ensuring the Docker containers are properly handled in the CI/CD environment.

Why Use GitHub Actions?

GitHub Actions is a powerful tool for automating your software development workflows. It allows you to define custom workflows directly in your GitHub repository, enabling continuous integration and continuous deployment (CI/CD). With GitHub Actions, you can automate the process of running tests, building your application, and deploying it to various environments.

GitHub Actions Workflow Configuration

To automate the testing of our CosmosDB integration, we’ll create a GitHub Actions workflow that runs our unit tests on every push to the main branch. Here’s how you can set it up:

Create a .github/workflows/test-cosmosdb.yml file in your repository with the following content:

name: Test CosmosDb pipeline on Linux

on:
  push:
    branches:
      - main

jobs:
  unit_tests:
    name: Run .NET unit tests (Linux)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Run .NET tests
        run: dotnet test

Some online examples show some additional steps before running the tests, where the SDK .NET is configured, but this isn’t really needed because the runner already has the .NET SDK pre-built.

- name: Install .NET SDK
  uses: actions/setup-dotnet@v2
  with:
      dotnet-version: '8.0.x'

Also, there could be a step to restore dependencies, but the dotnet test step already does that implicitly.

- name: Restore dependencies
  run: dotnet restore

Repository

All the code used in this tutorial, including the GitHub Actions workflow, is available in my repository

By following this approach, you can ensure that your integration tests run consistently and reliably, both locally and in your CI/CD pipeline.

Back to Blog

Related Posts

View All Posts »
NuGet customization and some gotchas

NuGet customization and some gotchas

Did you know you could customize the path where NuGet installs packages? And did you know you could apply some global NuGet configuration so that you could add custom authentication outside your solutions? Find out more on this post