Skip to content

Infrastructure Testing Patterns with Terratest and Pulumi

Practical infrastructure testing with Terratest and Pulumi: from unit-testing resource configuration to integration tests against real cloud environments.

4 min read
Infrastructure testing pyramid showing unit tests for configuration, integration tests for provisioned resources, and end-to-end tests for full stack validation

Infrastructure code deserves the same testing rigor as application code, but the testing patterns look fundamentally different. You can't unit-test a VPC the same way you unit-test a function—there's real cloud infrastructure involved, deployments take minutes, and failures leave resources running that cost money.

Terratest and Pulumi's testing frameworks approach this problem differently but complement each other well. Terratest wraps Terraform with Go-based integration tests. Pulumi's testing SDK offers unit tests that mock cloud providers and integration tests that actually deploy. Understanding when to use each saves both time and cloud spend.

Unit Testing Infrastructure Configuration

Unit tests for infrastructure validate the shape and properties of resources without deploying anything. They run in milliseconds and catch configuration errors early.

gogo
// ❌ No tests — configuration errors discovered in production
// main.tf just applies and you hope it works
resource "aws_s3_bucket" "data" {
  bucket = "my-data-bucket"
  // Forgot versioning, encryption, public access block...
}
tstypescript
// ✅ Pulumi unit tests — validate resource properties before deploy
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
// The infrastructure module under test
function createSecureBucket(
  name: string
): aws.s3.Bucket {
  const bucket = new aws.s3.Bucket(name, {
    versioning: { enabled: true },
    serverSideEncryptionConfiguration: {
      rule: {
        applyServerSideEncryptionByDefault: {
          sseAlgorithm: "aws:kms",
        },
      },
    },
  });
 
  new aws.s3.BucketPublicAccessBlock(
    `${name}-public-access`,
    {
      bucket: bucket.id,
      blockPublicAcls: true,
      blockPublicPolicy: true,
      ignorePublicAcls: true,
      restrictPublicBuckets: true,
    }
  );
 
  return bucket;
}
 
// Unit test — no cloud resources created
describe("Secure S3 Bucket", () => {
  let resources: any[];
 
  beforeAll(() => {
    // Mock Pulumi runtime
    pulumi.runtime.setMocks({
      newResource: (args) => {
        resources.push(args);
        return { id: `${args.name}-id`, state: args.inputs };
      },
      call: (args) => args.inputs,
    });
    resources = [];
  });
 
  it("should enable versioning", async () => {
    const bucket = createSecureBucket("test-bucket");
 
    const versioning = await new Promise((resolve) =>
      bucket.versioning.apply((v) => resolve(v))
    );
 
    expect(versioning).toEqual({ enabled: true });
  });
 
  it("should create a public access block", () => {
    createSecureBucket("test-bucket");
 
    const publicAccessBlock = resources.find(
      (r) => r.type === "aws:s3:BucketPublicAccessBlock"
    );
 
    expect(publicAccessBlock).toBeDefined();
    expect(publicAccessBlock.inputs.blockPublicAcls).toBe(
      true
    );
    expect(publicAccessBlock.inputs.blockPublicPolicy).toBe(
      true
    );
  });
 
  it("should use KMS encryption", async () => {
    const bucket = createSecureBucket("test-bucket");
 
    const encryption = await new Promise((resolve) =>
      bucket.serverSideEncryptionConfiguration.apply((v) =>
        resolve(v)
      )
    );
 
    expect(encryption).toEqual({
      rule: {
        applyServerSideEncryptionByDefault: {
          sseAlgorithm: "aws:kms",
        },
      },
    });
  });
});

These tests validate that your infrastructure module produces the expected resource configurations. They catch issues like missing encryption, disabled versioning, or absent access controls without spending a minute waiting for a Terraform plan.

Integration Testing with Terratest

Integration tests deploy real infrastructure, validate it works, and tear it down. Terratest is the established tool for this with Terraform.

gogo
package test
 
import (
	"crypto/tls"
	"fmt"
	"testing"
	"time"
 
	"github.com/gruntwork-io/terratest/modules/http-helper"
	"github.com/gruntwork-io/terratest/modules/random"
	"github.com/gruntwork-io/terratest/modules/terraform"
)
 
func TestWebServerModule(t *testing.T) {
	t.Parallel()
 
	uniqueID := random.UniqueId()
	instanceName := fmt.Sprintf("test-web-%s", uniqueID)
 
	terraformOptions := terraform.WithDefaultRetryableErrors(
		t,
		&terraform.Options{
			TerraformDir: "../modules/web-server",
			Vars: map[string]interface{}{
				"instance_name": instanceName,
				"environment":   "test",
				"instance_type": "t3.micro",
			},
			// Prevent destruction on first pass for debugging
			// Comment out for CI
			// EnvVars: map[string]string{
			// 	"SKIP_destroy": "true",
			// },
		},
	)
 
	// Always clean up resources when test finishes
	defer terraform.Destroy(t, terraformOptions)
 
	// Deploy the infrastructure
	terraform.InitAndApply(t, terraformOptions)
 
	// Retrieve outputs for validation
	publicIP := terraform.Output(t, terraformOptions, "public_ip")
	instanceID := terraform.Output(
		t,
		terraformOptions,
		"instance_id",
	)
 
	// Validate the server is responding
	url := fmt.Sprintf("http://%s:80", publicIP)
	tlsConfig := tls.Config{InsecureSkipVerify: false}
 
	http_helper.HttpGetWithRetryWithCustomValidation(
		t,
		url,
		&tlsConfig,
		30,           // max retries
		5*time.Second, // sleep between retries
		func(status int, body string) bool {
			return status == 200
		},
	)
 
	// Validate instance tags
	expectedTags := map[string]string{
		"Name":        instanceName,
		"Environment": "test",
		"ManagedBy":   "terraform",
	}
 
	actualTags := terraform.OutputMap(
		t,
		terraformOptions,
		"instance_tags",
	)
 
	for key, expected := range expectedTags {
		actual, exists := actualTags[key]
		if !exists {
			t.Errorf("Expected tag %s not found", key)
		}
		if actual != expected {
			t.Errorf(
				"Tag %s: expected %s, got %s",
				key,
				expected,
				actual,
			)
		}
	}
}

