· 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](/_astro/github-docker-cosmosdb.td8Cnsn1.png)
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. TheInitializeAsync
method starts the container, and theDisposeAsync
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. TheInitializeAsync
method starts the container, and theDisposeAsync
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.