Skip to main content
Version: v1.8 (stable)

How to write integration tests with Testcontainers

This guide shows you how to use the official Testcontainers.Topaz package to run a Topaz instance automatically in your test suite.

A fully working end-to-end example — including xUnit fixtures, Bicep deployments via the Azure CLI, and SDK-level assertions — is available in the Topaz GitHub repository.

Installation

dotnet add package TheCloudTheory.Topaz.Testcontainers

Prerequisites

1. DNS resolution

Topaz uses topaz.local.dev as its base hostname. Service endpoints use subdomains (e.g. myaccount.blob.storage.topaz.local.dev). Configure a wildcard DNS resolver so all subdomains resolve automatically:

macOS / Linux (dnsmasq)

# Install
brew install dnsmasq # macOS
sudo apt install dnsmasq # Debian/Ubuntu

# Route all *.topaz.local.dev to localhost
echo "address=/.topaz.local.dev/127.0.0.1" | sudo tee /etc/dnsmasq.d/topaz.conf

# Restart
sudo brew services restart dnsmasq # macOS
sudo systemctl restart dnsmasq # Linux

On macOS, also add a resolver file so the system uses dnsmasq for this domain:

sudo mkdir -p /etc/resolver
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/topaz.local.dev

Fallback: /etc/hosts

If you cannot install a DNS resolver, add an entry for each hostname your tests use:

127.0.0.1 topaz.local.dev
127.0.0.1 myaccount.blob.storage.topaz.local.dev
127.0.0.1 myvault.vault.topaz.local.dev

This requires a new entry per resource name and does not support wildcards.

2. Certificate trust

Topaz uses a self-signed TLS certificate. Call TopazContainer.InstallCertificateToCurrentUserStore() after the container starts to install it into the current user's trusted root store. Remove it on teardown with TopazContainer.UninstallCertificateFromCurrentUserStore(). No certificate validation bypass is needed.

3. Required environment variables

Two environment variables must be set whenever you call the Azure CLI (az) from within your tests or helper code:

VariableValuePurpose
AZURE_CORE_INSTANCE_DISCOVERYfalseSkips the AAD metadata discovery request that fails against a local emulator.
HTTPS_PROXYhttp://topaz.local.dev:44380Routes az traffic through Topaz's built-in CONNECT proxy so TLS termination works against the self-signed certificate.

Set them on the ProcessStartInfo before starting the process:

process.StartInfo.Environment["AZURE_CORE_INSTANCE_DISCOVERY"] = "false";
process.StartInfo.Environment["HTTPS_PROXY"] = "http://topaz.local.dev:44380";

Or export them in your shell / CI environment if you prefer a global approach:

export AZURE_CORE_INSTANCE_DISCOVERY=false
export HTTPS_PROXY=http://topaz.local.dev:44380
note

These variables are only required when invoking the Azure CLI. Azure SDK clients in the same process do not need HTTPS_PROXY — they rely on the certificate installed by TopazContainer.InstallCertificateToCurrentUserStore().

Basic usage

public sealed class MyServiceTests : IAsyncLifetime
{
private readonly TopazContainer _topaz = new TopazBuilder().Build();

public async Task InitializeAsync()
{
await _topaz.StartAsync();

// Installs the Topaz cert into CurrentUser\Root so HttpClient and
// Azure SDK clients trust it without disabling certificate validation.
TopazContainer.InstallCertificateToCurrentUserStore();
}

public async Task DisposeAsync()
{
TopazContainer.UninstallCertificateFromCurrentUserStore();
await _topaz.DisposeAsync();
}

[Fact]
public async Task StorageTest()
{
var blobUri = _topaz.GetStorageBlobUri("myaccount");
// Use blobUri with Azure.Storage.Blobs.BlobServiceClient ...

var cosmosUri = _topaz.GetCosmosDbUri("mycosmosaccount");
// Use cosmosUri with Microsoft.Azure.Cosmos.CosmosClient ...
}
}

