Skip to main content

Testing infrastructure-as-code with Topaz

In this tutorial, we will write automated tests that apply Terraform and Bicep configurations against Topaz, then assert the resulting resources using the Azure SDK. This goes beyond verifying that apply runs without error — it validates the actual shape, SKU, and tags of the provisioned resources.

A complete runnable example is available in Examples/Topaz.Example.IaCTesting.

What you will build

  • An xUnit test project that starts Topaz via Testcontainers
  • A Terraform configuration and a Bicep template that each provision a Storage account
  • Assertions against the provisioned resources using Azure.ResourceManager
  • A negative test that verifies an absent resource is not returned

Prerequisites

  • Topaz installed (see Getting started)
  • DNS setup completed and Topaz certificate trusted
  • Terraform CLI installed (terraform --version)
  • Azure CLI installed (az --version)
  • .NET 10 SDK installed

Step 1: Create the test project

dotnet new xunit -n Topaz.Example.IaCTesting
cd Topaz.Example.IaCTesting
dotnet add package Testcontainers
dotnet add package Azure.ResourceManager
dotnet add package Azure.ResourceManager.Storage
dotnet add package Azure.Identity
dotnet add package TheCloudTheory.Topaz.Identity
dotnet add package TheCloudTheory.Topaz.ResourceManager

Step 2: Create the shared Topaz fixture

The fixture starts a single Topaz container for the entire test suite. The ARM port (8899) is used as the readiness signal.

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;

public class TopazFixture : IAsyncLifetime
{
public IContainer Container { get; private set; } = null!;

public async Task InitializeAsync()
{
Container = new ContainerBuilder()
.WithImage("thecloudtheory/topaz-host:latest")
.WithPortBinding(8890, 8890) // Storage
.WithPortBinding(8891, 8891) // Blob Storage
.WithPortBinding(8899, 8899) // ARM / Resource Manager
.WithName("topaz.local.dev")
.WithCommand("--log-level", "Warning")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(8899))
.Build();

await Container.StartAsync();
}

public async Task DisposeAsync() => await Container.DisposeAsync();
}

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

Step 3: Write the Terraform configuration

Create a file at terraform/main.tf:

terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}

provider "azurerm" {
features {}

environment = "public"
resource_provider_registrations = "none"
skip_provider_registration = true

# Topaz ARM endpoint
arm_endpoint = "https://topaz.local.dev:8899/"

# Any non-empty values are accepted by Topaz
subscription_id = "00000000-0000-0000-0000-000000000001"
tenant_id = "50717675-3E5E-4A1E-8CB5-C62D8BE8CA48"
client_id = "topaz-terraform"
client_secret = "topaz-terraform"
}

resource "azurerm_resource_group" "rg" {
name = "rg-iac-test"
location = "West Europe"
}

resource "azurerm_storage_account" "storage" {
name = "stiactest"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"

tags = {
environment = "test"
owner = "platform-team"
}
}

Step 4: Write the Bicep template

Create a file at bicep/storage.bicep:

param storageAccountName string = 'stbiceptest'
param location string = 'westeurope'

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' existing = {
name: 'rg-iac-test'
}

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
tags: {
environment: 'test'
owner: 'platform-team'
}
}

Step 5: Write the tests

using Azure.ResourceManager;
using Azure.ResourceManager.Storage;
using Topaz.Identity;
using Xunit;

[Collection("Topaz")]
public class TerraformIaCTests
{
private readonly ArmClient _arm;

public TerraformIaCTests()
{
_arm = new ArmClient(
new AzureLocalCredential(),
"00000000-0000-0000-0000-000000000001");
}

[Fact]
public async Task Apply_ShouldProvisionStorageAccount_WithCorrectSku()
{
// Apply the Terraform config against Topaz
RunTerraform("init");
RunTerraform("apply -auto-approve");

// Assert using the Azure SDK
var subscription = await _arm.GetDefaultSubscriptionAsync();
var rg = (await subscription.GetResourceGroupAsync("rg-iac-test")).Value;
var storage = (await rg.GetStorageAccountAsync("stiactest")).Value;

Assert.Equal("Standard_LRS", storage.Data.Sku.Name.ToString());
Assert.Equal("test", storage.Data.Tags["environment"]);
Assert.Equal("platform-team", storage.Data.Tags["owner"]);
}

[Fact]
public async Task Destroy_ShouldRemoveStorageAccount()
{
RunTerraform("apply -auto-approve");
RunTerraform("destroy -auto-approve");

var subscription = await _arm.GetDefaultSubscriptionAsync();
var rg = (await subscription.GetResourceGroupAsync("rg-iac-test")).Value;

// The storage account should no longer exist
var accounts = rg.GetStorageAccounts();
Assert.Empty(accounts.Where(a => a.Data.Name == "stiactest"));
}

private static void RunTerraform(string arguments)
{
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "terraform",
Arguments = arguments,
WorkingDirectory = "terraform",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
}
};
process.Start();
process.WaitForExit();

if (process.ExitCode != 0)
{
var error = process.StandardError.ReadToEnd();
throw new InvalidOperationException($"terraform {arguments} failed: {error}");
}
}
}

Step 6: Run the tests

Make sure Topaz is running (either as a background container started by the fixture, or manually via topaz-host), then run:

dotnet test

You should see the test suite start Topaz, apply the configuration, and assert the resources — all without touching a real Azure subscription.

Speed

The biggest time cost is terraform init on first run. After the provider is downloaded, subsequent test runs that reuse the same Terraform working directory are significantly faster.

Switching to production

The only difference between Topaz and real Azure is the ARM endpoint URL and the Terraform provider arm_endpoint setting. Your Terraform configurations, Bicep templates, and SDK assertion code are identical.

Star on GitHub