The defer terraform.Destroy pattern ensures cleanup even when tests fail. Running tests in parallel with unique IDs prevents resource naming conflicts when multiple test runs execute simultaneously in CI.

Testing Network Configuration

Network infrastructure tests verify that security groups, routing, and connectivity work correctly after deployment.

gogo
func TestVPCNetworkIsolation(t *testing.T) {
	t.Parallel()
 
	uniqueID := random.UniqueId()
 
	terraformOptions := terraform.WithDefaultRetryableErrors(
		t,
		&terraform.Options{
			TerraformDir: "../modules/vpc",
			Vars: map[string]interface{}{
				"vpc_name":   fmt.Sprintf("test-vpc-%s", uniqueID),
				"cidr_block": "10.0.0.0/16",
				"environment": "test",
			},
		},
	)
 
	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)
 
	vpcID := terraform.Output(t, terraformOptions, "vpc_id")
	privateSubnetIDs := terraform.OutputList(
		t,
		terraformOptions,
		"private_subnet_ids",
	)
	publicSubnetIDs := terraform.OutputList(
		t,
		terraformOptions,
		"public_subnet_ids",
	)
 
	// Verify subnet counts
	if len(privateSubnetIDs) < 2 {
		t.Errorf(
			"Expected at least 2 private subnets, got %d",
			len(privateSubnetIDs),
		)
	}
 
	if len(publicSubnetIDs) < 2 {
		t.Errorf(
			"Expected at least 2 public subnets, got %d",
			len(publicSubnetIDs),
		)
	}
 
	// Verify private subnets don't have public IPs
	for _, subnetID := range privateSubnetIDs {
		subnet := aws.GetSubnet(t, subnetID, "us-east-1")
		if subnet.MapPublicIpOnLaunch {
			t.Errorf(
				"Private subnet %s has public IP mapping enabled",
				subnetID,
			)
		}
	}
 
	// Verify VPC has expected CIDR
	vpc := aws.GetVpcById(t, vpcID, "us-east-1")
	if vpc.CidrBlock != "10.0.0.0/16" {
		t.Errorf("VPC CIDR: expected 10.0.0.0/16, got %s", vpc.CidrBlock)
	}
}

Structuring the Test Pipeline

Infrastructure tests are slow. Organizing them into tiers and running them selectively keeps the feedback loop tight.

ymlyaml
# .github/workflows/infra-test.yml
name: Infrastructure Tests
 
on:
  pull_request:
    paths:
      - 'infra/**'
      - 'modules/**'
 
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Pulumi unit tests
        run: |
          cd infra
          npm ci
          npm test -- --testPathPattern=unit
 
  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    concurrency:
      group: infra-integration-${{ github.ref }}
      cancel-in-progress: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v4
        with:
          go-version: '1.21'
      - name: Run Terratest integration tests
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_TEST_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_TEST_SECRET }}
          AWS_DEFAULT_REGION: us-east-1
        run: |
          cd test
          go test -v -timeout 30m -run TestBasic ./...
 
  full-stack-tests:
    runs-on: ubuntu-latest
    needs: integration-tests
    if: github.event.pull_request.labels.*.name == 'infra-full-test'
    steps:
      - uses: actions/checkout@v4
      - name: Run full stack tests
        run: |
          cd test
          go test -v -timeout 60m ./...

The concurrency group ensures only one integration test runs per PR branch, preventing resource conflicts. Full-stack tests only run when explicitly triggered by a label, since they're the most expensive.

Key Takeaways

Unit tests for infrastructure using mocked providers catch configuration errors—missing encryption, wrong tags, absent security controls—in milliseconds without any cloud deployment. Terratest integration tests deploy real resources, verify they work, and destroy them automatically with defer terraform.Destroy, ensuring cleanup even on test failure. Unique IDs via random.UniqueId() and t.Parallel() let integration tests run concurrently without resource name collisions. Network tests should verify the security-critical properties: private subnets don't map public IPs, security groups restrict access correctly, and routing tables isolate traffic. A tiered pipeline—unit tests on every push, integration tests on PRs, full-stack tests on explicit trigger—balances feedback speed with cloud cost. The combination of Pulumi's unit testing for fast resource validation and Terratest's integration testing for real deployment verification covers the infrastructure testing spectrum from seconds to minutes.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX