DevOpsil
Terraform
93%
Fresh

Testing Terraform with Terratest: A Practical Guide

Zara BlackwoodZara Blackwood8 min read

Untested Infrastructure Is a Liability

You wouldn't ship application code without tests. So why are you shipping infrastructure without them? Every terraform apply on untested code is a gamble — and in production, the house always wins.

Terratest is a Go library that deploys real infrastructure, validates it, and tears it down. No mocks — real resources in a real cloud account. Here's how I test every module before it gets near production.

The Module Under Test

# modules/s3-bucket/main.tf

resource "aws_s3_bucket" "this" {
  bucket = "${var.name_prefix}-${var.environment}-${var.bucket_name}"
  tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
  })
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration {
    status = var.enable_versioning ? "Enabled" : "Suspended"
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Test Fixture

# modules/s3-bucket/test/fixtures/basic/main.tf

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  default = "us-east-1"
}

variable "test_id" {
  description = "Unique ID for test isolation"
  type        = string
}

module "s3_bucket" {
  source            = "../../../"
  name_prefix       = "test"
  environment       = "ci"
  bucket_name       = var.test_id
  enable_versioning = true
  tags              = { Test = "true" }
}

output "bucket_id" {
  value = module.s3_bucket.bucket_id
}

Every fixture takes a test_id. Terratest generates a unique ID per run so parallel tests never collide.

Writing the Integration Test

package test

import (
	"strings"
	"testing"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gruntwork-io/terratest/modules/random"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestS3BucketCreation(t *testing.T) {
	t.Parallel()
	uniqueID := strings.ToLower(random.UniqueId())

	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "./fixtures/basic",
		Vars: map[string]interface{}{
			"test_id":    uniqueID,
			"aws_region": "us-east-1",
		},
	})
	defer terraform.Destroy(t, opts)
	terraform.InitAndApply(t, opts)

	bucketID := terraform.Output(t, opts, "bucket_id")
	assert.Contains(t, bucketID, uniqueID)

	// Validate actual AWS resource, not just Terraform output
	sess := session.Must(session.NewSession(&aws.Config{
		Region: aws.String("us-east-1"),
	}))
	svc := s3.New(sess)

	enc, err := svc.GetBucketEncryption(&s3.GetBucketEncryptionInput{
		Bucket: aws.String(bucketID),
	})
	require.NoError(t, err)
	assert.Equal(t, "aws:kms",
		*enc.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm)

	acl, err := svc.GetPublicAccessBlock(&s3.GetPublicAccessBlockInput{
		Bucket: aws.String(bucketID),
	})
	require.NoError(t, err)
	assert.True(t, *acl.PublicAccessBlockConfiguration.BlockPublicAcls)
}

Key decisions: t.Parallel() for concurrency, defer Destroy for cleanup, and AWS SDK calls to validate what actually happened — not what Terraform thinks happened.

Unit Tests with Plan

Not every test needs real infrastructure. Plan-based tests give fast feedback.

func TestS3BucketPlanOnly(t *testing.T) {
	t.Parallel()
	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "./fixtures/basic",
		Vars: map[string]interface{}{
			"test_id": strings.ToLower(random.UniqueId()),
		},
	})

	plan := terraform.InitAndPlanAndShowWithStruct(t, opts)
	// Verify no destructive changes
	for _, changes := range plan.ResourceChangesMap {
		for _, c := range changes {
			assert.NotContains(t, c.Change.Actions, "delete")
		}
	}
}

Plan tests run in seconds. Use them in pre-commit hooks. Save integration tests for CI.

Running Tests in CI

