Untested infrastructure is broken infrastructure waiting to happen. Terratest brings software engineering testing practices to Infrastructure as Code, allowing you to write automated tests that deploy real infrastructure and verify it works.

Why Test Infrastructure?

Testing Terraform code prevents:

  • Syntax errors that terraform validate misses
  • Logical errors in module composition
  • Regressions when updating modules
  • Configuration drift from documentation
  • “Works on my machine” deployments

Getting Started with Terratest

Terratest is a Go library for testing infrastructure code. You write tests in Go, and Terratest handles the heavy lifting.

Project Setup

terraform-modules/
├── modules/
│   └── vpc/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── test/
    ├── go.mod
    ├── go.sum
    └── vpc_test.go

Initialize Go module:

cd test
go mod init github.com/myorg/terraform-modules/test
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert

Basic Test Structure

// test/vpc_test.go
package test

import (
    "testing"

    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVpcModule(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        // Path to the Terraform module
        TerraformDir: "../modules/vpc",

        // Variables to pass to the module
        Vars: map[string]interface{}{
            "name":               "test-vpc",
            "cidr_block":         "10.0.0.0/16",
            "availability_zones": []string{"us-east-1a", "us-east-1b"},
        },

        // Environment variables
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": "us-east-1",
        },
    }

    // Clean up resources after test
    defer terraform.Destroy(t, terraformOptions)

    // Deploy the infrastructure
    terraform.InitAndApply(t, terraformOptions)

    // Get outputs
    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")

    // Assertions
    assert.NotEmpty(t, vpcId)
    assert.Equal(t, 2, len(publicSubnetIds))
}

Run the test:

cd test
go test -v -timeout 30m ./...

Testing AWS Resources

Testing EC2 Instances

package test