The reusable fixture pattern

The best practice is to start Topaz once per test suite (not per test) using a shared setup fixture. All the major .NET test frameworks support this through their own lifecycle hooks.

NUnit

NUnit's [SetUpFixture] runs once per namespace assembly, making it ideal for a shared container:

using NUnit.Framework;

[SetUpFixture]
public class TopazFixture
{
private TopazContainer _topaz = null!;

[OneTimeSetUp]
public async Task StartAsync()
{
_topaz = new TopazBuilder().Build();
await _topaz.StartAsync();
TopazContainer.InstallCertificateToCurrentUserStore();
}

[OneTimeTearDown]
public async Task StopAsync()
{
TopazContainer.UninstallCertificateFromCurrentUserStore();
await _topaz.DisposeAsync();
}
}

xUnit

xUnit uses a shared IAsyncLifetime class fixture:

using Xunit;

public class TopazFixture : IAsyncLifetime
{
public TopazContainer Topaz { get; } = new TopazBuilder().Build();

public async Task InitializeAsync()
{
await Topaz.StartAsync();
TopazContainer.InstallCertificateToCurrentUserStore();
}

public async Task DisposeAsync()
{
TopazContainer.UninstallCertificateFromCurrentUserStore();
await Topaz.DisposeAsync();
}
}

[CollectionDefinition("Topaz")]
public class TopazCollection : ICollectionFixture<TopazFixture> { }

[Collection("Topaz")]
public class KeyVaultTests
{
private readonly TopazFixture _fixture;

public KeyVaultTests(TopazFixture fixture) => _fixture = fixture;

[Fact]
public async Task CreateKeyVault_ShouldSucceed()
{
var kvUri = _fixture.Topaz.GetKeyVaultUri("kv-test");
// arrange / act / assert using Azure SDK
}
}

MSTest

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TopazFixture
{
private static TopazContainer _topaz = null!;

[AssemblyInitialize]
public static async Task StartAsync(TestContext _)
{
_topaz = new TopazBuilder().Build();
await _topaz.StartAsync();
TopazContainer.InstallCertificateToCurrentUserStore();
}

[AssemblyCleanup]
public static async Task StopAsync()
{
TopazContainer.UninstallCertificateFromCurrentUserStore();
await _topaz.DisposeAsync();
}
}

Builder options

TopazBuilder exposes several optional configuration methods:

MethodDescription
WithDefaultSubscription(Guid)Sets the subscription ID created on startup. Defaults to a random GUID if omitted.
WithLogLevel(TopazLogLevel)Sets the verbosity of the Topaz host process (e.g. TopazLogLevel.Debug).
WithLoggingToFile(bool refreshLog = true)Enables logging to file. Pass false to keep the log from previous runs.
WithEmulatorIpAddress(string)Overrides the IP address the emulator listens on.

Example:

var topaz = new TopazBuilder()
.WithDefaultSubscription(Guid.Parse("00000000-0000-0000-0000-000000000001"))
.WithLogLevel(TopazLogLevel.Debug)
.WithLoggingToFile()
.Build();

Writing tests against Topaz

Once the container is running, use AzureLocalCredential and the standard Azure SDK clients. Retrieve service URIs via the typed helpers on TopazContainer:

using Azure;
using Azure.ResourceManager;
using Azure.ResourceManager.KeyVault;
using Azure.ResourceManager.KeyVault.Models;
using Topaz.Identity;
using Topaz.ResourceManager;