# .github/workflows/terraform-test.yml
name: Terraform Module Tests
on:
  pull_request:
    paths: ['modules/**']

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.8.0"
      - name: Validate All Modules
        run: |
          for dir in modules/*/; do
            terraform -chdir="$dir" init -backend=false
            terraform -chdir="$dir" validate
          done

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-arn: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/ci-terratest
          aws-region: us-east-1
      - name: Run Terratest
        run: go test -v -timeout 30m -count=1 ./...
        working-directory: modules/s3-bucket/test

Testing Terraform Modules with the Built-in Test Framework

Terraform 1.6+ includes a native test framework. No Go required.

# modules/s3-bucket/tests/basic.tftest.hcl

variables {
  name_prefix       = "test"
  environment       = "ci"
  bucket_name       = "tftest-basic"
  enable_versioning = true
  tags              = { Test = "true" }
}

run "creates_bucket_with_correct_tags" {
  command = plan

  assert {
    condition     = aws_s3_bucket.this.tags["Environment"] == "ci"
    error_message = "Expected Environment tag to be 'ci'"
  }

  assert {
    condition     = aws_s3_bucket.this.tags["ManagedBy"] == "terraform"
    error_message = "Expected ManagedBy tag to be 'terraform'"
  }
}

run "versioning_is_enabled" {
  command = plan

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
    error_message = "Versioning should be enabled"
  }
}

run "public_access_is_blocked" {
  command = plan

  assert {
    condition     = aws_s3_bucket_public_access_block.this.block_public_acls == true
    error_message = "Public ACLs should be blocked"
  }

  assert {
    condition     = aws_s3_bucket_public_access_block.this.restrict_public_buckets == true
    error_message = "Public buckets should be restricted"
  }
}

Run with:

terraform test

Native tests run in seconds and need no additional tooling. Use them for plan-level validations. Keep Terratest for integration tests that need AWS API verification.

Testing Patterns: What to Test and When

Test TypeToolSpeedWhat It ValidatesWhen to Run
terraform validateTerraform CLISecondsSyntax, type errorsPre-commit hooks
terraform plan assertionstftest.hclSecondsResource configurationPre-commit, CI
Plan-based Go testsTerratestSecondsComplex plan logicCI on every PR
Integration testsTerratestMinutesReal cloud resourcesCI on module changes
Contract testsTerratestSecondsModule interface stabilityCI on every PR

Contract Tests: Protect Your Module's API

When other teams consume your module, changes to outputs are breaking changes. Test for that.

func TestModuleOutputsExist(t *testing.T) {
	t.Parallel()
	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "./fixtures/basic",
		Vars: map[string]interface{}{
			"test_id": strings.ToLower(random.UniqueId()),
		},
	})

	plan := terraform.InitAndPlanAndShowWithStruct(t, opts)

	// Verify expected outputs exist in the plan
	expectedOutputs := []string{
		"bucket_id",
		"bucket_arn",
		"bucket_domain_name",
	}

	for _, output := range expectedOutputs {
		_, exists := plan.RawPlan.OutputChanges[output]
		assert.True(t, exists, "Expected output '%s' to exist", output)
	}
}

If someone removes or renames an output, the contract test fails before the change reaches consumers.

Testing with Mocks: AWS LocalStack

For faster integration tests without real AWS costs, use LocalStack:

func TestS3BucketWithLocalStack(t *testing.T) {
	t.Parallel()
	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "./fixtures/basic",
		Vars: map[string]interface{}{
			"test_id": strings.ToLower(random.UniqueId()),
		},
		EnvVars: map[string]string{
			"AWS_ACCESS_KEY_ID":     "test",
			"AWS_SECRET_ACCESS_KEY": "test",
			"AWS_DEFAULT_REGION":    "us-east-1",
		},
		BackendConfig: map[string]interface{}{
			"endpoint": "http://localhost:4566",
		},
	})
	defer terraform.Destroy(t, opts)
	terraform.InitAndApply(t, opts)

	bucketID := terraform.Output(t, opts, "bucket_id")
	assert.NotEmpty(t, bucketID)
}

LocalStack tests run in 10-20 seconds instead of 2-5 minutes. The tradeoff: some AWS behaviors differ from the real API. Use LocalStack for fast feedback loops and real AWS for final validation.

Test Organization for Large Module Libraries

modules/
├── s3-bucket/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   ├── tests/
│   │   ├── basic.tftest.hcl        # Native Terraform tests
│   │   ├── fixtures/
│   │   │   ├── basic/main.tf
│   │   │   └── with-replication/main.tf
│   │   ├── s3_test.go              # Terratest integration tests
│   │   └── go.mod
│   └── README.md
├── vpc/
│   ├── ...
│   └── tests/
│       ├── basic.tftest.hcl
│       ├── fixtures/
│       └── vpc_test.go
└── Makefile

A Makefile to run everything:

.PHONY: test-all test-unit test-integration

test-all: test-unit test-integration

test-unit:
	@for dir in modules/*/; do \
		echo "=== Testing $$dir ==="; \
		terraform -chdir="$$dir" init -backend=false; \
		terraform -chdir="$$dir" validate; \
		terraform -chdir="$$dir" test 2>/dev/null || true; \
	done