import (
    "testing"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestEc2Instance(t *testing.T) {
    t.Parallel()

    awsRegion := "us-east-1"

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/ec2",
        Vars: map[string]interface{}{
            "instance_type": "t3.micro",
            "name":          "test-instance",
        },
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": awsRegion,
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    // Get instance ID from Terraform output
    instanceId := terraform.Output(t, terraformOptions, "instance_id")

    // Verify instance is running using AWS SDK
    instance := aws.GetEc2InstanceById(t, instanceId, awsRegion)
    
    assert.Equal(t, "running", instance.State.Name)
    assert.Equal(t, "t3.micro", instance.InstanceType)
    
    // Verify tags
    expectedTags := map[string]string{
        "Name":      "test-instance",
        "ManagedBy": "terraform",
    }
    actualTags := aws.GetTagsForEc2Instance(t, awsRegion, instanceId)
    for key, expectedValue := range expectedTags {
        assert.Equal(t, expectedValue, actualTags[key])
    }
}

Testing S3 Buckets

func TestS3Bucket(t *testing.T) {
    t.Parallel()

    awsRegion := "us-east-1"
    bucketName := fmt.Sprintf("test-bucket-%s", random.UniqueId())

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/s3",
        Vars: map[string]interface{}{
            "bucket_name":   bucketName,
            "enable_versioning": true,
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    // Verify bucket exists
    aws.AssertS3BucketExists(t, awsRegion, bucketName)

    // Verify versioning is enabled
    actualVersioning := aws.GetS3BucketVersioning(t, awsRegion, bucketName)
    assert.Equal(t, "Enabled", actualVersioning)

    // Verify bucket is encrypted
    actualEncryption := aws.GetS3BucketEncryption(t, awsRegion, bucketName)
    assert.NotEmpty(t, actualEncryption)
}

Testing Security Groups

func TestSecurityGroup(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/security-group",
        Vars: map[string]interface{}{
            "name":        "test-sg",
            "vpc_id":      "vpc-12345",
            "allowed_ports": []int{80, 443},
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    sgId := terraform.Output(t, terraformOptions, "security_group_id")
    sg := aws.GetEc2SecurityGroup(t, sgId, "us-east-1")

    // Verify inbound rules
    assert.Len(t, sg.IpPermissions, 2)
    
    // Check port 80 rule exists
    found80 := false
    for _, rule := range sg.IpPermissions {
        if *rule.FromPort == 80 && *rule.ToPort == 80 {
            found80 = true
            assert.Equal(t, "0.0.0.0/0", *rule.IpRanges[0].CidrIp)
        }
    }
    assert.True(t, found80, "Port 80 rule not found")
}

HTTP Testing

Test that deployed services are actually reachable:

package test

import (
    "testing"
    "time"

    http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
    "github.com/gruntwork-io/terratest/modules/terraform"
)

func TestWebServer(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/web-server",
        Vars: map[string]interface{}{
            "instance_type": "t3.micro",
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    // Get the public URL
    publicUrl := terraform.Output(t, terraformOptions, "public_url")

    // Verify the service is responding
    maxRetries := 30
    timeBetweenRetries := 10 * time.Second

    http_helper.HttpGetWithRetry(
        t,
        publicUrl+"/health",
        nil,
        200,
        "OK",
        maxRetries,
        timeBetweenRetries,
    )
}

Test Fixtures

Create reusable test fixtures for complex setups:

// test/fixtures.go
package test

import (
    "testing"
    
    "github.com/gruntwork-io/terratest/modules/terraform"
)

type VpcFixture struct {
    VpcId           string
    PublicSubnetIds []string
    TerraformOptions *terraform.Options
}

func DeployVpcFixture(t *testing.T) *VpcFixture {
    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/vpc",
        Vars: map[string]interface{}{
            "name":               "test-fixture",
            "cidr_block":         "10.0.0.0/16",
            "availability_zones": []string{"us-east-1a", "us-east-1b"},
        },
    }

    terraform.InitAndApply(t, terraformOptions)

    return &VpcFixture{
        VpcId:            terraform.Output(t, terraformOptions, "vpc_id"),
        PublicSubnetIds:  terraform.OutputList(t, terraformOptions, "public_subnet_ids"),
        TerraformOptions: terraformOptions,
    }
}

func (f *VpcFixture) Destroy(t *testing.T) {
    terraform.Destroy(t, f.TerraformOptions)
}

Use in tests:

func TestEksCluster(t *testing.T) {
    t.Parallel()

    // Deploy VPC first
    vpc := DeployVpcFixture(t)
    defer vpc.Destroy(t)

    // Deploy EKS using the VPC
    eksOptions := &terraform.Options{
        TerraformDir: "../modules/eks",
        Vars: map[string]interface{}{
            "vpc_id":     vpc.VpcId,
            "subnet_ids": vpc.PublicSubnetIds,
            "cluster_name": "test-cluster",
        },
    }

    defer terraform.Destroy(t, eksOptions)
    terraform.InitAndApply(t, eksOptions)

    // Test EKS cluster...
}

Test Stages

For long-running tests, use stages to skip certain phases:

func TestCompleteInfrastructure(t *testing.T) {
    t.Parallel()

    workingDir := "../examples/complete"
    
    terraformOptions := &terraform.Options{
        TerraformDir: workingDir,
    }

    // Stage: Deploy
    defer test_structure.RunTestStage(t, "cleanup", func() {
        terraform.Destroy(t, terraformOptions)
    })

    test_structure.RunTestStage(t, "deploy", func() {
        terraform.InitAndApply(t, terraformOptions)
    })

    // Stage: Validate
    test_structure.RunTestStage(t, "validate", func() {
        validateInfrastructure(t, terraformOptions)
    })

    // Stage: Redeploy (test idempotency)
    test_structure.RunTestStage(t, "redeploy", func() {
        terraform.ApplyAndIdempotent(t, terraformOptions)
    })
}

Skip stages with environment variables:

# Skip cleanup for debugging
SKIP_cleanup=true go test -v -timeout 60m

# Only run validation (assumes deploy already done)
SKIP_deploy=true go test -v -timeout 10m

Testing Best Practices

Use Unique Names

import "github.com/gruntwork-io/terratest/modules/random"

func TestModule(t *testing.T) {
    uniqueId := random.UniqueId()
    
    terraformOptions := &terraform.Options{
        Vars: map[string]interface{}{
            "name": fmt.Sprintf("test-%s", uniqueId),
        },
    }
    // ...
}

Parallel Testing

func TestVpc(t *testing.T) {
    t.Parallel()  // Always add this
    // ...
}

func TestEks(t *testing.T) {
    t.Parallel()
    // ...
}

Timeouts

# Tests can be slow - increase timeout
go test -v -timeout 60m ./...

Environment Isolation

func TestModule(t *testing.T) {
    // Use different region per test to avoid conflicts
    awsRegion := aws.GetRandomRegion(t, nil, []string{"us-east-1", "us-west-2"})
    
    terraformOptions := &terraform.Options{
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": awsRegion,
        },
    }
}

CI/CD Integration

GitHub Actions

# .github/workflows/test.yml
name: Test Terraform Modules

on:
  pull_request:
    paths:
      - 'modules/**'
      - 'test/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Run tests
        run: |
          cd test
          go test -v -timeout 60m ./...

Key Takeaways

  1. Test real infrastructure — mocks miss real-world issues
  2. Always clean up — use defer terraform.Destroy()
  3. Use unique names — prevents conflicts in parallel tests
  4. Test idempotency — run apply twice, verify no changes
  5. Test outputs — verify modules produce expected values
  6. HTTP testing — verify services actually respond
  7. Use stages for debugging long-running tests

“If you can’t test it, you can’t trust it. Infrastructure is no different from application code.”