public class KeyVaultTests
{
private static readonly Guid SubscriptionId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private static readonly ArmClientOptions ArmClientOptions = TopazArmClientOptions.New;

[Test]
public void CreateKeyVault_ShouldBeAvailableAfterCreation()
{
var credential = new AzureLocalCredential(Globals.GlobalAdminId);
var armClient = new ArmClient(credential, SubscriptionId.ToString(), ArmClientOptions);

var resourceGroup = armClient
.GetDefaultSubscription()
.GetResourceGroup("rg-test").Value;

var operation = new KeyVaultCreateOrUpdateContent(
AzureLocation.WestEurope,
new KeyVaultProperties(
Guid.Empty,
new KeyVaultSku(KeyVaultSkuFamily.A, KeyVaultSkuName.Standard)));

resourceGroup.GetKeyVaults()
.CreateOrUpdate(WaitUntil.Completed, "kv-test", operation);

var kv = resourceGroup.GetKeyVault("kv-test");

Assert.That(kv.Value.Data.Name, Is.EqualTo("kv-test"));
}
}

Container-to-container setup

When your tests run inside a Docker container (e.g. CI), attach both containers to a shared network and inject the Topaz certificate into the secondary container's CA bundle:

var network = new NetworkBuilder().WithName(Guid.NewGuid().ToString("D")).Build();

var topaz = new TopazBuilder()
.WithNetwork(network)
.Build();

await topaz.StartAsync();

var certPem = TopazContainer.GetCertificatePem();

var myContainer = new ContainerBuilder()
.WithNetwork(network)
.WithExtraHost("topaz.local.dev", topaz.IpAddress)
.WithExtraHost("myaccount.blob.storage.topaz.local.dev", topaz.IpAddress)
// Add one WithExtraHost per subdomain your tests use
.WithResourceMapping(Encoding.UTF8.GetBytes(certPem), "/tmp/topaz.crt")
.WithEnvironment("REQUESTS_CA_BUNDLE", "/usr/lib64/az/lib/python3.12/site-packages/certifi/cacert.pem")
.Build();

await myContainer.StartAsync();

// Append the cert to the CA bundle inside the container:
await myContainer.ExecAsync(["/bin/sh", "-c",
"cat /tmp/topaz.crt >> /usr/lib64/az/lib/python3.12/site-packages/certifi/cacert.pem"]);

URI helpers

MethodReturns
GetResourceManagerUri()https://topaz.local.dev:{port}
GetKeyVaultUri(vaultName)https://{vaultName}.vault.topaz.local.dev:{port}
GetStorageBlobUri(account)https://{account}.blob.storage.topaz.local.dev:{port}
GetStorageQueueUri(account)https://{account}.queue.storage.topaz.local.dev:{port}
GetStorageTableUri(account)https://{account}.table.storage.topaz.local.dev:{port}
GetCosmosDbUri(account)https://{account}.documents.topaz.local.dev:{port}/
GetContainerRegistryUri(name)https://{name}.cr.topaz.local.dev:{port}
GetServiceBusAmqpUri(ns)amqp://{ns}.servicebus.topaz.local.dev:{port}
GetServiceBusHttpUri(ns)https://{ns}.servicebus.topaz.local.dev:{port}
GetEventHubAmqpUri(ns)amqp://{ns}.eventhub.topaz.local.dev:{port}
GetEventHubHttpUri(ns)https://{ns}.eventhub.topaz.local.dev:{port}
GetAppServiceUri(appName)https://{appName}.scm.azurewebsites.topaz.local.dev:{port}

All ports are mapped to random host ports at runtime.

Port reference

ConstantPortService
ResourceManagerPort8899ARM / Resource Manager (HTTPS)
KeyVaultPort8898Key Vault (HTTPS)
StoragePort8891Blob / Queue / Table / File Storage (HTTP)
CosmosDbPort8895Cosmos DB
ContainerRegistryPort8892Container Registry
ServiceBusAmqpPort8889Service Bus (AMQP)
ServiceBusHttpPort8887Service Bus (HTTP)
EventHubAmqpPort8888Event Hubs (AMQP)
EventHubHttpPort8897Event Hubs (HTTP)
AppServicePort8896App Service / Kudu
ConnectProxyPort44380HTTP CONNECT proxy (used for az login from the host)

The WaitStrategy targets port 8899 (ARM) as that is the last service to become ready.

Star on GitHub