test-integration:
	@for dir in modules/*/test; do \
		if [ -f "$$dir/go.mod" ]; then \
			echo "=== Integration tests for $$dir ==="; \
			cd "$$dir" && go test -v -timeout 30m -count=1 ./... && cd -; \
		fi \
	done

test-module:
	terraform -chdir="modules/$(MODULE)" test
	cd modules/$(MODULE)/test && go test -v -timeout 30m -count=1 ./...

Run make test-unit for fast feedback. Run make test-integration in CI. Run make test-module MODULE=s3-bucket when working on a specific module.

Cleanup Strategy for Test Resources

Test resources that survive their test run are orphaned infrastructure. You pay for them until someone notices.

// In every test: tag resources for cleanup
func TestVPCCreation(t *testing.T) {
	opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "./fixtures/basic",
		Vars: map[string]interface{}{
			"test_id": strings.ToLower(random.UniqueId()),
			"tags": map[string]string{
				"Test":      "true",
				"CreatedAt": time.Now().UTC().Format(time.RFC3339),
				"TestName":  t.Name(),
			},
		},
	})
	defer terraform.Destroy(t, opts)
	terraform.InitAndApply(t, opts)
}

Nightly cleanup Lambda:

import boto3
from datetime import datetime, timedelta, timezone

def handler(event, context):
    ec2 = boto3.resource('ec2')
    threshold = datetime.now(timezone.utc) - timedelta(hours=24)

    instances = ec2.instances.filter(
        Filters=[{'Name': 'tag:Test', 'Values': ['true']}]
    )

    for instance in instances:
        created_tag = next(
            (t['Value'] for t in instance.tags if t['Key'] == 'CreatedAt'),
            None
        )
        if created_tag:
            created_at = datetime.fromisoformat(created_tag)
            if created_at < threshold:
                print(f"Terminating orphaned test instance: {instance.id}")
                instance.terminate()

Troubleshooting

Problem: Tests pass locally but fail in CI. Fix: CI roles have narrower permissions. Run aws sts get-caller-identity in both environments and compare. Add missing permissions to the CI role.

Problem: Parallel tests fail with naming collisions. Fix: Every resource name must include the unique test ID. Hardcoded names are the bug. Grep your fixtures for any resource name that doesn't include var.test_id.

Problem: Destroy fails, leaving orphaned resources. Fix: Tag-based cleanup. A nightly Lambda deletes resources tagged Test = true older than 24 hours. Also check for resources with DeletionProtection enabled — Terratest can't destroy those without disabling protection first.

Problem: Tests take 20+ minutes. Fix: Split into plan-only unit tests (seconds) and deploy-based integration tests (minutes). Only run integration tests on module changes using path filters in CI.

Problem: Flaky tests due to AWS eventual consistency. Fix: Use terraform.WithDefaultRetryableErrors() which retries on common AWS errors. For custom retry logic, use retry.DoWithRetry() from the Terratest library.

Conclusion

Testing infrastructure is not optional. Use native terraform test for fast plan-level validation, Terratest for integration tests that verify real cloud resources, and contract tests to protect module interfaces. Run unit tests on every commit, integration tests on module changes, and clean up test resources nightly. The cost of a test AWS account is a fraction of the cost of a production outage from an untested module change.

Share:
Zara Blackwood
Zara Blackwood

Platform Engineer

Terraform enthusiast, platform builder, DRY advocate. I believe infrastructure should be versioned, reviewed, and deployed like any other code. GitOps or bust.

Related Articles

TerraformQuick RefFresh

Terraform CLI: Cheat Sheet

Terraform CLI cheat sheet with commands organized by workflow — init, plan, apply, destroy, state manipulation, imports, and workspace management.

Zara Blackwood·
3 min read